├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── benchmarks.py ├── dataclassy ├── __init__.py ├── dataclass.py ├── decorator.py ├── functions.py ├── mypy.py └── py.typed ├── mypy.ini ├── setup.py └── tests.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-20.04 8 | strategy: 9 | matrix: 10 | python-version: [3.6, 3.7, 3.8, 3.9, '3.10', 3.11, 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Unit tests 19 | run: | 20 | python tests.py 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | /.mypy_cache 4 | /dist 5 | /MANIFEST 6 | /*.egg-info 7 | 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | ### 1. Definitions 5 | 6 | **1.1. “Contributor”** 7 | means each individual or legal entity that creates, contributes to 8 | the creation of, or owns Covered Software. 9 | 10 | **1.2. “Contributor Version”** 11 | means the combination of the Contributions of others (if any) used 12 | by a Contributor and that particular Contributor's Contribution. 13 | 14 | **1.3. “Contribution”** 15 | means Covered Software of a particular Contributor. 16 | 17 | **1.4. “Covered Software”** 18 | means Source Code Form to which the initial Contributor has attached 19 | the notice in Exhibit A, the Executable Form of such Source Code 20 | Form, and Modifications of such Source Code Form, in each case 21 | including portions thereof. 22 | 23 | **1.5. “Incompatible With Secondary Licenses”** 24 | means 25 | 26 | * **(a)** that the initial Contributor has attached the notice described 27 | in Exhibit B to the Covered Software; or 28 | * **(b)** that the Covered Software was made available under the terms of 29 | version 1.1 or earlier of the License, but not also under the 30 | terms of a Secondary License. 31 | 32 | **1.6. “Executable Form”** 33 | means any form of the work other than Source Code Form. 34 | 35 | **1.7. “Larger Work”** 36 | means a work that combines Covered Software with other material, in 37 | a separate file or files, that is not Covered Software. 38 | 39 | **1.8. “License”** 40 | means this document. 41 | 42 | **1.9. “Licensable”** 43 | means having the right to grant, to the maximum extent possible, 44 | whether at the time of the initial grant or subsequently, any and 45 | all of the rights conveyed by this License. 46 | 47 | **1.10. “Modifications”** 48 | means any of the following: 49 | 50 | * **(a)** any file in Source Code Form that results from an addition to, 51 | deletion from, or modification of the contents of Covered 52 | Software; or 53 | * **(b)** any new file in Source Code Form that contains any Covered 54 | Software. 55 | 56 | **1.11. “Patent Claims” of a Contributor** 57 | means any patent claim(s), including without limitation, method, 58 | process, and apparatus claims, in any patent Licensable by such 59 | Contributor that would be infringed, but for the grant of the 60 | License, by the making, using, selling, offering for sale, having 61 | made, import, or transfer of either its Contributions or its 62 | Contributor Version. 63 | 64 | **1.12. “Secondary License”** 65 | means either the GNU General Public License, Version 2.0, the GNU 66 | Lesser General Public License, Version 2.1, the GNU Affero General 67 | Public License, Version 3.0, or any later versions of those 68 | licenses. 69 | 70 | **1.13. “Source Code Form”** 71 | means the form of the work preferred for making modifications. 72 | 73 | **1.14. “You” (or “Your”)** 74 | means an individual or a legal entity exercising rights under this 75 | License. For legal entities, “You” includes any entity that 76 | controls, is controlled by, or is under common control with You. For 77 | purposes of this definition, “control” means **(a)** the power, direct 78 | or indirect, to cause the direction or management of such entity, 79 | whether by contract or otherwise, or **(b)** ownership of more than 80 | fifty percent (50%) of the outstanding shares or beneficial 81 | ownership of such entity. 82 | 83 | 84 | ### 2. License Grants and Conditions 85 | 86 | #### 2.1. Grants 87 | 88 | Each Contributor hereby grants You a world-wide, royalty-free, 89 | non-exclusive license: 90 | 91 | * **(a)** under intellectual property rights (other than patent or trademark) 92 | Licensable by such Contributor to use, reproduce, make available, 93 | modify, display, perform, distribute, and otherwise exploit its 94 | Contributions, either on an unmodified basis, with Modifications, or 95 | as part of a Larger Work; and 96 | * **(b)** under Patent Claims of such Contributor to make, use, sell, offer 97 | for sale, have made, import, and otherwise transfer either its 98 | Contributions or its Contributor Version. 99 | 100 | #### 2.2. Effective Date 101 | 102 | The licenses granted in Section 2.1 with respect to any Contribution 103 | become effective for each Contribution on the date the Contributor first 104 | distributes such Contribution. 105 | 106 | #### 2.3. Limitations on Grant Scope 107 | 108 | The licenses granted in this Section 2 are the only rights granted under 109 | this License. No additional rights or licenses will be implied from the 110 | distribution or licensing of Covered Software under this License. 111 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 112 | Contributor: 113 | 114 | * **(a)** for any code that a Contributor has removed from Covered Software; 115 | or 116 | * **(b)** for infringements caused by: **(i)** Your and any other third party's 117 | modifications of Covered Software, or **(ii)** the combination of its 118 | Contributions with other software (except as part of its Contributor 119 | Version); or 120 | * **(c)** under Patent Claims infringed by Covered Software in the absence of 121 | its Contributions. 122 | 123 | This License does not grant any rights in the trademarks, service marks, 124 | or logos of any Contributor (except as may be necessary to comply with 125 | the notice requirements in Section 3.4). 126 | 127 | #### 2.4. Subsequent Licenses 128 | 129 | No Contributor makes additional grants as a result of Your choice to 130 | distribute the Covered Software under a subsequent version of this 131 | License (see Section 10.2) or under the terms of a Secondary License (if 132 | permitted under the terms of Section 3.3). 133 | 134 | #### 2.5. Representation 135 | 136 | Each Contributor represents that the Contributor believes its 137 | Contributions are its original creation(s) or it has sufficient rights 138 | to grant the rights to its Contributions conveyed by this License. 139 | 140 | #### 2.6. Fair Use 141 | 142 | This License is not intended to limit any rights You have under 143 | applicable copyright doctrines of fair use, fair dealing, or other 144 | equivalents. 145 | 146 | #### 2.7. Conditions 147 | 148 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 149 | in Section 2.1. 150 | 151 | 152 | ### 3. Responsibilities 153 | 154 | #### 3.1. Distribution of Source Form 155 | 156 | All distribution of Covered Software in Source Code Form, including any 157 | Modifications that You create or to which You contribute, must be under 158 | the terms of this License. You must inform recipients that the Source 159 | Code Form of the Covered Software is governed by the terms of this 160 | License, and how they can obtain a copy of this License. You may not 161 | attempt to alter or restrict the recipients' rights in the Source Code 162 | Form. 163 | 164 | #### 3.2. Distribution of Executable Form 165 | 166 | If You distribute Covered Software in Executable Form then: 167 | 168 | * **(a)** such Covered Software must also be made available in Source Code 169 | Form, as described in Section 3.1, and You must inform recipients of 170 | the Executable Form how they can obtain a copy of such Source Code 171 | Form by reasonable means in a timely manner, at a charge no more 172 | than the cost of distribution to the recipient; and 173 | 174 | * **(b)** You may distribute such Executable Form under the terms of this 175 | License, or sublicense it under different terms, provided that the 176 | license for the Executable Form does not attempt to limit or alter 177 | the recipients' rights in the Source Code Form under this License. 178 | 179 | #### 3.3. Distribution of a Larger Work 180 | 181 | You may create and distribute a Larger Work under terms of Your choice, 182 | provided that You also comply with the requirements of this License for 183 | the Covered Software. If the Larger Work is a combination of Covered 184 | Software with a work governed by one or more Secondary Licenses, and the 185 | Covered Software is not Incompatible With Secondary Licenses, this 186 | License permits You to additionally distribute such Covered Software 187 | under the terms of such Secondary License(s), so that the recipient of 188 | the Larger Work may, at their option, further distribute the Covered 189 | Software under the terms of either this License or such Secondary 190 | License(s). 191 | 192 | #### 3.4. Notices 193 | 194 | You may not remove or alter the substance of any license notices 195 | (including copyright notices, patent notices, disclaimers of warranty, 196 | or limitations of liability) contained within the Source Code Form of 197 | the Covered Software, except that You may alter any license notices to 198 | the extent required to remedy known factual inaccuracies. 199 | 200 | #### 3.5. Application of Additional Terms 201 | 202 | You may choose to offer, and to charge a fee for, warranty, support, 203 | indemnity or liability obligations to one or more recipients of Covered 204 | Software. However, You may do so only on Your own behalf, and not on 205 | behalf of any Contributor. You must make it absolutely clear that any 206 | such warranty, support, indemnity, or liability obligation is offered by 207 | You alone, and You hereby agree to indemnify every Contributor for any 208 | liability incurred by such Contributor as a result of warranty, support, 209 | indemnity or liability terms You offer. You may include additional 210 | disclaimers of warranty and limitations of liability specific to any 211 | jurisdiction. 212 | 213 | 214 | ### 4. Inability to Comply Due to Statute or Regulation 215 | 216 | If it is impossible for You to comply with any of the terms of this 217 | License with respect to some or all of the Covered Software due to 218 | statute, judicial order, or regulation then You must: **(a)** comply with 219 | the terms of this License to the maximum extent possible; and **(b)** 220 | describe the limitations and the code they affect. Such description must 221 | be placed in a text file included with all distributions of the Covered 222 | Software under this License. Except to the extent prohibited by statute 223 | or regulation, such description must be sufficiently detailed for a 224 | recipient of ordinary skill to be able to understand it. 225 | 226 | 227 | ### 5. Termination 228 | 229 | **5.1.** The rights granted under this License will terminate automatically 230 | if You fail to comply with any of its terms. However, if You become 231 | compliant, then the rights granted under this License from a particular 232 | Contributor are reinstated **(a)** provisionally, unless and until such 233 | Contributor explicitly and finally terminates Your grants, and **(b)** on an 234 | ongoing basis, if such Contributor fails to notify You of the 235 | non-compliance by some reasonable means prior to 60 days after You have 236 | come back into compliance. Moreover, Your grants from a particular 237 | Contributor are reinstated on an ongoing basis if such Contributor 238 | notifies You of the non-compliance by some reasonable means, this is the 239 | first time You have received notice of non-compliance with this License 240 | from such Contributor, and You become compliant prior to 30 days after 241 | Your receipt of the notice. 242 | 243 | **5.2.** If You initiate litigation against any entity by asserting a patent 244 | infringement claim (excluding declaratory judgment actions, 245 | counter-claims, and cross-claims) alleging that a Contributor Version 246 | directly or indirectly infringes any patent, then the rights granted to 247 | You by any and all Contributors for the Covered Software under Section 248 | 2.1 of this License shall terminate. 249 | 250 | **5.3.** In the event of termination under Sections 5.1 or 5.2 above, all 251 | end user license agreements (excluding distributors and resellers) which 252 | have been validly granted by You or Your distributors under this License 253 | prior to termination shall survive termination. 254 | 255 | 256 | ### 6. Disclaimer of Warranty 257 | 258 | > Covered Software is provided under this License on an “as is” 259 | > basis, without warranty of any kind, either expressed, implied, or 260 | > statutory, including, without limitation, warranties that the 261 | > Covered Software is free of defects, merchantable, fit for a 262 | > particular purpose or non-infringing. The entire risk as to the 263 | > quality and performance of the Covered Software is with You. 264 | > Should any Covered Software prove defective in any respect, You 265 | > (not any Contributor) assume the cost of any necessary servicing, 266 | > repair, or correction. This disclaimer of warranty constitutes an 267 | > essential part of this License. No use of any Covered Software is 268 | > authorized under this License except under this disclaimer. 269 | 270 | ### 7. Limitation of Liability 271 | 272 | > Under no circumstances and under no legal theory, whether tort 273 | > (including negligence), contract, or otherwise, shall any 274 | > Contributor, or anyone who distributes Covered Software as 275 | > permitted above, be liable to You for any direct, indirect, 276 | > special, incidental, or consequential damages of any character 277 | > including, without limitation, damages for lost profits, loss of 278 | > goodwill, work stoppage, computer failure or malfunction, or any 279 | > and all other commercial damages or losses, even if such party 280 | > shall have been informed of the possibility of such damages. This 281 | > limitation of liability shall not apply to liability for death or 282 | > personal injury resulting from such party's negligence to the 283 | > extent applicable law prohibits such limitation. Some 284 | > jurisdictions do not allow the exclusion or limitation of 285 | > incidental or consequential damages, so this exclusion and 286 | > limitation may not apply to You. 287 | 288 | 289 | ### 8. Litigation 290 | 291 | Any litigation relating to this License may be brought only in the 292 | courts of a jurisdiction where the defendant maintains its principal 293 | place of business and such litigation shall be governed by laws of that 294 | jurisdiction, without reference to its conflict-of-law provisions. 295 | Nothing in this Section shall prevent a party's ability to bring 296 | cross-claims or counter-claims. 297 | 298 | 299 | ### 9. Miscellaneous 300 | 301 | This License represents the complete agreement concerning the subject 302 | matter hereof. If any provision of this License is held to be 303 | unenforceable, such provision shall be reformed only to the extent 304 | necessary to make it enforceable. Any law or regulation which provides 305 | that the language of a contract shall be construed against the drafter 306 | shall not be used to construe this License against a Contributor. 307 | 308 | 309 | ### 10. Versions of the License 310 | 311 | #### 10.1. New Versions 312 | 313 | Mozilla Foundation is the license steward. Except as provided in Section 314 | 10.3, no one other than the license steward has the right to modify or 315 | publish new versions of this License. Each version will be given a 316 | distinguishing version number. 317 | 318 | #### 10.2. Effect of New Versions 319 | 320 | You may distribute the Covered Software under the terms of the version 321 | of the License under which You originally received the Covered Software, 322 | or under the terms of any subsequent version published by the license 323 | steward. 324 | 325 | #### 10.3. Modified Versions 326 | 327 | If you create software not governed by this License, and you want to 328 | create a new license for such software, you may create and use a 329 | modified version of this License if you rename the license and remove 330 | any references to the name of the license steward (except to note that 331 | such modified license differs from this License). 332 | 333 | #### 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 334 | 335 | If You choose to distribute Source Code Form that is Incompatible With 336 | Secondary Licenses under the terms of this version of the License, the 337 | notice described in Exhibit B of this License must be attached. 338 | 339 | ## Exhibit A - Source Code Form License Notice 340 | 341 | This Source Code Form is subject to the terms of the Mozilla Public 342 | License, v. 2.0. If a copy of the MPL was not distributed with this 343 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 344 | 345 | If it is not possible or desirable to put the notice in a particular 346 | file, then You may include the notice in a location (such as a LICENSE 347 | file in a relevant directory) where a recipient would be likely to look 348 | for such a notice. 349 | 350 | You may add additional accurate notices of copyright ownership. 351 | 352 | ## Exhibit B - “Incompatible With Secondary Licenses” Notice 353 | 354 | This Source Code Form is "Incompatible With Secondary Licenses", as 355 | defined by the Mozilla Public License, v. 2.0. 356 | 357 | 358 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dataclassy 2 | **dataclassy** is a reimplementation of data classes in Python - an alternative to the built-in [dataclasses module](https://docs.python.org/3/library/dataclasses.html) that avoids many of [its](https://stackoverflow.com/questions/54678337) [common](https://stackoverflow.com/q/51575931) [pitfalls](https://stackoverflow.com/q/50180735). dataclassy is designed to be more flexible, less verbose, and more powerful than dataclasses, while retaining a familiar interface. 3 | 4 | In short, dataclassy is a library for moving data around your Python programs that's optimised for speed, simplicity and developer happiness. 5 | 6 | ```python 7 | from dataclassy import dataclass 8 | from typing import Dict 9 | 10 | @dataclass 11 | class Pet: 12 | name: str 13 | species: str 14 | fluffy: bool 15 | foods: Dict[str, int] = {} 16 | ``` 17 | 18 | 19 | ## Why use dataclassy? 20 | This section describes various motivations for using **dataclassy** over **dataclasses**. 21 | 22 | #### Backwards compatibility 23 | dataclassy implements the decorator options of the latest version of dataclasses, plus its own, such that they are compatible back to Python 3.6. It is [tested against](.github/workflows/ci.yml) **CPython 3.6 through 3.11** and **PyPy 3.6 through 3.9**. 24 | 25 | #### Upgrade your data classes 26 | - new decorator options 27 | - [`slots`](#slots) generates `__slots__` to reduce memory footprint and improve attribute lookup performance 28 | - [`kwargs`](#kwargs) appends `**kwargs` to `__init__`'s parameter list to consume unexpected arguments 29 | - [`iter`](#iter) allows class instances to be destructured, like named tuples 30 | - [`hide_internals`](#hide_internals) automatically hides private fields from `__repr__` and excludes them from comparison and iteration 31 | - `@dataclass` usage and options are inherited (subclasses do not have to reuse the decorator) 32 | - fields can be in any order - fields with defaults are [reordered](#parameter-reordering) - making inheritance feasible 33 | - mutable default values (`list`, `set`, `dict` and more) are [automatically copied](#default-values) upon initialisation 34 | - new functions: [`is_dataclass_instance`](#is_dataclass_instanceobj) and [`values`](#valuesdataclass-internalsfalse) 35 | 36 | #### Additionally, dataclassy 37 | - implements all the decorator options and functions of dataclasses 38 | - is tiny (~160 LOC; less than 25% the size of dataclasses) 39 | - has no external dependencies, and no stdlib imports other than `types`, `typing` and `reprlib` 40 | - is [fast](benchmarks.py), matching dataclasses' performance when `slots=False` and significantly exceeding it when `slots=True` 41 | - supports multiple inheritance and custom metaclasses 42 | - comes with [support for mypy](#mypy-support) 43 | 44 | #### Other differences 45 | dataclassy's API is strongly influenced by dataclasses', but with a focus on minimalism and elegance. 46 | 47 | - there's no `field` or `Field`. Use [`Hashed`](#hashed), [`Internal`](#internal) or [`factory`](#factorycreator) to replicate its functions 48 | - there's no `InitVar`. Simply add arguments to `__post_init__` 49 | - there's no need for `ClassVar`. Simply omit the field's type hint to ignore it 50 | 51 | #### Also consider 52 | 53 | - [`attrs`](https://github.com/python-attrs/attrs) if you need complex validation and type conversions 54 | - [`pydantic`](https://github.com/samuelcolvin/pydantic) if you need strict type checking 55 | 56 | 57 | ## Usage 58 | ### Installation 59 | Install the latest release from [PyPI](https://pypi.org/project/dataclassy/) with pip: 60 | 61 | ```console 62 | pip install dataclassy 63 | ``` 64 | 65 | Or install the latest development version straight from this repository: 66 | 67 | ```console 68 | pip install https://github.com/biqqles/dataclassy/archive/master.zip -U 69 | ``` 70 | 71 | 72 | ### Migration 73 | dataclassy's API is broadly similar to dataclasses. If you simply use the decorator and other functions, it is possible to instantly migrate from dataclasses to dataclassy by simply changing 74 | 75 | ```Python 76 | from dataclasses import * 77 | ``` 78 | 79 | to 80 | 81 | ```Python 82 | from dataclassy import * 83 | ``` 84 | 85 | Otherwise, you will have to make a couple of easy refactorings (that should leave you with cleaner code!). Consult the table under [Differences](#differences) or skip ahead to [Examples](#examples) to see dataclassy in action. 86 | 87 | #### Similarities 88 | dataclassy's `dataclass` decorator takes all of the same arguments as dataclasses', plus its own, and should therefore be a drop-in replacement. 89 | 90 | dataclassy also implements all dataclasses' [functions](#functions): `is_dataclass`, `fields`, `replace`, `make_dataclass`, `asdict` and `astuple` (the last two are aliased from `as_dict` and `as_tuple` respectively), and they should work as you expect. 91 | 92 | #### Differences 93 | Although dataclassy's API is similar to dataclasses', [compatibility with it is not a goal](https://straight-shoota.github.io/crystal-book/feature/tutorials-initial/) (this is similar to the relationship between Crystal and Ruby). 94 | 95 | dataclassy has several important differences from dataclasses, mainly reflective of its minimalistic style and implementation. These differences are enumerated below and fully expanded on in the next section. 96 | 97 | | |dataclasses |dataclassy | 98 | |---------------------------------|:-------------------------------------------|:---------------------------------------| 99 | |*init-only variables* |fields with type `InitVar` |arguments to `__post_init__` | 100 | |*class variables* |fields with type `ClassVar` |fields without type annotation | 101 | |*mutable defaults* |`a: Dict = field(default_factory=dict)` |`a: Dict = {}` | 102 | |*dynamic defaults* |`b: MyClass = field(default_factory=MyClass)`|`b: MyClass = factory(MyClass)` | 103 | |*field excluded from `repr`* |`c: int = field(repr=False)` |`Internal` type wrapper or `_name` | 104 | |*"late init" field* |`d: int = field(init=False)` |`d: int = None` | 105 | |*abstract data class* |`class Foo(ABC):` |`class Foo(metaclass=ABCMeta):` | 106 | 107 | There are a couple of minor differences, too: 108 | 109 | - `fields` returns `Dict[str, Type]` instead of `Dict[Field, Type]` and has an additional parameter which filters internal fields 110 | - Attempting to modify a frozen instance raises `AttributeError` with an explanation rather than `FrozenInstanceError` 111 | 112 | Finally, there are some quality of life improvements that, while not being directly implicated in migration, will allow you to make your code cleaner: 113 | 114 | - `@dataclass` does not need to be applied to every subclass - its behaviour and options are inherited 115 | - Unlike dataclasses, fields with defaults do not need to follow those without them. This is particularly useful when working with subclasses, which is almost impossible with dataclasses 116 | - dataclassy adds a `DataClass` type annotation to represent variables that should be generic data class instances 117 | - dataclassy has the `is_dataclass_instance` suggested as a [recipe](https://docs.python.org/3/library/dataclasses.html#dataclasses.is_dataclass) for dataclasses built-in 118 | - The generated comparison methods (when `order=True`) are compatible with supertypes and subtypes of the class. This means that heterogeneous collections of instances with the same superclass can be sorted 119 | 120 | It is also worth noting that internally, dataclasses and dataclassy work in different ways. You can think of dataclassy as _turning your class into a different type of thing_ (indeed, it uses a metaclass) and dataclasses as merely _adding things to your class_ (it does not). 121 | 122 | 123 | ### Examples 124 | #### The basics 125 | To define a data class, simply apply the `@dataclass` decorator to a class definition ([see above](#dataclassy)). 126 | 127 | Without arguments to the decorator, the resulting class will behave very similarly to its equivalent from the built-in module. However, dataclassy's decorator has some additional options over dataclasses', and it is also inherited so that subclasses of data classes are automatically data classes too. 128 | 129 | The decorator generates various methods for the class. Which ones exactly depend on the options to the decorator. For example, `@dataclass(repr=False)` will prevent a `__repr__` method from being generated. `@dataclass` is equivalent to using the decorator with default parameters (i.e. `@dataclass` and `@dataclass()` are equivalent). Options to the decorator are detailed fully in the [next section](#decorator-options). 130 | 131 | #### Class variables 132 | You can exclude a class attribute from dataclassy's mechanisms entirely by simply defining it without a type annotation. This can be used for class variables and constants. 133 | 134 | #### Parameter reordering 135 | dataclassy modifies the order of fields when converting them into parameters for the generated `__init__`. Specifically, fields with default values always follow those without them. This stems from Python's requirement that _parameters_ with default arguments follow those without them. Conceptually, you can think of the process to generate the parameter list like this: 136 | 137 | 1. dataclassy takes the fields in definition order 138 | 2. it splits them into two lists, the first being fields without default values and the second being fields with them 139 | 3. it appends the second list to the first 140 | 141 | This simple design decision prevents the dreaded `TypeError: non-default argument '...' follows default argument` error that anyone who has tried to do serious inheritance using dataclasses will know well. 142 | 143 | You can verify the signature of the generated initialiser for any class using `signature` from the `inspect` module. For example, using the definition linked to above, `inspect.signature(Pet)` will return `(name: str, species: str, fluffy: bool, foods: Dict[str, int] = {}))`. 144 | 145 | If we then decided to subclass `Pet` to add a new field, `hungry`: 146 | 147 | ```python 148 | @dataclass 149 | class HungryPet(Pet): 150 | hungry: bool 151 | ``` 152 | 153 | You will see that `inspect.signature(HungryPet)` returns `(name: str, species: str, fluffy: bool, hungry: bool, foods: Dict[str, int] = {})`. 154 | 155 | #### Inheritance 156 | Unlike dataclasses, dataclassy's decorator only needs to be applied once, and all subclasses will become data classes with the same options as the parent class. The decorator can still be reapplied to subclasses in order to apply new parameters. 157 | 158 | To change the type, or to add or change the default value of a field in a subclass, simply redeclare it in the subclass. 159 | 160 | #### Post-initialisation processing 161 | If an initialiser is requested (`init=True`), dataclassy automatically sets the attributes of the class upon initialisation. You can define code that should run after this happens - this is called _post-init processing_. 162 | 163 | The method that contains this logic should be called `__post_init__`. Like with dataclasses, if `init=False` or the class has no fields, `__post_init__` will not be called. 164 | 165 | ```Python 166 | @dataclass 167 | class CustomInit: 168 | a: int 169 | b: int 170 | 171 | def __post_init__(self): 172 | self.c = self.a / self.b 173 | ``` 174 | 175 | In this example, when the class is instantiated with `CustomInit(1, 2)`, the field `c` is calculated as `0.5`. 176 | 177 | Like with any function, your `__post_init__` can also take parameters which exist only in the context of `__post_init__`. These can be used for arguments to the class that you do not want to store as fields. A parameter cannot have the name of a class field; this is to prevent ambiguity. 178 | 179 | #### Default values 180 | Default values for fields work exactly as default arguments to functions (and in fact this is how they are implemented), with one difference: for copyable defaults, a copy is automatically created for each class instance. This means that a new copy of the `list` field `foods` in `Pet` above will be created each time it is instantiated, so that appending to that attribute in one instance will not affect other instances. A "copyable default" is defined as any object implementing a `copy` method, which includes all the built-in mutable collections (including `defaultdict`). 181 | 182 | If you want to create new instances of objects which do not have a `copy` method, use the [`factory`](#factorycreator) function. This function takes any zero-argument callable. When the class is instantiated, this callable is executed to produce a default value for the field: 183 | 184 | ```Python 185 | class MyClass: 186 | pass 187 | 188 | @dataclass 189 | class CustomDefault: 190 | m: MyClass = factory(MyClass) 191 | 192 | CustomDefault() # CustomDefault(m=<__main__.MyClass object at 0x7f8b156feb50>) 193 | CustomDefault() # CustomDefault(m=<__main__.MyClass object at 0x7f8b156fc7d0>) 194 | ``` 195 | 196 | ## API 197 | ### Decorator 198 | #### `@dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, hide_internals=True, iter=False, kwargs=False, slots=False, meta=DataClassMeta)` 199 | The decorator used to signify that a class definition should become a data class. The decorator returns a new data class with generated methods as detailed below. If the class already defines a particular method, it will not be replaced with a generated one. 200 | 201 | Without arguments, its behaviour is, superficially, almost identical to its equivalent in the built-in module. However, dataclassy's decorator only needs to be applied once, and all subclasses will become data classes with the same parameters. The decorator can still be reapplied to subclasses in order to change parameters. 202 | 203 | A data class' fields are defined using Python's type annotations syntax. To change the type or default value of a field in a subclass, simply redeclare it. 204 | 205 | This decorator takes advantage of two equally important features added in Python 3.6: [variable annotations](https://docs.python.org/3/glossary.html#term-variable-annotation) and [dictionaries being ordered](https://docs.python.org/3.7/tutorial/datastructures.html#dictionaries). (The latter is technically an [implementation detail](https://docs.python.org/3.6/whatsnew/3.6.html#whatsnew36-compactdict) of Python 3.6, only becoming standardised in Python 3.7, but is the case for all current implementations of Python 3.6, i.e. CPython and PyPy.) 206 | 207 | 208 | #### Decorator options 209 | > The term "field", as used in this section, refers to a class-level variable with a type annotation. For more information, see the documentation for [`fields()`](#fieldsdataclass-internalsfalse) below. 210 | 211 | ##### `init` 212 | If true (the default), generate an [`__init__`](https://docs.python.org/3/reference/datamodel.html#object.__init__) method that has as parameters all fields up its inheritance chain. These are ordered in definition order, with all fields with default values placed towards the end, following all fields without them. The method initialises the class by applying these parameters to the class as attributes. If defined, it will also call `__post_init__` with any remaining arguments. 213 | 214 | This ordering is an important distinction from dataclasses, where all fields are simply ordered in definition order, and is what allows dataclassy's data classes to be far more flexible in terms of inheritance. 215 | 216 | A shallow copy will be created for mutable arguments (defined as those defining a `copy` method). This means that default field values that are mutable (e.g. a list) will not be mutated between instances. 217 | 218 | ##### `repr` 219 | If true (the default), generate a [`__repr__`](https://docs.python.org/3/reference/datamodel.html#object.__repr__) method that displays all fields (or if [`hide_internals`](#hide_internals) is true, all fields excluding [internal](#internal) ones) of the data class instance and their values. 220 | 221 | ##### `eq` 222 | If true (the default), generate an [`__eq__`](https://docs.python.org/3/reference/datamodel.html#object.__eq__) method that compares this data class to another of the same type as if they were tuples created by [`as_tuple`](#as_tupledataclass), excluding [internal fields](#internal) if [`hide_internals`](#hide_internals) is true. 223 | 224 | ##### `order` 225 | If true, a [`__lt__`](https://docs.python.org/3/reference/datamodel.html#object.__lt__) method is generated, making the class *orderable*. If `eq` is also true, all other comparison methods are also generated. These methods compare this data class to another of the same type (or a subclass) as if they were tuples created by [`as_tuple`](#as_tupledataclass), excluding [internal fields](#internal) if [`hide_internals`](#hide_internals) is true. The normal rules of [lexicographical comparison](https://docs.python.org/3/reference/expressions.html#value-comparisons) apply. 226 | 227 | ##### `unsafe_hash` 228 | If true, force the generation of a [`__hash__`](https://docs.python.org/3/reference/datamodel.html#object.__hash__) method that attempts to hash the class as if it were a tuple of its hashable fields. If `unsafe_hash` is false, `__hash__` will only be generated if `eq` and `frozen` are both true. 229 | 230 | ##### `match_args` 231 | If true (the default), generate a `__match_args__` attribute that enables structural pattern matching on Python 3.10+. 232 | 233 | ##### `kw_only` 234 | If true, all parameters to the generated `__init__` are marked as [keyword-only](https://peps.python.org/pep-3102). This includes arguments passed through to `__post_init__`. 235 | 236 | ##### `frozen` 237 | If true, instances are nominally immutable: fields cannot be overwritten or deleted after initialisation in `__init__`. Attempting to do so will raise an `AttributeError`. **Warning: incurs a significant initialisation performance penalty.** 238 | 239 | ##### `hide_internals` 240 | If true (the default), [internal fields](#internal) are not included in the generated [`__repr__`](#repr), comparison functions ([`__eq__`](#eq), [ `__lt__`](#order), etc.), or [`__iter__`](#iter). 241 | 242 | ##### `iter` 243 | If true, generate an [`__iter__`](https://docs.python.org/3/reference/datamodel.html#object.__iter__) method that returns the values of the class's fields, in order of definition, noting that [internal fields](#internal) are excluded when [`hide_internals`](#hide_internals) is true. This can be used to destructure a data class instance, as with a Scala `case class` or a Python `namedtuple`. 244 | 245 | ##### `kwargs` 246 | If true, add [`**kwargs`](https://docs.python.org/3.3/glossary.html#term-parameter) to the end of the parameter list for `__init__`. This simplifies data class instantiation from dictionaries that may have keys in addition to the fields of the data class (i.e. `SomeDataClass(**some_dict)`). 247 | 248 | ##### `slots` 249 | If true, generate a [`__slots__`](https://docs.python.org/3/reference/datamodel.html#slots) attribute for the class. This reduces the memory footprint of instances and attribute lookup overhead. However, `__slots__` come with a few [restrictions](https://docs.python.org/3/reference/datamodel.html#notes-on-using-slots) (for example, multiple inheritance becomes tricky) that you should be aware of. 250 | 251 | ##### `meta` 252 | Set this parameter to use a metaclass other than dataclassy's own. This metaclass must subclass [`dataclassy.dataclass.DataClassMeta`](dataclassy/dataclass.py). 253 | 254 | `DataClassMeta` is best considered less stable than the parts of the library available in the root namespace. Only use a custom metaclass if absolutely necessary. 255 | 256 | 257 | ### Functions 258 | #### `factory(producer)` 259 | Takes a zero-argument callable and creates a _factory_ that executes this callable to generate a default value for the field at class initialisation time. 260 | 261 | #### `is_dataclass(obj)` 262 | Returns True if `obj` is a data class as implemented in this module. 263 | 264 | #### `is_dataclass_instance(obj)` 265 | Returns True if `obj` is an instance of a data class as implemented in this module. 266 | 267 | #### `fields(dataclass, internals=False)` 268 | Return a dict of `dataclass`'s fields and their types. `internals` selects whether to include internal fields. `dataclass` can be either a data class or an instance of a data class. 269 | 270 | A field is defined as a class-level variable with a [type annotation](https://docs.python.org/3/glossary.html#term-variable-annotation). Variables defined in the class without type annotations are completely excluded from dataclassy's consideration. Class variables and constants can therefore be indicated by the absence of type annotations. 271 | 272 | #### `values(dataclass, internals=False)` 273 | Return a dict of `dataclass`'s fields and their values. `internals` selects whether to include internal fields. `dataclass` must be an instance of a data class. 274 | 275 | #### `as_dict(dataclass dict_factory=dict)` 276 | Recursively create a dict of a data class instance's fields and their values. 277 | 278 | This function is recursively called on data classes, named tuples and iterables. 279 | 280 | #### `as_tuple(dataclass)` 281 | Recursively create a tuple of the values of a data class instance's fields, in definition order. 282 | 283 | This function is recursively called on data classes, named tuples and iterables. 284 | 285 | #### `make_dataclass(name, fields, defaults, bases=(), **options)` 286 | Dynamically create a data class with name `name`, fields `fields`, default field values `defaults` and inheriting from `bases`. 287 | 288 | #### `replace(dataclass, **changes)` 289 | Return a new copy of `dataclass` with field values replaced as specified in `changes`. 290 | 291 | ### Type hints 292 | #### `Internal` 293 | The `Internal` type wrapper marks a field as being "internal" to the data class. Fields which begin with the ["internal use"](https://www.python.org/dev/peps/pep-0008/#descriptive-naming-styles) idiomatic indicator `_` or the [private field](https://docs.python.org/3/tutorial/classes.html#private-variables) interpreter indicator `__` are automatically treated as internal fields. The `Internal` type wrapper therefore serves as an alternative method of indicating that a field is internal for situations where you are unable to name your fields in this way. 294 | 295 | #### `Hashed` 296 | Use `Hashed` to wrap the type annotations of fields that you want to be included in a data class' `__hash__`. The value hashed by `__hash__` consists of a tuple of the instance's type followed by any fields marked as `Hashed`. 297 | 298 | #### `DataClass` 299 | Use this type hint to indicate that a variable, parameter or field should be a generic data class instance. For example, dataclassy uses these in the signatures of `as_dict`, `as_tuple` and `values` to show that these functions should be called on data class instances. 300 | 301 | ### Mypy support 302 | In order to use dataclassy in projects with mypy, you will need to use the mypy plugin. You can create a `mypy.ini` or `.mypy.ini` for such projects with the following content: 303 | 304 | ```ini 305 | [mypy] 306 | plugins = dataclassy.mypy 307 | ``` 308 | -------------------------------------------------------------------------------- /benchmarks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Benchmarks for dataclassy. 3 | Example output: 4 | 5 | === Simple class === 6 | dataclasses: 0.22464673200738616 seconds 7 | dataclassy: 0.2270237009797711 seconds 8 | dataclassy (slots): 0.1814715740038082 seconds 9 | 10 | === Default value === 11 | dataclasses: 0.2395797820063308 seconds 12 | dataclassy: 0.25323228500201367 seconds 13 | dataclassy (slots): 0.20265625597676262 seconds 14 | """ 15 | from typing import Dict 16 | from timeit import timeit 17 | import dataclasses 18 | import dataclassy 19 | 20 | 21 | def heading(text: str, decoration: str = '==='): 22 | """Print a heading.""" 23 | print('\n', decoration, text, decoration) 24 | 25 | 26 | def result(label: str, expression: str): 27 | """Time an expression and print the result, along with a label.""" 28 | timing = timeit(expression, globals=globals()) 29 | print(f'{label}: {timing} seconds') 30 | 31 | 32 | heading('Simple class') 33 | 34 | 35 | @dataclasses.dataclass 36 | class DsSimple: 37 | a: int 38 | b: int 39 | 40 | 41 | @dataclassy.dataclass 42 | class DySimple: 43 | a: int 44 | b: int 45 | 46 | 47 | @dataclassy.dataclass(slots=True) 48 | class DySimpleSlots: 49 | a: int 50 | b: int 51 | 52 | 53 | result('dataclasses', 'DsSimple(1, 2)') 54 | result('dataclassy', 'DySimple(1, 2)') 55 | result('dataclassy (slots)', 'DySimpleSlots(1, 2)') 56 | 57 | 58 | heading('Default value') 59 | 60 | 61 | @dataclasses.dataclass 62 | class DsDefault: 63 | c: Dict = dataclasses.field(default_factory=dict) 64 | 65 | 66 | @dataclassy.dataclass 67 | class DyDefault: 68 | c: Dict = {} 69 | 70 | 71 | @dataclassy.dataclass(slots=True) 72 | class DyDefaultSlots: 73 | c: Dict = {} 74 | 75 | 76 | result('dataclasses', 'DsDefault()') 77 | result('dataclassy', 'DyDefault()') 78 | result('dataclassy (slots)', 'DyDefaultSlots()') 79 | -------------------------------------------------------------------------------- /dataclassy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020, 2021 biqqles. 3 | This Source Code Form is subject to the terms of the Mozilla Public 4 | License, v. 2.0. If a copy of the MPL was not distributed with this 5 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | """ 7 | from .decorator import dataclass, make_dataclass 8 | from .dataclass import DataClass, Hashed, Internal, factory 9 | from .functions import fields, values, as_dict, as_tuple, replace 10 | 11 | # aliases intended for migration from dataclasses 12 | asdict, astuple = as_dict, as_tuple 13 | 14 | # for the benefit of mypy --strict 15 | __all__ = ( 16 | 'dataclass', 'make_dataclass', 17 | 'DataClass', 'Hashed', 'Internal', 'factory', 18 | 'fields', 'values', 'as_dict', 'as_tuple', 'replace', 19 | 'asdict', 'astuple', 20 | ) 21 | -------------------------------------------------------------------------------- /dataclassy/dataclass.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020, 2021 biqqles. 3 | This Source Code Form is subject to the terms of the Mozilla Public 4 | License, v. 2.0. If a copy of the MPL was not distributed with this 5 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | This file contains the internal mechanism that makes data classes 8 | work, as well as functions which operate on them. 9 | """ 10 | from types import FunctionType as Function 11 | from typing import Any, Callable, Dict, List, Type, TypeVar, Union, cast 12 | from reprlib import recursive_repr 13 | 14 | DataClass = Any # type hint representing a generic data class instance 15 | 16 | 17 | class Hint(type): 18 | """Metaclass for a type hint "wrapper". Wraps the actual type of a field to convey information about how it is 19 | intended to be used, much like typing.ClassVar. Usage is like Hint[some_type]. 20 | This is a metaclass because __class_getitem__ is not recognised in Python 3.6.""" 21 | Wrapped = TypeVar('Wrapped') 22 | 23 | def __getitem__(cls, item: Wrapped) -> Union['Hint', Wrapped]: 24 | """Create a new Union of the wrapper and the wrapped type. Union is smart enough to flatten nested 25 | unions automatically.""" 26 | return Union[cls, item] 27 | 28 | def is_hinted(cls, hint: Union[Type, str]) -> bool: 29 | """Check whether a type hint represents this Hint.""" 30 | return ((hasattr(hint, '__args__') and cls in (hint.__args__ or [])) or 31 | (type(hint) is str and f'{cls.__name__}[' in hint)) 32 | 33 | 34 | class Internal(metaclass=Hint): 35 | """Marks that a field is internal to the class and so should not in a repr.""" 36 | 37 | 38 | class Hashed(metaclass=Hint): 39 | """Marks that a field should be included in the generated __hash__.""" 40 | 41 | 42 | class Factory: 43 | """This class takes a zero-argument callable. When a Factory instance is set as the default value of a field, this 44 | callable is executed and the instance variable set to the result.""" 45 | Produces = TypeVar('Produces') 46 | 47 | def __init__(self, producer: Callable[[], Produces]): 48 | """The generated __init__ checks for the existence of a `copy` method to determine whether a default value 49 | should be copied upon class instantiation. This is because the built-in mutable collections have a method like 50 | this. This class (ab)uses this behaviour to elegantly implement the factory.""" 51 | self.copy = producer 52 | 53 | 54 | def factory(producer: Callable[[], Factory.Produces]) -> Factory.Produces: 55 | """Takes a zero-argument callable and creates a Factory that executes this callable to generate a default value for 56 | the field at class initialisation time. Casts the resulting Factory to keep mypy happy.""" 57 | return cast(Factory.Produces, Factory(producer)) 58 | 59 | 60 | class DataClassMeta(type): 61 | """The metaclass that implements data class behaviour.""" 62 | DEFAULT_OPTIONS = dict(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, 63 | kw_only=False, match_args=True, hide_internals=True, iter=False, kwargs=False, slots=False) 64 | 65 | def __new__(mcs, name, bases, dict_, **kwargs): 66 | """Create a new data class.""" 67 | 68 | # delete what may become stale references so that Python creates new ones 69 | 70 | dict_.pop('__dict__', None) 71 | dict_ = {f: v for f, v in dict_.items() if type(v).__name__ != 'member_descriptor'} 72 | 73 | # collect functions, annotations, defaults, slots and options from this class' ancestors, in definition order 74 | 75 | all_annotations = {} 76 | all_defaults = {} 77 | all_slots = set() 78 | options = dict(mcs.DEFAULT_OPTIONS) 79 | 80 | # record all functions defined by the user up through the inheritance chain 81 | all_attrs = {a for b in bases for a in dir(b) if is_user_func(getattr(b, a, None))} | dict_.keys() 82 | 83 | dataclass_bases = [vars(b) for b in bases if isinstance(b, mcs)] 84 | for b in dataclass_bases + [dict_]: 85 | all_annotations.update(b.get('__annotations__', {})) 86 | all_defaults.update(b.get('__defaults__', {})) 87 | all_slots.update(b.get('__slots__', set())) 88 | options.update(b.get('__dataclass__', {})) 89 | 90 | post_init = '__post_init__' in all_attrs 91 | 92 | # update options and defaults for *this* class 93 | 94 | options.update(kwargs) 95 | all_defaults.update({f: v for f, v in dict_.items() if f in all_annotations}) 96 | 97 | # store defaults, annotations and decorator options for future subclasses 98 | 99 | dict_['__defaults__'] = all_defaults 100 | dict_['__annotations__'] = all_annotations 101 | dict_['__dataclass__'] = options 102 | 103 | # create and apply generated methods and attributes 104 | 105 | if options['slots']: 106 | # if the slots option is added, add __slots__. Values with default values must only be present in slots, 107 | # not dict, otherwise Python will interpret them as read only 108 | for d in all_annotations.keys() & dict_.keys(): 109 | del dict_[d] 110 | dict_.setdefault('__slots__', tuple(all_annotations.keys() - all_slots)) 111 | elif '__slots__' in dict_: 112 | # if the slots option gets removed, remove __slots__ 113 | del dict_['__slots__'] 114 | 115 | if options['init'] and all_annotations and '__init__' not in all_attrs: 116 | dict_.setdefault('__init__', generate_init(all_annotations, all_defaults, options, post_init)) 117 | 118 | if options['repr']: 119 | '__repr__' in all_attrs or dict_.setdefault('__repr__', recursive_repr()(__repr__)) 120 | 121 | if options['eq']: 122 | '__eq__' in all_attrs or dict_.setdefault('__eq__', __eq__) 123 | 124 | if options['iter']: 125 | '__iter__' in all_attrs or dict_.setdefault('__iter__', __iter__) 126 | 127 | if options['frozen']: 128 | '__delattr__' in all_attrs or dict_.setdefault('__delattr__', __setattr__) 129 | '__setattr__' in all_attrs or dict_.setdefault('__setattr__', __setattr__) 130 | 131 | if options['order']: 132 | '__lt__' in all_attrs or dict_.setdefault('__lt__', __lt__) 133 | 134 | if (options['eq'] and options['frozen']) or options['unsafe_hash']: 135 | '__hash__' in all_attrs or dict_.setdefault('__hash__', generate_hash(all_annotations)) 136 | 137 | if options['match_args']: 138 | dict_.setdefault('__match_args__', tuple(sorted(all_annotations, key=all_defaults.__contains__))) 139 | 140 | return super().__new__(mcs, name, bases, dict_) 141 | 142 | # noinspection PyMissingConstructor,PyUnresolvedReferences,PyTypeChecker 143 | def __init__(cls, *_, **__): 144 | if cls.__dataclass__['eq'] and cls.__dataclass__['order']: 145 | from functools import total_ordering 146 | total_ordering(cls) 147 | 148 | # determine a static expression for an instance's fields as a tuple, then evaluate this to create a property 149 | # allowing efficient representation for internal methods 150 | internals = not cls.__dataclass__['hide_internals'] 151 | tuple_expr = ' '.join(f'self.{f},' for f in fields(cls, internals)) 152 | cls.__tuple__ = property(eval(f'lambda self: ({tuple_expr})')) 153 | 154 | 155 | def eval_function(name: str, lines: List[str], annotations: Dict, locals_: Dict, globals_: Dict) -> Function: 156 | """Evaluate a function definition, returning the resulting object.""" 157 | exec('\n\t'.join(lines), globals_, locals_) 158 | function = locals_.pop(name) 159 | function.__annotations__ = annotations 160 | function.__dataclass__ = True # add a marker showing this function was generated by dataclassy 161 | return function 162 | 163 | 164 | def is_user_func(obj: Any, object_methods=frozenset(vars(object).values())) -> bool: 165 | """Given an object, returns true if it is a function explicitly defined by the user (i.e. not generated by 166 | dataclassy and not a wrapper/descriptor). Otherwise, returns False. 167 | The unusual check against object_methods is because PyPy returns True for methods on object (unlike CPython).""" 168 | return type(obj) is Function and obj not in object_methods and not hasattr(obj, '__dataclass__') 169 | 170 | 171 | def generate_init(annotations: Dict, defaults: Dict, options: Dict, user_init: bool) -> Function: 172 | """Generate and return an __init__ method for a data class. This method has as parameters all fields of the data 173 | class. When the data class is initialised, arguments to this function are applied to the fields of the new 174 | instance.""" 175 | kw_only = ['*'] if options['kw_only'] else [] 176 | arguments = [a for a in annotations if a not in defaults] 177 | default_arguments = [f'{a}={a}' for a in defaults] 178 | args = ['*args'] if user_init and not kw_only else [] 179 | kwargs = ['**kwargs'] if user_init or options['kwargs'] else [] 180 | 181 | parameters = ', '.join(kw_only + arguments + default_arguments + args + kwargs) 182 | 183 | # surprisingly, given global lookups are slow, using them is the fastest way to compare a field to its default 184 | # the alternatives are to look up on self (which wouldn't work when slots=True) or look up self.__defaults__ 185 | copied = {n: (f'd_{n}', v) for n, v in defaults.items() if hasattr(v, 'copy')} 186 | 187 | # determine what to do with arguments before assignment. If the argument matches a mutable default, make a copy 188 | references = {n: f'{n}.copy() if {n} is {d} else {n}' for n, (d, _) in copied.items()} 189 | 190 | # if the class is frozen, use the necessary but far slower object.__setattr__ 191 | assigner = 'object.__setattr__(self, {!r}, {})' if options['frozen'] else 'self.{} = {}' 192 | assignments = [assigner.format(n, references.get(n, n)) for n in annotations] 193 | 194 | # if defined, call __post_init__ with leftover arguments 195 | call_post_init = f'self.__post_init__({", ".join(args + kwargs)})' if user_init else '' 196 | 197 | # generate the function 198 | lines = [f'def __init__(self, {parameters}):', *assignments, call_post_init] 199 | return eval_function('__init__', lines, annotations, defaults, dict(copied.values())) 200 | 201 | 202 | def generate_hash(annotations: Dict[str, Type]) -> Function: 203 | """Generate a __hash__ method for a data class. The hashed value consists of a tuple of the instance's type 204 | followed by any fields marked as "Hashed".""" 205 | hash_of = ', '.join(['self.__class__', *(f'self.{f}' for f, h in annotations.items() if Hashed.is_hinted(h))]) 206 | return eval_function('__hash__', ['def __hash__(self):', 207 | f'return hash(({hash_of}))'], dict(self=DataClass), {}, {}) 208 | 209 | 210 | # generic method implementations common to all data classes 211 | from .functions import values, fields 212 | 213 | 214 | def __eq__(self: DataClass, other: DataClass): 215 | return type(self) is type(other) and self.__tuple__ == other.__tuple__ 216 | 217 | 218 | def __lt__(self: DataClass, other: DataClass): 219 | if isinstance(other, type(self)): 220 | return self.__tuple__ < other.__tuple__ 221 | return NotImplemented 222 | 223 | 224 | def __iter__(self): 225 | return iter(self.__tuple__) 226 | 227 | 228 | def __repr__(self): 229 | show_internals = not self.__dataclass__['hide_internals'] 230 | field_values = ', '.join(f'{f}={v!r}' for f, v in values(self, show_internals).items()) 231 | return f'{type(self).__name__}({field_values})' 232 | 233 | 234 | def __setattr__(*_): 235 | raise AttributeError('Frozen class') 236 | -------------------------------------------------------------------------------- /dataclassy/decorator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020, 2021 biqqles. 3 | This Source Code Form is subject to the terms of the Mozilla Public 4 | License, v. 2.0. If a copy of the MPL was not distributed with this 5 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | This file contains code relating to dataclassy's decorator. 8 | """ 9 | from typing import Dict, Optional, Type 10 | from .dataclass import DataClass, DataClassMeta 11 | 12 | 13 | def dataclass(cls: Optional[type] = None, *, meta=DataClassMeta, **options) -> Type[DataClass]: 14 | """The decorator used to convert an ordinary class into a data class. 15 | 16 | :param cls: The class to be converted into a data class 17 | :param meta: The metaclass to use 18 | :key init: Generate an __init__ method 19 | :key repr: Generate a __repr__ method 20 | :key eq: Generate an __eq__ method 21 | :key frozen: Allow field reassignment after initialisation 22 | :key order: Generate comparison methods other than __eq__ 23 | :key unsafe_hash: Force generation of __hash__ 24 | :key match_args: Generate a __match_args__ attribute 25 | :key kw_only: Make all parameters to the generated __init__ keyword-only 26 | 27 | :key kwargs: Append **kwargs to the list of initialiser parameters 28 | :key slots: Generate __slots__ 29 | :key iter: Generate an __iter__ method 30 | :key hide_internals: Hide internal methods in __repr__ 31 | 32 | :return: The newly created data class 33 | """ 34 | assert issubclass(meta, DataClassMeta) 35 | 36 | def apply_metaclass(to_class, metaclass=meta): 37 | """Apply a metaclass to a class.""" 38 | dict_ = dict(vars(to_class), __metaclass__=metaclass) 39 | return metaclass(to_class.__name__, to_class.__bases__, dict_, **options) 40 | 41 | if cls: # if decorator used with no arguments, apply metaclass to the class immediately 42 | if not isinstance(cls, type): 43 | raise TypeError('This decorator must be applied to a class') 44 | return apply_metaclass(cls) 45 | return apply_metaclass # otherwise, return function for later evaluation 46 | 47 | 48 | def make_dataclass(name: str, fields: Dict, defaults: Dict, bases=(), **options) -> Type[DataClass]: 49 | """Dynamically create a data class with name `name`, fields `fields`, default field values `defaults` and 50 | inheriting from `bases`.""" 51 | return dataclass(type(name, bases, dict(defaults, __annotations__=fields)), **options) 52 | -------------------------------------------------------------------------------- /dataclassy/functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020, 2021 biqqles. 3 | This Source Code Form is subject to the terms of the Mozilla Public 4 | License, v. 2.0. If a copy of the MPL was not distributed with this 5 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | This file defines functions which operate on data classes. 8 | """ 9 | from typing import Any, Callable, Dict, Tuple, Type, Union 10 | 11 | from .dataclass import DataClassMeta, DataClass, Internal 12 | 13 | 14 | def is_dataclass(obj: Any) -> bool: 15 | """Return True if the given object is a data class as implemented in this package, otherwise False.""" 16 | return isinstance(obj, DataClassMeta) or is_dataclass_instance(obj) 17 | 18 | 19 | def is_dataclass_instance(obj: Any) -> bool: 20 | """Return True if the given object is an instance of a data class, otherwise False.""" 21 | return isinstance(type(obj), DataClassMeta) 22 | 23 | 24 | def fields(dataclass: Union[DataClass, Type[DataClass]], internals=False) -> Dict[str, Type]: 25 | """Return a dict of `dataclass`'s fields and their types. `internals` selects whether to include internal fields. 26 | `dataclass` can be either a data class or an instance of a data class. A field is defined as a class-level variable 27 | with a type annotation.""" 28 | assert is_dataclass(dataclass) 29 | return _filter_annotations(dataclass.__annotations__, internals) 30 | 31 | 32 | def values(dataclass: DataClass, internals=False) -> Dict[str, Any]: 33 | """Return a dict of `dataclass`'s fields and their values. `internals` selects whether to include internal fields. 34 | `dataclass` must be an instance of a data class. A field is defined as a class-level variable with a type 35 | annotation.""" 36 | assert is_dataclass_instance(dataclass) 37 | return {f: getattr(dataclass, f) for f in fields(dataclass, internals)} 38 | 39 | 40 | def as_dict(dataclass: DataClass, dict_factory=dict) -> Dict[str, Any]: 41 | """Recursively create a dict of a dataclass instance's fields and their values. 42 | This function is recursively called on data classes, named tuples and iterables.""" 43 | assert is_dataclass_instance(dataclass) 44 | return _recurse_structure(dataclass, dict_factory) 45 | 46 | 47 | def as_tuple(dataclass: DataClass) -> Tuple: 48 | """Recursively create a tuple of the values of a dataclass instance's fields, in definition order. 49 | This function is recursively called on data classes, named tuples and iterables.""" 50 | assert is_dataclass_instance(dataclass) 51 | return _recurse_structure(dataclass, lambda k_v: tuple(v for k, v in k_v)) 52 | 53 | 54 | def replace(dataclass: DataClass, **changes) -> DataClass: 55 | """Return a new copy of `dataclass` with field values replaced as specified in `changes`.""" 56 | return type(dataclass)(**dict(values(dataclass, internals=True), **changes)) 57 | 58 | 59 | def _filter_annotations(annotations: Dict[str, Type], internals: bool) -> Dict[str, Type]: 60 | """Filter an annotations dict for to remove or keep internal fields.""" 61 | return annotations if internals else {f: a for f, a in annotations.items() 62 | if not f.startswith('_') and not Internal.is_hinted(a)} 63 | 64 | 65 | def _recurse_structure(var: Any, iter_proc: Callable) -> Any: 66 | """Recursively convert an arbitrarily nested structure beginning at `var`, copying and processing any iterables 67 | encountered with `iter_proc`.""" 68 | if is_dataclass(var): 69 | var = values(var, internals=True) 70 | if hasattr(var, '_asdict'): # handle named tuples 71 | # noinspection PyCallingNonCallable, PyProtectedMember 72 | var = var._asdict() 73 | if isinstance(var, dict): 74 | return iter_proc((_recurse_structure(k, iter_proc), _recurse_structure(v, iter_proc)) for k, v in var.items()) 75 | if isinstance(var, (list, tuple)): 76 | return type(var)(_recurse_structure(e, iter_proc) for e in var) 77 | return var 78 | -------------------------------------------------------------------------------- /dataclassy/mypy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2021 Gianni Tedesco. 3 | This Source Code Form is subject to the terms of the Mozilla Public 4 | License, v. 2.0. If a copy of the MPL was not distributed with this 5 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | This file is a plugin for mypy that adds support for dataclassy. 8 | """ 9 | from typing import ( 10 | Generator, Optional, Iterable, Callable, NamedTuple, Mapping, Type, Tuple, 11 | List, TypeVar, 12 | ) 13 | from operator import or_ 14 | from functools import reduce 15 | 16 | from mypy.plugin import ( 17 | SemanticAnalyzerPluginInterface, Plugin, ClassDefContext, 18 | ) 19 | from mypy.nodes import ( 20 | ARG_POS, ARG_OPT, MDEF, JsonDict, TypeInfo, Argument, AssignmentStmt, 21 | PlaceholderNode, SymbolTableNode, TempNode, NameExpr, Var, 22 | ) 23 | from mypy.plugins.common import ( 24 | add_method, _get_decorator_bool_argument, deserialize_and_fixup_type, 25 | ) 26 | from mypy.types import NoneType, get_proper_type 27 | 28 | __all__ = ( 29 | 'DataclassyPlugin', 30 | 'plugin', 31 | ) 32 | 33 | 34 | T = TypeVar('T') 35 | 36 | 37 | def partition(pred: Callable[[T], bool], 38 | it: Iterable[T], 39 | ) -> Tuple[List[T], List[T]]: 40 | ts: List[T] = [] 41 | fs: List[T] = [] 42 | t = ts.append 43 | f = fs.append 44 | for item in it: 45 | (t if pred(item) else f)(item) 46 | return fs, ts 47 | 48 | 49 | ClassDefCallback = Optional[Callable[[ClassDefContext], None]] 50 | _meta_key = 'dataclassy' 51 | 52 | 53 | class ClassyArgs(NamedTuple): 54 | init: bool = True 55 | repr: bool = True 56 | eq: bool = True 57 | frozen: bool = False 58 | order: bool = False 59 | unsafe_hash: bool = False 60 | match_args: bool = True 61 | kw_only: bool = False 62 | 63 | kwargs: bool = False 64 | slots: bool = False 65 | iter: bool = False 66 | hide_internals: bool = True 67 | 68 | class ClassyField(NamedTuple): 69 | name: str 70 | type: Type 71 | has_default: bool = False 72 | 73 | @property 74 | def var(self): 75 | return Var(self.name, self.type) 76 | 77 | @property 78 | def metharg(self): 79 | kind = ARG_POS if not self.has_default else ARG_OPT 80 | return Argument( 81 | variable=self.var, 82 | type_annotation=self.type, 83 | initializer=None, 84 | kind=kind, 85 | ) 86 | 87 | def serialize(self) -> JsonDict: 88 | return { 89 | 'name': self.name, 90 | 'type': self.type.serialize(), 91 | 'has_default': self.has_default, 92 | } 93 | 94 | @classmethod 95 | def deserialize(cls, 96 | info: TypeInfo, 97 | data: JsonDict, 98 | api: SemanticAnalyzerPluginInterface, 99 | ) -> 'ClassyField': 100 | return cls( 101 | name=data['name'], 102 | type=deserialize_and_fixup_type(data['type'], api), 103 | has_default=data['has_default'], 104 | ) 105 | 106 | 107 | class ClassyInfo(NamedTuple): 108 | fields: Mapping[str, ClassyField] 109 | args: ClassyArgs 110 | is_root: bool = False 111 | 112 | def serialize(self) -> JsonDict: 113 | return { 114 | 'fields': {name: f.serialize() for name, f in self.fields.items()}, 115 | 'args': self.args._asdict(), 116 | 'is_root': self.is_root, 117 | } 118 | 119 | @classmethod 120 | def deserialize(cls, 121 | info: TypeInfo, 122 | data: JsonDict, 123 | api: SemanticAnalyzerPluginInterface, 124 | ) -> 'ClassyInfo': 125 | return cls( 126 | fields={k: ClassyField.deserialize(info, v, api) 127 | for k, v in data['fields'].items()}, 128 | args=ClassyArgs(**data['args']), 129 | is_root=data['is_root'], 130 | ) 131 | 132 | 133 | def _gather_attributes(cls) -> Generator[ClassyField, None, None]: 134 | info = cls.info 135 | 136 | defaults: List[ClassyField] = [] 137 | 138 | for s in cls.defs.body: 139 | if not (isinstance(s, AssignmentStmt) and s.new_syntax): 140 | continue 141 | 142 | lhs = s.lvalues[0] 143 | if not isinstance(lhs, NameExpr): 144 | continue 145 | 146 | name = lhs.name 147 | 148 | sym = info.names.get(name) 149 | if sym is None: 150 | continue 151 | 152 | node = sym.node 153 | if isinstance(node, PlaceholderNode): 154 | return None 155 | 156 | assert isinstance(node, Var) 157 | 158 | if node.is_classvar: 159 | continue 160 | 161 | node_type = get_proper_type(node.type) 162 | 163 | rexpr = s.rvalue 164 | if not isinstance(rexpr, TempNode): 165 | # print('DEFAULT:', name, node_type, type(rexpr)) 166 | defaults.append(ClassyField(name, sym.type, True)) 167 | continue 168 | 169 | yield ClassyField(name, sym.type) 170 | 171 | yield from defaults 172 | 173 | 174 | def _munge_dataclassy(ctx: ClassDefContext, 175 | classy: ClassyInfo, 176 | ) -> None: 177 | cls = ctx.cls 178 | info = cls.info 179 | fields = classy.fields 180 | 181 | # We store the dataclassy info here so that we can figure out later which 182 | # classes are dataclassy classes 183 | info.metadata[_meta_key] = classy.serialize() 184 | 185 | # Add the __init__ method if we have to 186 | if classy.args.init: 187 | add_method( 188 | ctx, 189 | '__init__', 190 | args=[f.metharg for f in fields.values()], 191 | return_type=NoneType(), 192 | ) 193 | 194 | # Add the fields 195 | for field in fields.values(): 196 | var = field.var 197 | var.info = info 198 | var.is_property = True 199 | var._fullname = f'{info.fullname}.{var.name}' 200 | info.names[field.name] = SymbolTableNode(MDEF, var) 201 | 202 | 203 | def _make_dataclassy(ctx: ClassDefContext) -> None: 204 | """ 205 | This class has the @dataclassy decorator. It is going to be the root-class 206 | of a dataclassy hierarchy. 207 | 208 | """ 209 | 210 | cls = ctx.cls 211 | info = cls.info 212 | 213 | name = cls.name 214 | bases = info.bases 215 | 216 | # Get the decorator arguments 217 | args_dict = {a: _get_decorator_bool_argument(ctx, a, d) 218 | for (a, d) in ClassyArgs._field_defaults.items()} 219 | args = ClassyArgs(**args_dict) 220 | 221 | # Then the fields 222 | fields = {f.name: f for f in _gather_attributes(cls)} 223 | 224 | # Finally, annotate the thing 225 | classy = ClassyInfo(fields, args, is_root=True) 226 | _munge_dataclassy(ctx, classy) 227 | 228 | 229 | def _check_dataclassy(ctx: ClassDefContext) -> None: 230 | """ 231 | If this class has a @dataclassy-decorated class in one of it's base-classes 232 | then we need to look at all the fields in all dataclassy parent classes, 233 | and we need to get the decorator-args for the root-dataclassy type in the 234 | hierarchy and combine all that together to figure out how to annotate this 235 | one. 236 | """ 237 | cls = ctx.cls 238 | info = cls.info 239 | 240 | name = cls.name 241 | 242 | # gather metadata from all parent classes in MRO 243 | all_metas = (t.metadata.get(_meta_key) for t in reversed(info.mro)) 244 | parents = [ClassyInfo.deserialize(info, t, ctx.api) 245 | for t in all_metas if t is not None] 246 | 247 | # There are no dataclassy classes, so we're done 248 | if not parents: 249 | return 250 | 251 | # Figure out which of the parents is the root, we need this to get 252 | # decorator args 253 | args = [t for t in parents if t.is_root][0].args 254 | 255 | # Collect together all fields from parents, and finally from this class 256 | order: List[ClassyField] = [] 257 | for t in parents: 258 | order.extend(t.fields.values()) 259 | order.extend(_gather_attributes(cls)) 260 | 261 | # Now partition the fields so we can put those with default values at the 262 | # end of the list 263 | order, defaults = partition(lambda f: f.has_default, order) 264 | order.extend(defaults) 265 | 266 | fields = {f.name: f for f in order} 267 | 268 | # Finally, we can annotate the current class 269 | classy = ClassyInfo(fields, args, is_root=False) 270 | _munge_dataclassy(ctx, classy) 271 | 272 | 273 | class DataclassyPlugin(Plugin): 274 | _decorators = { 275 | 'dataclassy.decorator.dataclass', 276 | } 277 | 278 | def get_class_decorator_hook(self, fullname: str) -> ClassDefCallback: 279 | if fullname not in self._decorators: 280 | return None 281 | return _make_dataclassy 282 | 283 | def get_base_class_hook(self, fullname: str) -> ClassDefCallback: 284 | return _check_dataclassy 285 | 286 | 287 | def plugin(version: str): 288 | return DataclassyPlugin 289 | -------------------------------------------------------------------------------- /dataclassy/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/biqqles/dataclassy/94f9cb8495f31cd2916a39bf2670d83567fc7553/dataclassy/py.typed -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = dataclassy.mypy 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='dataclassy', 5 | version='1.0.1', 6 | 7 | author='biqqles', 8 | author_email='biqqles@proton.me', 9 | url='https://github.com/biqqles/dataclassy', 10 | 11 | description='A fast and flexible reimplementation of data classes', 12 | long_description=open('README.md').read(), 13 | long_description_content_type='text/markdown', 14 | 15 | packages=['dataclassy'], 16 | package_data={ 17 | 'dataclassy': ['py.typed'], 18 | }, 19 | classifiers=[ 20 | 'Programming Language :: Python :: 3', 21 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 22 | 'Operating System :: OS Independent', 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Programming Language :: Python :: Implementation :: CPython', 25 | 'Programming Language :: Python :: Implementation :: PyPy', 26 | 'Typing :: Typed', 27 | ], 28 | python_requires='>=3.6', 29 | ) 30 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020, 2021 biqqles, Heshy Roskes. 3 | This Source Code Form is subject to the terms of the Mozilla Public 4 | License, v. 2.0. If a copy of the MPL was not distributed with this 5 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | This file contains tests for dataclassy. 8 | """ 9 | import unittest 10 | from typing import Any, Dict, List, Tuple, Optional, Type, Union 11 | from abc import ABCMeta 12 | from collections import OrderedDict, namedtuple 13 | from inspect import signature 14 | from platform import python_implementation 15 | from sys import getsizeof, version_info 16 | 17 | from dataclassy import * 18 | from dataclassy.dataclass import DataClassMeta 19 | 20 | 21 | def parameters(obj) -> Dict[str, Union[Type, Tuple[Type, Any]]]: 22 | """Get the parameters for a callable. Returns an OrderedDict so that order is taken into account when comparing. 23 | TODO: update for Python >3.10 where all annotations are strings.""" 24 | raw_parameters = signature(obj).parameters.values() 25 | return OrderedDict({p.name: p.annotation if p.default is p.empty else (p.annotation, p.default) 26 | for p in raw_parameters}) 27 | 28 | 29 | class Tests(unittest.TestCase): 30 | def setUp(self): 31 | """Define and initialise some data classes.""" 32 | @dataclass(slots=True) 33 | class Alpha: 34 | a: int 35 | b: int = 2 36 | c: int 37 | 38 | class Beta(Alpha): 39 | d: int = 4 40 | e: Internal[Dict] = {} 41 | f: int 42 | 43 | @dataclass(slots=False, iter=True) # test option inheritance and overriding 44 | class Gamma(Beta): 45 | pass 46 | 47 | @dataclass # same fields as Beta but without inheritance or slots 48 | class Delta: 49 | a: int 50 | b: int = 2 51 | c: int 52 | d: int = 4 53 | e: Internal[Dict] = {} 54 | f: str 55 | 56 | NT = namedtuple('NT', 'x y z') 57 | 58 | @dataclass # a complex (nested) class 59 | class Epsilon: 60 | g: Tuple[NT] 61 | h: List['Epsilon'] 62 | _i: int = 0 63 | 64 | @dataclass(iter=True, order=True, hide_internals=False) 65 | class Zeta: 66 | a: int 67 | _b: int 68 | 69 | @dataclass(iter=True, order=True) 70 | class Eta: 71 | a: int 72 | _b: int 73 | 74 | self.Alpha, self.Beta, self.Gamma, self.Delta, self.Epsilon, self.Zeta, self.Eta = Alpha, Beta, Gamma, Delta, Epsilon, Zeta, Eta 75 | self.NT = NT 76 | self.b = self.Beta(1, 2, 3) 77 | self.e = self.Epsilon((self.NT(1, 2, 3)), [self.Epsilon(4, 5, 6)]) 78 | 79 | def test_decorator_options(self): 80 | """Test decorator options are inherited and overridden correctly.""" 81 | self.assertTrue(self.Beta.__dataclass__['slots']) 82 | self.assertFalse(self.Delta.__dataclass__['slots']) 83 | 84 | def test_invalid_decorator_use(self): 85 | """Test invalid use of the decorator.""" 86 | with self.assertRaises(TypeError): 87 | dataclass(1) 88 | 89 | with self.assertRaises(AssertionError): 90 | @dataclass(meta=int) 91 | class Dummy: 92 | pass 93 | 94 | def test_readme(self): 95 | """Test all examples from the project readme.""" 96 | from dataclassy import dataclass 97 | from typing import Dict 98 | 99 | @dataclass 100 | class Pet: 101 | name: str 102 | species: str 103 | fluffy: bool 104 | foods: Dict[str, int] = {} 105 | 106 | self.assertEqual(parameters(Pet), 107 | OrderedDict({'name': str, 'species': str, 'fluffy': bool, 'foods': (Dict[str, int], {})})) 108 | 109 | @dataclass 110 | class HungryPet(Pet): 111 | hungry: bool 112 | 113 | self.assertEqual(parameters(HungryPet), 114 | OrderedDict({'name': str, 'species': str, 'fluffy': bool, 115 | 'hungry': bool, 'foods': (Dict[str, int], {})})) 116 | 117 | @dataclass 118 | class CustomInit: 119 | a: int 120 | b: int 121 | 122 | def __post_init__(self): 123 | self.c = self.a / self.b 124 | 125 | self.assertEqual(CustomInit(1, 2).c, 0.5) 126 | 127 | class MyClass: 128 | pass 129 | 130 | @dataclass 131 | class CustomDefault: 132 | m: MyClass = factory(MyClass) 133 | 134 | self.assertIsNot(CustomDefault().m, CustomDefault().m) 135 | 136 | def test_internal(self): 137 | """Test the internal type hint.""" 138 | self.assertTrue(Internal.is_hinted(Internal[int])) 139 | self.assertTrue(Internal.is_hinted('Internal[int]')) 140 | self.assertTrue(Internal.is_hinted(Internal[Union[int, str]])) 141 | self.assertTrue(Internal.is_hinted('Internal[Callable[[int], Tuple[int, int]]]')) 142 | self.assertFalse(Internal.is_hinted(int)) 143 | self.assertFalse(Internal.is_hinted(Union[int, str])) 144 | 145 | # test __args__ being present but None 146 | issue39 = Union[int, str] 147 | issue39.__args__ = None 148 | self.assertFalse(Internal.is_hinted(issue39)) 149 | 150 | def test_init(self): 151 | """Test correct generation of a __new__ method.""" 152 | self.assertEqual( 153 | parameters(self.Beta), 154 | OrderedDict({'a': int, 'c': int, 'f': int, 'b': (int, 2), 'd': (int, 4), 'e': (Union[Internal, Dict], {})})) 155 | 156 | @dataclass(init=False) 157 | class NoInit: 158 | def __post_init__(self): 159 | pass 160 | 161 | def test_repr(self): 162 | """Test correct generation of a __repr__ method.""" 163 | self.assertEqual(repr(self.b), 'Beta(a=1, b=2, c=2, d=4, f=3)') 164 | 165 | @dataclass 166 | class Recursive: 167 | recurse: Optional['Recursive'] = None 168 | 169 | r = Recursive() 170 | r.recurse = r 171 | self.assertEqual(repr(r), 'Recursive(recurse=...)') 172 | 173 | s = Recursive(r) # circularly recursive 174 | self.assertEqual(repr(s), 'Recursive(recurse=Recursive(recurse=...))') 175 | 176 | def test_iter(self): 177 | """Test correct generation of an __iter__ method.""" 178 | iterable = self.Gamma(0, 1, [2, 3]) 179 | a, b, *_, f = iterable 180 | self.assertEqual(a, 0) 181 | self.assertEqual(b, 2) 182 | self.assertEqual(f, [2, 3]) 183 | 184 | # test with and without hide_internals 185 | iterable = self.Zeta(0, 1) 186 | a, _b = iterable 187 | self.assertEqual(a, 0) 188 | self.assertEqual(_b, 1) 189 | iterable = self.Eta(0, 1) 190 | with self.assertRaises(ValueError): 191 | a, _b = iterable 192 | 193 | def test_eq(self): 194 | """Test correct generation of an __eq__ method.""" 195 | self.assertEqual(self.b, self.b) 196 | unequal_b = self.Beta(10, 20, 30) 197 | self.assertNotEqual(self.b, unequal_b) 198 | self.assertNotEqual(self.b, [0]) # test comparisons with non-dataclasses 199 | 200 | # test with and without hide_internals 201 | self.assertNotEqual(self.Zeta(0, 0), self.Zeta(0, 1)) 202 | self.assertEqual(self.Eta(0, 0), self.Eta(0, 1)) 203 | 204 | def test_order(self): 205 | """Test correct generation of comparison methods.""" 206 | @dataclass(order=True) 207 | class Orderable: 208 | a: int 209 | b: int 210 | 211 | class OrderableSubclass(Orderable): 212 | c: int = 0 213 | 214 | @dataclass(eq=False, order=True) 215 | class PartiallyOrderable: 216 | pass 217 | 218 | self.assertTrue(Orderable(1, 2) < Orderable(1, 3)) 219 | self.assertTrue(Orderable(1, 3) > Orderable(1, 2)) 220 | self.assertTrue(Orderable(1, 2) < OrderableSubclass(1, 3)) # subclasses are comparable 221 | self.assertTrue(OrderableSubclass(1, 3) >= OrderableSubclass(1, 2)) 222 | 223 | self.assertEqual(sorted([Orderable(1, 2), OrderableSubclass(1, 3), Orderable(0, 0)]), 224 | [Orderable(0, 0), Orderable(1, 2), OrderableSubclass(1, 3)]) 225 | 226 | with self.assertRaises(TypeError): # test absence of total_ordering if eq is false 227 | PartiallyOrderable() >= PartiallyOrderable() 228 | 229 | # test with and without hide_internals 230 | self.assertLess(self.Zeta(0, 0), self.Zeta(0, 1)) 231 | self.assertFalse(self.Eta(0, 0) < self.Eta(0, 1)) 232 | 233 | def test_hashable(self): 234 | """Test correct generation of a __hash__ method.""" 235 | @dataclass(eq=True, frozen=True) 236 | class Hashable: 237 | a: Hashed[int] 238 | b: List[int] = [2] 239 | 240 | @dataclass(unsafe_hash=True) 241 | class AlsoHashable: 242 | c: Hashed[int] 243 | 244 | self.assertFalse(hash(Hashable(1)) == hash(AlsoHashable(1))) 245 | d = {Hashable(1): 1, Hashable(2): 2, AlsoHashable(1): 3} 246 | self.assertEqual(d[Hashable(1)], 1) 247 | self.assertEqual(hash((Hashable, 1)), hash(Hashable(1))) 248 | 249 | @dataclass(unsafe_hash=True) 250 | class Invalid: 251 | d: Hashed[List[str]] 252 | 253 | with self.assertRaises(TypeError): 254 | hash(Invalid([])) 255 | 256 | def test_slots(self): 257 | """Test correct generation and efficacy of a __slots__ attribute.""" 258 | self.assertTrue(hasattr(self.b, '__slots__')) 259 | self.assertFalse(hasattr(self.b, '__dict__')) 260 | e = self.Epsilon(1, 2, 3) 261 | 262 | if python_implementation() != 'PyPy': # sizes cannot be determined on PyPy 263 | self.assertGreater(getsizeof(e) + getsizeof(e.__dict__), getsizeof(self.b)) 264 | 265 | # test repeated decorator application (issue #50) 266 | @dataclass(slots=True) 267 | class Base: 268 | foo: int 269 | 270 | @dataclass(slots=True) 271 | class Derived(Base): 272 | bar: int 273 | 274 | self.assertEqual(Base.__slots__, ('foo',)) 275 | self.assertEqual(Derived.__slots__, ('bar',)) 276 | 277 | Derived(1, 2) 278 | 279 | def test_frozen(self): 280 | """Test correct generation of __setattr__ and __delattr__ for a frozen class.""" 281 | @dataclass(frozen=True) 282 | class Frozen: 283 | a: int 284 | b: int 285 | 286 | f = Frozen(1, 2) 287 | with self.assertRaises(AttributeError): 288 | f.a = 3 289 | with self.assertRaises(AttributeError): 290 | del f.b 291 | 292 | def test_empty_dataclass(self): 293 | """Test data classes with no fields and data classes with only class fields.""" 294 | @dataclass 295 | class Empty: 296 | pass 297 | 298 | @dataclass(kwargs=False) 299 | class ClassVarOnly: 300 | class_var = 0 301 | 302 | self.assertEqual(parameters(ClassVarOnly), {}) 303 | 304 | def test_mutable_defaults(self): 305 | """Test mutable defaults are copied and not mutated between instances.""" 306 | @dataclass 307 | class MutableDefault: 308 | mutable: List[int] = [] 309 | 310 | a = MutableDefault() 311 | a.mutable.append(2) 312 | b = MutableDefault() 313 | b.mutable.append(3) 314 | c = MutableDefault(4) # incorrect types should still be OK (shouldn't try to call copy) 315 | self.assertEqual(a.mutable, [2]) 316 | self.assertEqual(b.mutable, [3]) 317 | self.assertEqual(c.mutable, 4) 318 | 319 | def test_custom_init(self): 320 | """Test user-defined __post_init__ used for post-initialisation logic.""" 321 | @dataclass 322 | class CustomPostInit: 323 | a: int 324 | b: int 325 | 326 | def __post_init__(self, c): 327 | self.d = (self.a + self.b) / c 328 | 329 | custom_post = CustomPostInit(1, 2, 3) 330 | self.assertEqual(custom_post.d, 1.0) 331 | 332 | @dataclass 333 | class CustomInitKwargs: 334 | a: int 335 | b: int 336 | 337 | def __post_init__(self, *args, **kwargs): 338 | self.c = kwargs 339 | 340 | custom_kwargs = CustomInitKwargs(1, 2, c=3) 341 | self.assertEqual(custom_kwargs.c, {'c': 3}) 342 | 343 | @dataclass 344 | class Issue6: 345 | path: int = 1 346 | 347 | def __post_init__(self): 348 | pass 349 | 350 | Issue6(3) # previously broken (see issue #6) 351 | with self.assertRaises(TypeError): # previously broken (see issue #7) 352 | Issue6(3, a=2) 353 | 354 | # test class with no fields but init args 355 | 356 | @dataclass 357 | class Empty: 358 | def __init__(self, a): # TODO: change into test for __post_init__ aliasing when that feature is added 359 | pass 360 | 361 | Empty(0) 362 | 363 | # test init detection when defined on subclass 364 | 365 | @dataclass 366 | class TotallyEmpty: 367 | pass 368 | 369 | class HasInit(TotallyEmpty): 370 | _test: int = None 371 | 372 | def __post_init__(self, test: int): 373 | self._test = test 374 | 375 | HasInit(test=3) 376 | 377 | def test_multiple_inheritance(self): 378 | """Test that multiple inheritance produces an __post_init__ with the expected parameters.""" 379 | class Multiple(self.Alpha, self.Epsilon): 380 | z: bool 381 | 382 | self.assertEqual(parameters(Multiple), 383 | OrderedDict({'a': int, 'c': int, 'g': Tuple[self.NT], 'h': List['Epsilon'], 'z': bool, 384 | 'b': (int, 2), '_i': (int, 0)})) 385 | 386 | # verify initialiser is functional 387 | multiple = Multiple(1, 2, tuple(), [], True) 388 | self.assertEqual(multiple.a, 1) 389 | self.assertEqual(multiple.h, []) 390 | 391 | def test_init_subclass(self): 392 | """Test custom init when it is defined in a subclass.""" 393 | @dataclass 394 | class NoInit: 395 | a: int 396 | 397 | class NoInitInSubClass(NoInit): 398 | b: int 399 | 400 | class InitInSubClass(NoInit): 401 | def __post_init__(self, c): 402 | self.c = c 403 | 404 | self.assertTrue(hasattr(InitInSubClass, '__post_init__')) 405 | self.assertFalse(hasattr(NoInitInSubClass, '__post_init__')) 406 | init_in_sub_class = InitInSubClass(0, 1) 407 | self.assertEqual(init_in_sub_class.c, 1) 408 | 409 | def test_no_init_subclass(self): 410 | """Test custom init when it is defined in a superclass.""" 411 | @dataclass 412 | class HasInit: 413 | a: int 414 | 415 | def __post_init__(self, d): 416 | self.d = d 417 | 418 | class NoInitInSubClass(HasInit): 419 | b: int 420 | 421 | class NoInitInSubSubClass(NoInitInSubClass): 422 | c: int 423 | 424 | no_init_in_sub_class = NoInitInSubClass(a=1, b=2, d=3) 425 | self.assertEqual(no_init_in_sub_class.d, 3) 426 | no_init_in_sub_sub_class = NoInitInSubSubClass(a=1, b=2, c=3, d=4) 427 | self.assertEqual(no_init_in_sub_sub_class.d, 4) 428 | 429 | def test_fields(self): 430 | """Test fields().""" 431 | self.assertEqual(fields(self.e), dict(g=Tuple[self.NT], h=List['Epsilon'])) 432 | self.assertEqual(fields(self.e, True), dict(g=Tuple[self.NT], h=List['Epsilon'], _i=int)) 433 | 434 | def test_values(self): 435 | """Test values().""" 436 | self.assertEqual(values(self.e), dict(g=self.NT(1, 2, 3), h=[self.Epsilon(4, 5)])) 437 | self.assertEqual(values(self.e, True), dict(g=self.NT(1, 2, 3), h=[self.Epsilon(4, 5)], _i=0)) 438 | 439 | def test_as_tuple(self): 440 | """Test as_tuple().""" 441 | self.assertEqual(as_tuple(self.e), ((1, 2, 3), [(4, 5, 6)], 0)) 442 | 443 | def test_as_dict(self): 444 | """Test as_dict().""" 445 | self.assertEqual(as_dict(self.e), {'g': {'x': 1, 'y': 2, 'z': 3}, 'h': [{'g': 4, 'h': 5, '_i': 6}], '_i': 0}) 446 | 447 | def test_make_dataclass(self): 448 | """Test functional creation of a data class using make_dataclass.""" 449 | dynamic = make_dataclass('Dynamic', dict(name=str), {}) 450 | dynamic(name='Dynamic') 451 | 452 | def test_replace(self): 453 | """Test replace().""" 454 | self.assertEqual(replace(self.b, f=4), self.Beta(1, 2, 4)) 455 | self.assertEqual(self.b, self.Beta(1, 2, 3)) # assert that the original instance remains unchanged 456 | 457 | def test_meta_subclass(self): 458 | """Test subclassing of DataClassMeta.""" 459 | class DataClassMetaSubclass(DataClassMeta): 460 | def __new__(mcs, name, bases, dict_): 461 | dict_['get_a'] = lambda self: self.a 462 | return super().__new__(mcs, name, bases, dict_) 463 | 464 | @dataclass(meta=DataClassMetaSubclass) 465 | class UserDataClass: 466 | a: int 467 | 468 | self.assertEqual(UserDataClass(a=2).get_a(), 2) 469 | 470 | def test_classcell(self): 471 | """Test that __classcell__ gets passed to type.__new__ if and only if it's supposed to. 472 | __classcell__ gets generated whenever a class uses super().""" 473 | @dataclass 474 | class Parent: 475 | a: int 476 | 477 | def f(self): 478 | return self.a 479 | 480 | # creating the Child1 class will fail in python >= 3.8 if __classcell__ doesn't get propagated 481 | # in < 3.8, it will give a DeprecationWarning, but calling f will give an error 482 | class Child1(Parent): 483 | def f(self): 484 | self.a += 1 485 | return super().f() 486 | 487 | child1 = Child1(3) 488 | self.assertEqual(child1.f(), 4) 489 | 490 | class Child2(Parent): 491 | def f(self): 492 | self.a += 2 493 | return super().f() 494 | 495 | child2 = Child2(3) 496 | self.assertEqual(child2.f(), 5) 497 | 498 | class MultipleInheritance(Child1, Child2): 499 | pass 500 | 501 | multiple_inheritance = MultipleInheritance(3) 502 | 503 | # if __classcell__ from a parent gets passed to type.__new__ 504 | # when there's no __classcell__ in the child, then this gives 505 | # an infinite recursion error. 506 | 507 | # if f is given from the parent to the child 508 | # when there's no f in the child, then 509 | # it returns 5 instead of 6, because MultipleInheritance explicitly 510 | # gets Child2's f, and super() redirects to Parent's f, skipping Child1 511 | self.assertEqual(multiple_inheritance.f(), 6) 512 | 513 | def test_inheritance(self): 514 | """Test that method inheritance works as expected.""" 515 | @dataclass(iter=True) 516 | class Parent: 517 | a: int 518 | 519 | def __hash__(self): 520 | return hash(self.a) 521 | 522 | def user_method(self): 523 | return 524 | 525 | class Child(Parent): 526 | b: int = 0 527 | 528 | class Grandchild(Child): 529 | def __hash__(self): 530 | return 2 * super().__hash__() 531 | 532 | # user-defined methods are untouched 533 | self.assertIs(Parent.__hash__, Child.__hash__) 534 | self.assertIs(Parent.user_method, Child.user_method) 535 | self.assertEqual(hash(Parent(1)) * 2, hash(Grandchild(1))) 536 | 537 | # dataclassy-defined methods are replaced 538 | self.assertIsNot(Parent.__init__, Child.__init__) 539 | 540 | @dataclass(unsafe_hash=True) 541 | class Parent2: 542 | a: int 543 | 544 | class Child2(Parent2): 545 | b: int 546 | 547 | # dataclassy-defined methods are regenerated for subclasses 548 | self.assertIsNot(Parent2.__hash__, Child2.__hash__) 549 | 550 | def test_multiple_inheritance_post_init(self): 551 | """Test post-init execution under multiple-inheritance.""" 552 | @dataclass 553 | class Grandparent: 554 | a: int 555 | 556 | def __post_init__(self): 557 | pass 558 | 559 | class Parent1(Grandparent): 560 | b: int 561 | 562 | def __post_init__(self, c, *args, **kwargs): 563 | self.c = c 564 | super().__post_init__(*args, **kwargs) 565 | 566 | class Parent2(Grandparent): 567 | d: int 568 | 569 | def __post_init__(self, e, *args, **kwargs): 570 | self.e = e 571 | super().__post_init__(*args, **kwargs) 572 | 573 | class Child(Parent1, Parent2): 574 | pass 575 | 576 | child = Child(a=1, b=2, c=3, d=4, e=5) 577 | self.assertEqual(child.a, 1) 578 | self.assertEqual(child.b, 2) 579 | self.assertEqual(child.c, 3) 580 | self.assertEqual(child.d, 4) 581 | self.assertEqual(child.e, 5) 582 | 583 | def test_multiple_inheritance_members(self): 584 | """Test multiple-inheritance for member functions.""" 585 | @dataclass 586 | class A: 587 | def f(self): 588 | return 1 589 | 590 | class B(A): 591 | def f(self): 592 | return 2 593 | 594 | class C(A): 595 | pass 596 | 597 | class D(C, B): 598 | pass 599 | 600 | self.assertIs(D.f, B.f) 601 | 602 | def test_factory(self): 603 | """Test factory().""" 604 | class CustomClassDefault: 605 | def __init__(self): 606 | self.three = 3 607 | 608 | @dataclass 609 | class WithFactories: 610 | a: Dict = factory(dict) 611 | b: int = factory(lambda: 1) 612 | c: CustomClassDefault = factory(CustomClassDefault) 613 | 614 | with_factories = WithFactories() 615 | self.assertEqual(with_factories.a, {}) 616 | self.assertEqual(with_factories.b, 1) 617 | self.assertEqual(with_factories.c.three, 3) 618 | 619 | with_factories_2 = WithFactories() 620 | self.assertIsNot(with_factories.a, with_factories_2.a) 621 | 622 | def test_abc(self): 623 | """Test subclassing a class with metaclass=ABCMeta. This once caused a weird Attribute Error 624 | (see issue #46)""" 625 | @dataclass 626 | class A(metaclass=ABCMeta): 627 | pass 628 | 629 | class B(A): 630 | pass 631 | 632 | class C(B): 633 | pass 634 | 635 | def test_match_args(self): 636 | """Test generation of a __match_args__ attribute.""" 637 | 638 | # __match_args__ should be tuple in order of parameters to __init__ 639 | self.assertEqual(self.Alpha.__match_args__, ('a', 'c', 'b')) 640 | self.assertEqual(tuple(parameters(self.Beta)), self.Beta.__match_args__) 641 | 642 | # Python 3.10 pattern matching is invalid syntax on older versions to needs to be parsed at runtime 643 | if version_info < (3, 10): 644 | return 645 | 646 | to_be_matched = (0, 2, 1) 647 | namespace = locals().copy() 648 | exec("""match self.Alpha(*to_be_matched): 649 | case self.Alpha(a, c, b): 650 | matched_value = a, c, b""", {}, namespace) 651 | 652 | self.assertEqual(namespace['matched_value'], to_be_matched) 653 | 654 | def test_kw_only(self): 655 | """Test effect of the kw_only decorator option.""" 656 | @dataclass(kw_only=True) 657 | class KwOnly: 658 | a: int 659 | b: str 660 | 661 | KwOnly(a=1, b='2') 662 | 663 | with self.assertRaises(TypeError): 664 | KwOnly(1, '2') 665 | 666 | with self.assertRaises(TypeError): 667 | KwOnly() 668 | 669 | # post-init args also become keyword only 670 | 671 | class KwOnlyWithPostInit(KwOnly): 672 | def __post_init__(self, c: float): 673 | pass 674 | 675 | KwOnlyWithPostInit(a=1, b='2', c=3.0) 676 | 677 | with self.assertRaises(TypeError): 678 | KwOnlyWithPostInit(3.0, a=1, b='2') 679 | 680 | 681 | if __name__ == '__main__': 682 | unittest.main() 683 | --------------------------------------------------------------------------------