classicist 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- classicist-1.0.0/LICENSE.md +21 -0
- classicist-1.0.0/PKG-INFO +257 -0
- classicist-1.0.0/README.md +227 -0
- classicist-1.0.0/pyproject.toml +66 -0
- classicist-1.0.0/requirements.development.txt +4 -0
- classicist-1.0.0/requirements.distribution.txt +4 -0
- classicist-1.0.0/requirements.txt +1 -0
- classicist-1.0.0/setup.cfg +4 -0
- classicist-1.0.0/source/classicist/__init__.py +118 -0
- classicist-1.0.0/source/classicist/version.txt +1 -0
- classicist-1.0.0/source/classicist.egg-info/PKG-INFO +257 -0
- classicist-1.0.0/source/classicist.egg-info/SOURCES.txt +16 -0
- classicist-1.0.0/source/classicist.egg-info/dependency_links.txt +1 -0
- classicist-1.0.0/source/classicist.egg-info/requires.txt +10 -0
- classicist-1.0.0/source/classicist.egg-info/top_level.txt +1 -0
- classicist-1.0.0/source/classicist.egg-info/zip-safe +1 -0
- classicist-1.0.0/tests/test_classproperty.py +33 -0
- classicist-1.0.0/tests/test_hybridmethod.py +148 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright © 2025 Daniel Sissman.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: classicist
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Classy class decorators for Python.
|
|
5
|
+
Author: Daniel Sissman
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: documentation, https://github.com/bluebinary/classicist/blob/main/README.md
|
|
8
|
+
Project-URL: changelog, https://github.com/bluebinary/classicist/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: repository, https://github.com/bluebinary/classicist
|
|
10
|
+
Project-URL: issues, https://github.com/bluebinary/classicist/issues
|
|
11
|
+
Project-URL: homepage, https://github.com/bluebinary/classicist
|
|
12
|
+
Keywords: decorator,hybrid method,class method,instance method,class property,class properties
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE.md
|
|
21
|
+
Provides-Extra: development
|
|
22
|
+
Requires-Dist: black==24.10.*; extra == "development"
|
|
23
|
+
Requires-Dist: pytest==8.3.*; extra == "development"
|
|
24
|
+
Requires-Dist: pytest-codeblocks==0.17.0; extra == "development"
|
|
25
|
+
Provides-Extra: distribution
|
|
26
|
+
Requires-Dist: build; extra == "distribution"
|
|
27
|
+
Requires-Dist: twine; extra == "distribution"
|
|
28
|
+
Requires-Dist: wheel; extra == "distribution"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Classicist: Classy Class Decorators & Extensions
|
|
32
|
+
|
|
33
|
+
The Classicist library provides several useful class decorators for Python class methods
|
|
34
|
+
including a `hybridmethod` decorator that allows methods defined in a class to be used
|
|
35
|
+
both a class method and an instance method, and a `classproperty` decorator that allows
|
|
36
|
+
class methods to be accessed as class properties.
|
|
37
|
+
|
|
38
|
+
The `classicist` library was previously named `hybridmethod` so if a prior version had been
|
|
39
|
+
installed, please update references to the new library name. Installation of the
|
|
40
|
+
library via its old name, `hybridmethod`, will install the new `classicist` library with
|
|
41
|
+
a mapping for backwards compatibility so that code continues to function as before.
|
|
42
|
+
|
|
43
|
+
### Requirements
|
|
44
|
+
|
|
45
|
+
The Classicist library has been tested with Python 3.9, 3.10, 3.11, 3.12 and 3.13. The library is not compatible with Python 3.8 or earlier.
|
|
46
|
+
|
|
47
|
+
### Installation
|
|
48
|
+
|
|
49
|
+
The Classicist library is available from PyPI, so may be added to a project's dependencies via its `requirements.txt` file or similar by referencing the Classicist library's name, `classicist`, or the library may be installed directly into your local runtime environment using `pip` via the `pip install` command by entering the following into your shell:
|
|
50
|
+
|
|
51
|
+
$ pip install classicist
|
|
52
|
+
|
|
53
|
+
#### Hybrid Methods
|
|
54
|
+
|
|
55
|
+
The Classicist library provides a `hybridmethod` method decorator that allows methods
|
|
56
|
+
defined in a class to be used as both a class method and an instance method.
|
|
57
|
+
|
|
58
|
+
The `@hybridmethod` decorator provided by the library wraps methods defined in classes
|
|
59
|
+
using the usual `@decorator` syntax. Methods defined in classes that are decorated with
|
|
60
|
+
the `@hybridmethod` decorator can then be accessed as both class methods and as instance
|
|
61
|
+
methods, with the first argument passed to the method being a reference to either the
|
|
62
|
+
class when the method is called as a class method or to the instance when the method is
|
|
63
|
+
called as an instance method.
|
|
64
|
+
|
|
65
|
+
If a class-level property is defined and then an instance-level property is created with
|
|
66
|
+
the same name that shadows the class-level property, the hybrid method can be used to
|
|
67
|
+
interact with both the class-level property and the instance-level property simply based
|
|
68
|
+
on whether the hybrid method was called directly on the class or on an a class instance.
|
|
69
|
+
|
|
70
|
+
If desired, a simple check of the value of the first variable passed to a hybrid method
|
|
71
|
+
using `isinstance(<variable>, <class>)` allows one to determine if the call was made on
|
|
72
|
+
an instance of the class in which case `isinstance()` evaluates to `True` or if the call
|
|
73
|
+
was made on the class itself, in which case `isinstance()` evaluates to `False`.
|
|
74
|
+
|
|
75
|
+
The variable passed as the first argument to the method may have any name, including as
|
|
76
|
+
is common in Python, `self`, although the use of `self` as the name of this argument on
|
|
77
|
+
an instance method is just customary and the name has no significance.
|
|
78
|
+
|
|
79
|
+
If using the `isinstance(<variable>, <class>)` check as described above is used simply
|
|
80
|
+
substitute in the name of the first variable of a hybrid method for `<variable>` and the
|
|
81
|
+
name of the class for `<class>`.
|
|
82
|
+
|
|
83
|
+
#### Hybrid Methods: Usage
|
|
84
|
+
|
|
85
|
+
To use the `hybridmethod` decorator import the decorator from the `classicist` library
|
|
86
|
+
and use it to decorate the class methods you wish to use as both class methods and
|
|
87
|
+
instance methods:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from classicist import hybridmethod
|
|
91
|
+
|
|
92
|
+
class hybridcollection(object):
|
|
93
|
+
items: list[str] = []
|
|
94
|
+
|
|
95
|
+
def __init__(self):
|
|
96
|
+
# Create an 'items' instance variable; note that this shadows the class variable
|
|
97
|
+
# of the same name which can still be accessed directly via self.__class__.items
|
|
98
|
+
self.items: list[object] = []
|
|
99
|
+
|
|
100
|
+
@hybridmethod
|
|
101
|
+
def add_item(self, item: object):
|
|
102
|
+
# We can use the following line to differentiate between the call being made on
|
|
103
|
+
# an instance or directly on the class; isinstance(self, <class>) returns True
|
|
104
|
+
# if the method was called on an instance of the class, or False if the method
|
|
105
|
+
# was called on the class directly; the 'self' variable will reference either
|
|
106
|
+
# the instance or the class; although 'self' is traditionally used in Python as
|
|
107
|
+
# reference to the instance
|
|
108
|
+
if isinstance(self, hybridcollection):
|
|
109
|
+
self.items.append(item)
|
|
110
|
+
else:
|
|
111
|
+
self.items.append(item)
|
|
112
|
+
|
|
113
|
+
def get_class_items(self) -> list[object]:
|
|
114
|
+
return self.__class__.items
|
|
115
|
+
|
|
116
|
+
def get_instance_items(self) -> list[object]:
|
|
117
|
+
return self.items
|
|
118
|
+
|
|
119
|
+
def get_combined_items(self) -> list[object]:
|
|
120
|
+
return self.__class__.items + self.items
|
|
121
|
+
|
|
122
|
+
hybridcollection.add_item("ABC") # Add an item to the class-level items list
|
|
123
|
+
|
|
124
|
+
collection = hybridcollection()
|
|
125
|
+
|
|
126
|
+
collection.add_item("XYZ") # Add an item to the instance-level items list
|
|
127
|
+
|
|
128
|
+
assert collection.get_class_items() == ["ABC"]
|
|
129
|
+
|
|
130
|
+
assert collection.get_instance_items() == ["XYZ"]
|
|
131
|
+
|
|
132
|
+
assert collection.get_combined_items() == ["ABC", "XYZ"]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### Class Properties
|
|
136
|
+
|
|
137
|
+
The Classicist library provides a `classproperty` method decorator that allows class
|
|
138
|
+
methods to be accessed as class properties.
|
|
139
|
+
|
|
140
|
+
The `@classproperty` decorator provided by the library wraps methods defined in classes
|
|
141
|
+
using the usual `@decorator` syntax. Methods defined in classes that are decorated with
|
|
142
|
+
the `@classproperty` decorator can then be accessed as though they were real properties
|
|
143
|
+
on the class.
|
|
144
|
+
|
|
145
|
+
The `@classproperty` decorator addresses the removal in Python 3.13 of the prior support
|
|
146
|
+
for combining the `@classmethod` and `@property` decorators to create class properties,
|
|
147
|
+
a change which was made due to complexity in the underlying interpreter implementation.
|
|
148
|
+
|
|
149
|
+
#### Class Properties: Usage
|
|
150
|
+
|
|
151
|
+
To use the `classproperty` decorator import the decorator from the `classicist` library
|
|
152
|
+
and use it to decorate any class methods you wish to access as class properties.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from classicist import classproperty
|
|
156
|
+
|
|
157
|
+
class exampleclass(object):
|
|
158
|
+
@classproperty
|
|
159
|
+
def greeting(cls) -> str:
|
|
160
|
+
"""The 'greeting' class method has been decorated with classproperty so acts as
|
|
161
|
+
a property; here we could do some work to generate a return value."""
|
|
162
|
+
return "hello"
|
|
163
|
+
|
|
164
|
+
assert isinstance(exampleclass, type)
|
|
165
|
+
assert issubclass(exampleclass, exampleclass)
|
|
166
|
+
assert issubclass(exampleclass, object)
|
|
167
|
+
|
|
168
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
169
|
+
# The return value of `.greeting` is indiscernible from the value being returned
|
|
170
|
+
assert isinstance(exampleclass.greeting, str)
|
|
171
|
+
assert exampleclass.greeting == "hello"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
⚠️ An important caveat regarding class properties which applies equally to the method of
|
|
175
|
+
supporting class properties provided by this library, and to class properties which are
|
|
176
|
+
supported natively in Python 3.9 – 3.12 by combining the `@classmethod` and `@property`
|
|
177
|
+
decorators, is that unfortunately unless a custom metaclass is used to intervene, class
|
|
178
|
+
properties can be overwritten by value assignment.
|
|
179
|
+
|
|
180
|
+
This is a result of differences in Python's handling for descriptors between classes and
|
|
181
|
+
instances of classes. For both classes and instances, the `__get__` descriptor is called
|
|
182
|
+
while the `__set__` and `__delete__` descriptor methods will only be called on instances
|
|
183
|
+
such that we have no way to be involved in the property reassignment or deletion process
|
|
184
|
+
as would be the case for properties on instances where we can create our own setter and
|
|
185
|
+
deleter methods in addition to the getter.
|
|
186
|
+
|
|
187
|
+
This caveat can be remedied through a custom metaclass however, which overrides default
|
|
188
|
+
behaviour, and is able to intercept the `__setattr_` and `__delattr__` calls as needed.
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from classicist import classproperty
|
|
192
|
+
|
|
193
|
+
class exampleclass(object):
|
|
194
|
+
@classproperty
|
|
195
|
+
def greeting(cls) -> str:
|
|
196
|
+
# Generate a return value here
|
|
197
|
+
return "hello"
|
|
198
|
+
|
|
199
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
200
|
+
assert exampleclass.greeting == "hello"
|
|
201
|
+
|
|
202
|
+
# Note: The `.greeting` property will be reassigned to the new value, "goodbye":
|
|
203
|
+
exampleclass.greeting = "goodbye"
|
|
204
|
+
assert exampleclass.greeting == "goodbye"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
As can be seen with the method of natively supporting class properties, they could also
|
|
208
|
+
have their values reassigned without warning:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
import sys
|
|
212
|
+
import pytest
|
|
213
|
+
|
|
214
|
+
# As Python only natively supported combining @classmethod and @property between version
|
|
215
|
+
# 3.9 and 3.12, the example below is not usable on other versions, such as 3.13+
|
|
216
|
+
if sys.version_info.major == 3 and not (9 <= sys.version_info.minor <= 12):
|
|
217
|
+
pytest.skip("This test can run on Python versions 3.9 – 3.12")
|
|
218
|
+
|
|
219
|
+
class exampleclass(object):
|
|
220
|
+
@classmethod
|
|
221
|
+
@property
|
|
222
|
+
def greeting(cls) -> str:
|
|
223
|
+
# Generate a return value here
|
|
224
|
+
return "hello"
|
|
225
|
+
|
|
226
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
227
|
+
assert exampleclass.greeting == "hello"
|
|
228
|
+
|
|
229
|
+
# Note: The `.greeting` property will be reassigned to the new value, "goodbye":
|
|
230
|
+
exampleclass.greeting = "goodbye"
|
|
231
|
+
assert exampleclass.greeting == "goodbye"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Unit Tests
|
|
235
|
+
|
|
236
|
+
The Classicist library includes a suite of comprehensive unit tests which ensure that
|
|
237
|
+
the library functionality operates as expected. The unit tests were developed with and
|
|
238
|
+
are run via `pytest`.
|
|
239
|
+
|
|
240
|
+
To ensure that the unit tests are run within a predictable runtime environment where all of the necessary dependencies are available, a [Docker](https://www.docker.com) image is created within which the tests are run. To run the unit tests, ensure Docker and Docker Compose is [installed](https://docs.docker.com/engine/install/), and perform the following commands, which will build the Docker image via `docker compose build` and then run the tests via `docker compose run` – the output of running the tests will be displayed:
|
|
241
|
+
|
|
242
|
+
```shell
|
|
243
|
+
$ docker compose build
|
|
244
|
+
$ docker compose run tests
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
To run the unit tests with optional command line arguments being passed to `pytest`, append the relevant arguments to the `docker compose run tests` command, as follows, for example passing `-vv` to enable verbose output:
|
|
248
|
+
|
|
249
|
+
```shell
|
|
250
|
+
$ docker compose run tests -vv
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
See the documentation for [PyTest](https://docs.pytest.org/en/latest/) regarding available optional command line arguments.
|
|
254
|
+
|
|
255
|
+
### Copyright & License Information
|
|
256
|
+
|
|
257
|
+
Copyright © 2025 Daniel Sissman; licensed under the MIT License.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Classicist: Classy Class Decorators & Extensions
|
|
2
|
+
|
|
3
|
+
The Classicist library provides several useful class decorators for Python class methods
|
|
4
|
+
including a `hybridmethod` decorator that allows methods defined in a class to be used
|
|
5
|
+
both a class method and an instance method, and a `classproperty` decorator that allows
|
|
6
|
+
class methods to be accessed as class properties.
|
|
7
|
+
|
|
8
|
+
The `classicist` library was previously named `hybridmethod` so if a prior version had been
|
|
9
|
+
installed, please update references to the new library name. Installation of the
|
|
10
|
+
library via its old name, `hybridmethod`, will install the new `classicist` library with
|
|
11
|
+
a mapping for backwards compatibility so that code continues to function as before.
|
|
12
|
+
|
|
13
|
+
### Requirements
|
|
14
|
+
|
|
15
|
+
The Classicist library has been tested with Python 3.9, 3.10, 3.11, 3.12 and 3.13. The library is not compatible with Python 3.8 or earlier.
|
|
16
|
+
|
|
17
|
+
### Installation
|
|
18
|
+
|
|
19
|
+
The Classicist library is available from PyPI, so may be added to a project's dependencies via its `requirements.txt` file or similar by referencing the Classicist library's name, `classicist`, or the library may be installed directly into your local runtime environment using `pip` via the `pip install` command by entering the following into your shell:
|
|
20
|
+
|
|
21
|
+
$ pip install classicist
|
|
22
|
+
|
|
23
|
+
#### Hybrid Methods
|
|
24
|
+
|
|
25
|
+
The Classicist library provides a `hybridmethod` method decorator that allows methods
|
|
26
|
+
defined in a class to be used as both a class method and an instance method.
|
|
27
|
+
|
|
28
|
+
The `@hybridmethod` decorator provided by the library wraps methods defined in classes
|
|
29
|
+
using the usual `@decorator` syntax. Methods defined in classes that are decorated with
|
|
30
|
+
the `@hybridmethod` decorator can then be accessed as both class methods and as instance
|
|
31
|
+
methods, with the first argument passed to the method being a reference to either the
|
|
32
|
+
class when the method is called as a class method or to the instance when the method is
|
|
33
|
+
called as an instance method.
|
|
34
|
+
|
|
35
|
+
If a class-level property is defined and then an instance-level property is created with
|
|
36
|
+
the same name that shadows the class-level property, the hybrid method can be used to
|
|
37
|
+
interact with both the class-level property and the instance-level property simply based
|
|
38
|
+
on whether the hybrid method was called directly on the class or on an a class instance.
|
|
39
|
+
|
|
40
|
+
If desired, a simple check of the value of the first variable passed to a hybrid method
|
|
41
|
+
using `isinstance(<variable>, <class>)` allows one to determine if the call was made on
|
|
42
|
+
an instance of the class in which case `isinstance()` evaluates to `True` or if the call
|
|
43
|
+
was made on the class itself, in which case `isinstance()` evaluates to `False`.
|
|
44
|
+
|
|
45
|
+
The variable passed as the first argument to the method may have any name, including as
|
|
46
|
+
is common in Python, `self`, although the use of `self` as the name of this argument on
|
|
47
|
+
an instance method is just customary and the name has no significance.
|
|
48
|
+
|
|
49
|
+
If using the `isinstance(<variable>, <class>)` check as described above is used simply
|
|
50
|
+
substitute in the name of the first variable of a hybrid method for `<variable>` and the
|
|
51
|
+
name of the class for `<class>`.
|
|
52
|
+
|
|
53
|
+
#### Hybrid Methods: Usage
|
|
54
|
+
|
|
55
|
+
To use the `hybridmethod` decorator import the decorator from the `classicist` library
|
|
56
|
+
and use it to decorate the class methods you wish to use as both class methods and
|
|
57
|
+
instance methods:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from classicist import hybridmethod
|
|
61
|
+
|
|
62
|
+
class hybridcollection(object):
|
|
63
|
+
items: list[str] = []
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
# Create an 'items' instance variable; note that this shadows the class variable
|
|
67
|
+
# of the same name which can still be accessed directly via self.__class__.items
|
|
68
|
+
self.items: list[object] = []
|
|
69
|
+
|
|
70
|
+
@hybridmethod
|
|
71
|
+
def add_item(self, item: object):
|
|
72
|
+
# We can use the following line to differentiate between the call being made on
|
|
73
|
+
# an instance or directly on the class; isinstance(self, <class>) returns True
|
|
74
|
+
# if the method was called on an instance of the class, or False if the method
|
|
75
|
+
# was called on the class directly; the 'self' variable will reference either
|
|
76
|
+
# the instance or the class; although 'self' is traditionally used in Python as
|
|
77
|
+
# reference to the instance
|
|
78
|
+
if isinstance(self, hybridcollection):
|
|
79
|
+
self.items.append(item)
|
|
80
|
+
else:
|
|
81
|
+
self.items.append(item)
|
|
82
|
+
|
|
83
|
+
def get_class_items(self) -> list[object]:
|
|
84
|
+
return self.__class__.items
|
|
85
|
+
|
|
86
|
+
def get_instance_items(self) -> list[object]:
|
|
87
|
+
return self.items
|
|
88
|
+
|
|
89
|
+
def get_combined_items(self) -> list[object]:
|
|
90
|
+
return self.__class__.items + self.items
|
|
91
|
+
|
|
92
|
+
hybridcollection.add_item("ABC") # Add an item to the class-level items list
|
|
93
|
+
|
|
94
|
+
collection = hybridcollection()
|
|
95
|
+
|
|
96
|
+
collection.add_item("XYZ") # Add an item to the instance-level items list
|
|
97
|
+
|
|
98
|
+
assert collection.get_class_items() == ["ABC"]
|
|
99
|
+
|
|
100
|
+
assert collection.get_instance_items() == ["XYZ"]
|
|
101
|
+
|
|
102
|
+
assert collection.get_combined_items() == ["ABC", "XYZ"]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### Class Properties
|
|
106
|
+
|
|
107
|
+
The Classicist library provides a `classproperty` method decorator that allows class
|
|
108
|
+
methods to be accessed as class properties.
|
|
109
|
+
|
|
110
|
+
The `@classproperty` decorator provided by the library wraps methods defined in classes
|
|
111
|
+
using the usual `@decorator` syntax. Methods defined in classes that are decorated with
|
|
112
|
+
the `@classproperty` decorator can then be accessed as though they were real properties
|
|
113
|
+
on the class.
|
|
114
|
+
|
|
115
|
+
The `@classproperty` decorator addresses the removal in Python 3.13 of the prior support
|
|
116
|
+
for combining the `@classmethod` and `@property` decorators to create class properties,
|
|
117
|
+
a change which was made due to complexity in the underlying interpreter implementation.
|
|
118
|
+
|
|
119
|
+
#### Class Properties: Usage
|
|
120
|
+
|
|
121
|
+
To use the `classproperty` decorator import the decorator from the `classicist` library
|
|
122
|
+
and use it to decorate any class methods you wish to access as class properties.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from classicist import classproperty
|
|
126
|
+
|
|
127
|
+
class exampleclass(object):
|
|
128
|
+
@classproperty
|
|
129
|
+
def greeting(cls) -> str:
|
|
130
|
+
"""The 'greeting' class method has been decorated with classproperty so acts as
|
|
131
|
+
a property; here we could do some work to generate a return value."""
|
|
132
|
+
return "hello"
|
|
133
|
+
|
|
134
|
+
assert isinstance(exampleclass, type)
|
|
135
|
+
assert issubclass(exampleclass, exampleclass)
|
|
136
|
+
assert issubclass(exampleclass, object)
|
|
137
|
+
|
|
138
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
139
|
+
# The return value of `.greeting` is indiscernible from the value being returned
|
|
140
|
+
assert isinstance(exampleclass.greeting, str)
|
|
141
|
+
assert exampleclass.greeting == "hello"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
⚠️ An important caveat regarding class properties which applies equally to the method of
|
|
145
|
+
supporting class properties provided by this library, and to class properties which are
|
|
146
|
+
supported natively in Python 3.9 – 3.12 by combining the `@classmethod` and `@property`
|
|
147
|
+
decorators, is that unfortunately unless a custom metaclass is used to intervene, class
|
|
148
|
+
properties can be overwritten by value assignment.
|
|
149
|
+
|
|
150
|
+
This is a result of differences in Python's handling for descriptors between classes and
|
|
151
|
+
instances of classes. For both classes and instances, the `__get__` descriptor is called
|
|
152
|
+
while the `__set__` and `__delete__` descriptor methods will only be called on instances
|
|
153
|
+
such that we have no way to be involved in the property reassignment or deletion process
|
|
154
|
+
as would be the case for properties on instances where we can create our own setter and
|
|
155
|
+
deleter methods in addition to the getter.
|
|
156
|
+
|
|
157
|
+
This caveat can be remedied through a custom metaclass however, which overrides default
|
|
158
|
+
behaviour, and is able to intercept the `__setattr_` and `__delattr__` calls as needed.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from classicist import classproperty
|
|
162
|
+
|
|
163
|
+
class exampleclass(object):
|
|
164
|
+
@classproperty
|
|
165
|
+
def greeting(cls) -> str:
|
|
166
|
+
# Generate a return value here
|
|
167
|
+
return "hello"
|
|
168
|
+
|
|
169
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
170
|
+
assert exampleclass.greeting == "hello"
|
|
171
|
+
|
|
172
|
+
# Note: The `.greeting` property will be reassigned to the new value, "goodbye":
|
|
173
|
+
exampleclass.greeting = "goodbye"
|
|
174
|
+
assert exampleclass.greeting == "goodbye"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
As can be seen with the method of natively supporting class properties, they could also
|
|
178
|
+
have their values reassigned without warning:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
import sys
|
|
182
|
+
import pytest
|
|
183
|
+
|
|
184
|
+
# As Python only natively supported combining @classmethod and @property between version
|
|
185
|
+
# 3.9 and 3.12, the example below is not usable on other versions, such as 3.13+
|
|
186
|
+
if sys.version_info.major == 3 and not (9 <= sys.version_info.minor <= 12):
|
|
187
|
+
pytest.skip("This test can run on Python versions 3.9 – 3.12")
|
|
188
|
+
|
|
189
|
+
class exampleclass(object):
|
|
190
|
+
@classmethod
|
|
191
|
+
@property
|
|
192
|
+
def greeting(cls) -> str:
|
|
193
|
+
# Generate a return value here
|
|
194
|
+
return "hello"
|
|
195
|
+
|
|
196
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
197
|
+
assert exampleclass.greeting == "hello"
|
|
198
|
+
|
|
199
|
+
# Note: The `.greeting` property will be reassigned to the new value, "goodbye":
|
|
200
|
+
exampleclass.greeting = "goodbye"
|
|
201
|
+
assert exampleclass.greeting == "goodbye"
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Unit Tests
|
|
205
|
+
|
|
206
|
+
The Classicist library includes a suite of comprehensive unit tests which ensure that
|
|
207
|
+
the library functionality operates as expected. The unit tests were developed with and
|
|
208
|
+
are run via `pytest`.
|
|
209
|
+
|
|
210
|
+
To ensure that the unit tests are run within a predictable runtime environment where all of the necessary dependencies are available, a [Docker](https://www.docker.com) image is created within which the tests are run. To run the unit tests, ensure Docker and Docker Compose is [installed](https://docs.docker.com/engine/install/), and perform the following commands, which will build the Docker image via `docker compose build` and then run the tests via `docker compose run` – the output of running the tests will be displayed:
|
|
211
|
+
|
|
212
|
+
```shell
|
|
213
|
+
$ docker compose build
|
|
214
|
+
$ docker compose run tests
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
To run the unit tests with optional command line arguments being passed to `pytest`, append the relevant arguments to the `docker compose run tests` command, as follows, for example passing `-vv` to enable verbose output:
|
|
218
|
+
|
|
219
|
+
```shell
|
|
220
|
+
$ docker compose run tests -vv
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
See the documentation for [PyTest](https://docs.pytest.org/en/latest/) regarding available optional command line arguments.
|
|
224
|
+
|
|
225
|
+
### Copyright & License Information
|
|
226
|
+
|
|
227
|
+
Copyright © 2025 Daniel Sissman; licensed under the MIT License.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "classicist"
|
|
3
|
+
description = "Classy class decorators for Python."
|
|
4
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
5
|
+
keywords = ["decorator", "hybrid method", "class method", "instance method", "class property", "class properties"]
|
|
6
|
+
authors = [{name = "Daniel Sissman"}]
|
|
7
|
+
license = "MIT"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Programming Language :: Python :: 3",
|
|
10
|
+
"Programming Language :: Python :: 3.10",
|
|
11
|
+
"Programming Language :: Python :: 3.11",
|
|
12
|
+
"Programming Language :: Python :: 3.12",
|
|
13
|
+
"Programming Language :: Python :: 3.13",
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.10"
|
|
16
|
+
dynamic = [
|
|
17
|
+
"version",
|
|
18
|
+
"dependencies",
|
|
19
|
+
"optional-dependencies",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
documentation = "https://github.com/bluebinary/classicist/blob/main/README.md"
|
|
24
|
+
changelog = "https://github.com/bluebinary/classicist/blob/main/CHANGELOG.md"
|
|
25
|
+
repository = "https://github.com/bluebinary/classicist"
|
|
26
|
+
issues = "https://github.com/bluebinary/classicist/issues"
|
|
27
|
+
homepage = "https://github.com/bluebinary/classicist"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["setuptools", "wheel"]
|
|
31
|
+
build-backend = "setuptools.build_meta"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.dynamic]
|
|
34
|
+
version = {file = "source/classicist/version.txt"}
|
|
35
|
+
dependencies = {file = "requirements.txt"}
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.dynamic.optional-dependencies]
|
|
38
|
+
development = {file = "requirements.development.txt"}
|
|
39
|
+
distribution = {file = "requirements.distribution.txt"}
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
zip-safe = true
|
|
43
|
+
include-package-data = true
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages]
|
|
46
|
+
find = {where = ["source"]}
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
minversion = "6.0"
|
|
50
|
+
addopts = "-ra -q"
|
|
51
|
+
testpaths = [
|
|
52
|
+
"tests"
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.black]
|
|
56
|
+
line-length = 88
|
|
57
|
+
target-version = ['py310']
|
|
58
|
+
include = '\.pyi?$'
|
|
59
|
+
extend-exclude = '''
|
|
60
|
+
/(
|
|
61
|
+
# The following are specific to Black, you probably don't want those.
|
|
62
|
+
| blib2to3
|
|
63
|
+
| tests/data
|
|
64
|
+
| profiling
|
|
65
|
+
)/
|
|
66
|
+
'''
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Classicist Library: Runtime Dependencies
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class hybridmethod(object):
|
|
8
|
+
"""The 'hybridmethod' decorator allows a method to be used as both a class method
|
|
9
|
+
and an instance method. The hybridmethod class decorator can wrap methods defined
|
|
10
|
+
in classes using the usual @decorator syntax. Methods defined in classes that are
|
|
11
|
+
decorated with the @hybridmethod decorator can be accessed as both class methods
|
|
12
|
+
and as instance methods, with the first argument passed to the method being the
|
|
13
|
+
reference to either the class when the method is called as a class method or to
|
|
14
|
+
the instance when the method is called as an instance method.
|
|
15
|
+
|
|
16
|
+
A check of the value of the first variable using isinstance(<variable>, <class>) can
|
|
17
|
+
be used within a hybrid method to determine if the call was made on an instance of
|
|
18
|
+
the class in which case the isinstance() call would evalute to True or if the call
|
|
19
|
+
was made on the class itself, in which case isinstance() would evaluate to False.
|
|
20
|
+
The variable passed as the first argument to the method may have any name, including
|
|
21
|
+
'self', as in Python, the use of 'self' as the name of the first argument on an
|
|
22
|
+
instance method is just customary and the name has no significance like it does in
|
|
23
|
+
other languages where the reference to the instance is provided automatically and
|
|
24
|
+
may go by 'self', 'this' or something else."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, function: callable):
|
|
27
|
+
logger.debug(
|
|
28
|
+
"%s.__init__(function: %s)",
|
|
29
|
+
self.__class__.__name__,
|
|
30
|
+
function,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if not callable(function):
|
|
34
|
+
raise TypeError(
|
|
35
|
+
"The '%s' decorator can only be used to wrap callables!"
|
|
36
|
+
% (self.__class__.__name__)
|
|
37
|
+
)
|
|
38
|
+
elif not type(function).__name__ == "function":
|
|
39
|
+
raise TypeError(
|
|
40
|
+
"The '%s' decorator can only be used to wrap functions!"
|
|
41
|
+
% (self.__class__.__name__)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.function: callable = function
|
|
45
|
+
|
|
46
|
+
def __get__(self, instance, owner) -> callable:
|
|
47
|
+
logger.debug(
|
|
48
|
+
"%s.__get__(self: %s, instance: %s, owner: %s)",
|
|
49
|
+
self.__class__.__name__,
|
|
50
|
+
self,
|
|
51
|
+
instance,
|
|
52
|
+
owner,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if instance is None:
|
|
56
|
+
return lambda *args, **kwargs: self.function(owner, *args, **kwargs)
|
|
57
|
+
else:
|
|
58
|
+
return lambda *args, **kwargs: self.function(instance, *args, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class classproperty(property):
|
|
62
|
+
"""The classproperty decorator transforms a method into a class-level property. This
|
|
63
|
+
provides access to the method as if it were a class attribute; this addresses the
|
|
64
|
+
removal of support for combining the @classmethod and @property decorators to create
|
|
65
|
+
class properties in Python 3.13, a change which was made due to some complexity in
|
|
66
|
+
the underlying interpreter implementation."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, fget: callable, fset: callable = None, fdel: callable = None):
|
|
69
|
+
super().__init__(fget, fset, fdel)
|
|
70
|
+
|
|
71
|
+
def __get__(self, instance: object, klass: type = None):
|
|
72
|
+
if klass is None:
|
|
73
|
+
return self
|
|
74
|
+
return self.fget(klass)
|
|
75
|
+
|
|
76
|
+
def __set__(self, instance: object, value: object):
|
|
77
|
+
# Note that the __set__ descriptor cannot be used on class methods unless
|
|
78
|
+
# the class is created with a metaclass that implements this behaviour
|
|
79
|
+
raise NotImplemented
|
|
80
|
+
|
|
81
|
+
def __delete__(self, instance: object):
|
|
82
|
+
# Note that the __delete__ descriptor cannot be used on class methods unless
|
|
83
|
+
# the class is created with a metaclass that implements this behaviour
|
|
84
|
+
raise NotImplemented
|
|
85
|
+
|
|
86
|
+
def __getattr__(self, name: str):
|
|
87
|
+
if name in ATTRIBUTES:
|
|
88
|
+
return getattr(self.fget, name)
|
|
89
|
+
else:
|
|
90
|
+
raise AttributeError(
|
|
91
|
+
"The classproperty method '%s' does not have an '%s' attribute!"
|
|
92
|
+
% (
|
|
93
|
+
self.fget.__name__,
|
|
94
|
+
name,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# # For inspectability, provide access to the underlying function's metadata
|
|
99
|
+
# # including __module__, __name__, __qualname__, __doc__, and __annotations__
|
|
100
|
+
# @property
|
|
101
|
+
# def __module__(self):
|
|
102
|
+
# return self.fget.__module__
|
|
103
|
+
#
|
|
104
|
+
# @property
|
|
105
|
+
# def __name__(self):
|
|
106
|
+
# return self.fget.__name__
|
|
107
|
+
#
|
|
108
|
+
# @property
|
|
109
|
+
# def __qualname__(self):
|
|
110
|
+
# return self.fget.__qualname__
|
|
111
|
+
#
|
|
112
|
+
# @property
|
|
113
|
+
# def __doc__(self):
|
|
114
|
+
# return self.fget.__doc__
|
|
115
|
+
#
|
|
116
|
+
# @property
|
|
117
|
+
# def __annotations__(self):
|
|
118
|
+
# return self.fget.__annotations__
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: classicist
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Classy class decorators for Python.
|
|
5
|
+
Author: Daniel Sissman
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: documentation, https://github.com/bluebinary/classicist/blob/main/README.md
|
|
8
|
+
Project-URL: changelog, https://github.com/bluebinary/classicist/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: repository, https://github.com/bluebinary/classicist
|
|
10
|
+
Project-URL: issues, https://github.com/bluebinary/classicist/issues
|
|
11
|
+
Project-URL: homepage, https://github.com/bluebinary/classicist
|
|
12
|
+
Keywords: decorator,hybrid method,class method,instance method,class property,class properties
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE.md
|
|
21
|
+
Provides-Extra: development
|
|
22
|
+
Requires-Dist: black==24.10.*; extra == "development"
|
|
23
|
+
Requires-Dist: pytest==8.3.*; extra == "development"
|
|
24
|
+
Requires-Dist: pytest-codeblocks==0.17.0; extra == "development"
|
|
25
|
+
Provides-Extra: distribution
|
|
26
|
+
Requires-Dist: build; extra == "distribution"
|
|
27
|
+
Requires-Dist: twine; extra == "distribution"
|
|
28
|
+
Requires-Dist: wheel; extra == "distribution"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Classicist: Classy Class Decorators & Extensions
|
|
32
|
+
|
|
33
|
+
The Classicist library provides several useful class decorators for Python class methods
|
|
34
|
+
including a `hybridmethod` decorator that allows methods defined in a class to be used
|
|
35
|
+
both a class method and an instance method, and a `classproperty` decorator that allows
|
|
36
|
+
class methods to be accessed as class properties.
|
|
37
|
+
|
|
38
|
+
The `classicist` library was previously named `hybridmethod` so if a prior version had been
|
|
39
|
+
installed, please update references to the new library name. Installation of the
|
|
40
|
+
library via its old name, `hybridmethod`, will install the new `classicist` library with
|
|
41
|
+
a mapping for backwards compatibility so that code continues to function as before.
|
|
42
|
+
|
|
43
|
+
### Requirements
|
|
44
|
+
|
|
45
|
+
The Classicist library has been tested with Python 3.9, 3.10, 3.11, 3.12 and 3.13. The library is not compatible with Python 3.8 or earlier.
|
|
46
|
+
|
|
47
|
+
### Installation
|
|
48
|
+
|
|
49
|
+
The Classicist library is available from PyPI, so may be added to a project's dependencies via its `requirements.txt` file or similar by referencing the Classicist library's name, `classicist`, or the library may be installed directly into your local runtime environment using `pip` via the `pip install` command by entering the following into your shell:
|
|
50
|
+
|
|
51
|
+
$ pip install classicist
|
|
52
|
+
|
|
53
|
+
#### Hybrid Methods
|
|
54
|
+
|
|
55
|
+
The Classicist library provides a `hybridmethod` method decorator that allows methods
|
|
56
|
+
defined in a class to be used as both a class method and an instance method.
|
|
57
|
+
|
|
58
|
+
The `@hybridmethod` decorator provided by the library wraps methods defined in classes
|
|
59
|
+
using the usual `@decorator` syntax. Methods defined in classes that are decorated with
|
|
60
|
+
the `@hybridmethod` decorator can then be accessed as both class methods and as instance
|
|
61
|
+
methods, with the first argument passed to the method being a reference to either the
|
|
62
|
+
class when the method is called as a class method or to the instance when the method is
|
|
63
|
+
called as an instance method.
|
|
64
|
+
|
|
65
|
+
If a class-level property is defined and then an instance-level property is created with
|
|
66
|
+
the same name that shadows the class-level property, the hybrid method can be used to
|
|
67
|
+
interact with both the class-level property and the instance-level property simply based
|
|
68
|
+
on whether the hybrid method was called directly on the class or on an a class instance.
|
|
69
|
+
|
|
70
|
+
If desired, a simple check of the value of the first variable passed to a hybrid method
|
|
71
|
+
using `isinstance(<variable>, <class>)` allows one to determine if the call was made on
|
|
72
|
+
an instance of the class in which case `isinstance()` evaluates to `True` or if the call
|
|
73
|
+
was made on the class itself, in which case `isinstance()` evaluates to `False`.
|
|
74
|
+
|
|
75
|
+
The variable passed as the first argument to the method may have any name, including as
|
|
76
|
+
is common in Python, `self`, although the use of `self` as the name of this argument on
|
|
77
|
+
an instance method is just customary and the name has no significance.
|
|
78
|
+
|
|
79
|
+
If using the `isinstance(<variable>, <class>)` check as described above is used simply
|
|
80
|
+
substitute in the name of the first variable of a hybrid method for `<variable>` and the
|
|
81
|
+
name of the class for `<class>`.
|
|
82
|
+
|
|
83
|
+
#### Hybrid Methods: Usage
|
|
84
|
+
|
|
85
|
+
To use the `hybridmethod` decorator import the decorator from the `classicist` library
|
|
86
|
+
and use it to decorate the class methods you wish to use as both class methods and
|
|
87
|
+
instance methods:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from classicist import hybridmethod
|
|
91
|
+
|
|
92
|
+
class hybridcollection(object):
|
|
93
|
+
items: list[str] = []
|
|
94
|
+
|
|
95
|
+
def __init__(self):
|
|
96
|
+
# Create an 'items' instance variable; note that this shadows the class variable
|
|
97
|
+
# of the same name which can still be accessed directly via self.__class__.items
|
|
98
|
+
self.items: list[object] = []
|
|
99
|
+
|
|
100
|
+
@hybridmethod
|
|
101
|
+
def add_item(self, item: object):
|
|
102
|
+
# We can use the following line to differentiate between the call being made on
|
|
103
|
+
# an instance or directly on the class; isinstance(self, <class>) returns True
|
|
104
|
+
# if the method was called on an instance of the class, or False if the method
|
|
105
|
+
# was called on the class directly; the 'self' variable will reference either
|
|
106
|
+
# the instance or the class; although 'self' is traditionally used in Python as
|
|
107
|
+
# reference to the instance
|
|
108
|
+
if isinstance(self, hybridcollection):
|
|
109
|
+
self.items.append(item)
|
|
110
|
+
else:
|
|
111
|
+
self.items.append(item)
|
|
112
|
+
|
|
113
|
+
def get_class_items(self) -> list[object]:
|
|
114
|
+
return self.__class__.items
|
|
115
|
+
|
|
116
|
+
def get_instance_items(self) -> list[object]:
|
|
117
|
+
return self.items
|
|
118
|
+
|
|
119
|
+
def get_combined_items(self) -> list[object]:
|
|
120
|
+
return self.__class__.items + self.items
|
|
121
|
+
|
|
122
|
+
hybridcollection.add_item("ABC") # Add an item to the class-level items list
|
|
123
|
+
|
|
124
|
+
collection = hybridcollection()
|
|
125
|
+
|
|
126
|
+
collection.add_item("XYZ") # Add an item to the instance-level items list
|
|
127
|
+
|
|
128
|
+
assert collection.get_class_items() == ["ABC"]
|
|
129
|
+
|
|
130
|
+
assert collection.get_instance_items() == ["XYZ"]
|
|
131
|
+
|
|
132
|
+
assert collection.get_combined_items() == ["ABC", "XYZ"]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### Class Properties
|
|
136
|
+
|
|
137
|
+
The Classicist library provides a `classproperty` method decorator that allows class
|
|
138
|
+
methods to be accessed as class properties.
|
|
139
|
+
|
|
140
|
+
The `@classproperty` decorator provided by the library wraps methods defined in classes
|
|
141
|
+
using the usual `@decorator` syntax. Methods defined in classes that are decorated with
|
|
142
|
+
the `@classproperty` decorator can then be accessed as though they were real properties
|
|
143
|
+
on the class.
|
|
144
|
+
|
|
145
|
+
The `@classproperty` decorator addresses the removal in Python 3.13 of the prior support
|
|
146
|
+
for combining the `@classmethod` and `@property` decorators to create class properties,
|
|
147
|
+
a change which was made due to complexity in the underlying interpreter implementation.
|
|
148
|
+
|
|
149
|
+
#### Class Properties: Usage
|
|
150
|
+
|
|
151
|
+
To use the `classproperty` decorator import the decorator from the `classicist` library
|
|
152
|
+
and use it to decorate any class methods you wish to access as class properties.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from classicist import classproperty
|
|
156
|
+
|
|
157
|
+
class exampleclass(object):
|
|
158
|
+
@classproperty
|
|
159
|
+
def greeting(cls) -> str:
|
|
160
|
+
"""The 'greeting' class method has been decorated with classproperty so acts as
|
|
161
|
+
a property; here we could do some work to generate a return value."""
|
|
162
|
+
return "hello"
|
|
163
|
+
|
|
164
|
+
assert isinstance(exampleclass, type)
|
|
165
|
+
assert issubclass(exampleclass, exampleclass)
|
|
166
|
+
assert issubclass(exampleclass, object)
|
|
167
|
+
|
|
168
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
169
|
+
# The return value of `.greeting` is indiscernible from the value being returned
|
|
170
|
+
assert isinstance(exampleclass.greeting, str)
|
|
171
|
+
assert exampleclass.greeting == "hello"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
⚠️ An important caveat regarding class properties which applies equally to the method of
|
|
175
|
+
supporting class properties provided by this library, and to class properties which are
|
|
176
|
+
supported natively in Python 3.9 – 3.12 by combining the `@classmethod` and `@property`
|
|
177
|
+
decorators, is that unfortunately unless a custom metaclass is used to intervene, class
|
|
178
|
+
properties can be overwritten by value assignment.
|
|
179
|
+
|
|
180
|
+
This is a result of differences in Python's handling for descriptors between classes and
|
|
181
|
+
instances of classes. For both classes and instances, the `__get__` descriptor is called
|
|
182
|
+
while the `__set__` and `__delete__` descriptor methods will only be called on instances
|
|
183
|
+
such that we have no way to be involved in the property reassignment or deletion process
|
|
184
|
+
as would be the case for properties on instances where we can create our own setter and
|
|
185
|
+
deleter methods in addition to the getter.
|
|
186
|
+
|
|
187
|
+
This caveat can be remedied through a custom metaclass however, which overrides default
|
|
188
|
+
behaviour, and is able to intercept the `__setattr_` and `__delattr__` calls as needed.
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from classicist import classproperty
|
|
192
|
+
|
|
193
|
+
class exampleclass(object):
|
|
194
|
+
@classproperty
|
|
195
|
+
def greeting(cls) -> str:
|
|
196
|
+
# Generate a return value here
|
|
197
|
+
return "hello"
|
|
198
|
+
|
|
199
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
200
|
+
assert exampleclass.greeting == "hello"
|
|
201
|
+
|
|
202
|
+
# Note: The `.greeting` property will be reassigned to the new value, "goodbye":
|
|
203
|
+
exampleclass.greeting = "goodbye"
|
|
204
|
+
assert exampleclass.greeting == "goodbye"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
As can be seen with the method of natively supporting class properties, they could also
|
|
208
|
+
have their values reassigned without warning:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
import sys
|
|
212
|
+
import pytest
|
|
213
|
+
|
|
214
|
+
# As Python only natively supported combining @classmethod and @property between version
|
|
215
|
+
# 3.9 and 3.12, the example below is not usable on other versions, such as 3.13+
|
|
216
|
+
if sys.version_info.major == 3 and not (9 <= sys.version_info.minor <= 12):
|
|
217
|
+
pytest.skip("This test can run on Python versions 3.9 – 3.12")
|
|
218
|
+
|
|
219
|
+
class exampleclass(object):
|
|
220
|
+
@classmethod
|
|
221
|
+
@property
|
|
222
|
+
def greeting(cls) -> str:
|
|
223
|
+
# Generate a return value here
|
|
224
|
+
return "hello"
|
|
225
|
+
|
|
226
|
+
# We can access `.greeting` as though it was defined as a property:
|
|
227
|
+
assert exampleclass.greeting == "hello"
|
|
228
|
+
|
|
229
|
+
# Note: The `.greeting` property will be reassigned to the new value, "goodbye":
|
|
230
|
+
exampleclass.greeting = "goodbye"
|
|
231
|
+
assert exampleclass.greeting == "goodbye"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Unit Tests
|
|
235
|
+
|
|
236
|
+
The Classicist library includes a suite of comprehensive unit tests which ensure that
|
|
237
|
+
the library functionality operates as expected. The unit tests were developed with and
|
|
238
|
+
are run via `pytest`.
|
|
239
|
+
|
|
240
|
+
To ensure that the unit tests are run within a predictable runtime environment where all of the necessary dependencies are available, a [Docker](https://www.docker.com) image is created within which the tests are run. To run the unit tests, ensure Docker and Docker Compose is [installed](https://docs.docker.com/engine/install/), and perform the following commands, which will build the Docker image via `docker compose build` and then run the tests via `docker compose run` – the output of running the tests will be displayed:
|
|
241
|
+
|
|
242
|
+
```shell
|
|
243
|
+
$ docker compose build
|
|
244
|
+
$ docker compose run tests
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
To run the unit tests with optional command line arguments being passed to `pytest`, append the relevant arguments to the `docker compose run tests` command, as follows, for example passing `-vv` to enable verbose output:
|
|
248
|
+
|
|
249
|
+
```shell
|
|
250
|
+
$ docker compose run tests -vv
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
See the documentation for [PyTest](https://docs.pytest.org/en/latest/) regarding available optional command line arguments.
|
|
254
|
+
|
|
255
|
+
### Copyright & License Information
|
|
256
|
+
|
|
257
|
+
Copyright © 2025 Daniel Sissman; licensed under the MIT License.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
requirements.development.txt
|
|
5
|
+
requirements.distribution.txt
|
|
6
|
+
requirements.txt
|
|
7
|
+
source/classicist/__init__.py
|
|
8
|
+
source/classicist/version.txt
|
|
9
|
+
source/classicist.egg-info/PKG-INFO
|
|
10
|
+
source/classicist.egg-info/SOURCES.txt
|
|
11
|
+
source/classicist.egg-info/dependency_links.txt
|
|
12
|
+
source/classicist.egg-info/requires.txt
|
|
13
|
+
source/classicist.egg-info/top_level.txt
|
|
14
|
+
source/classicist.egg-info/zip-safe
|
|
15
|
+
tests/test_classproperty.py
|
|
16
|
+
tests/test_hybridmethod.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
classicist
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from classicist import classproperty
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture(scope="module", name="exampleclass")
|
|
7
|
+
def test_classproperty_fixture() -> type:
|
|
8
|
+
class exampleclass(object):
|
|
9
|
+
@classproperty
|
|
10
|
+
def name(cls) -> str:
|
|
11
|
+
return cls.__name__
|
|
12
|
+
|
|
13
|
+
return exampleclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_classproperty(exampleclass: type):
|
|
17
|
+
"""Test the classproperty decorator on a demonstration class."""
|
|
18
|
+
|
|
19
|
+
assert isinstance(exampleclass, type)
|
|
20
|
+
assert issubclass(exampleclass, exampleclass)
|
|
21
|
+
|
|
22
|
+
assert isinstance(exampleclass.name, str)
|
|
23
|
+
assert exampleclass.name == "exampleclass"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_classproperty_overwrite(exampleclass: type):
|
|
27
|
+
# Unfortunately without a metaclass to intervene, classproperties can be overwritten
|
|
28
|
+
# as although Python automatically calls the __get__ descriptor method it will not
|
|
29
|
+
# automatically call the __set__ or __delete__ descriptor methods on classes, so we
|
|
30
|
+
# have no way to prevent the property being reassigned unless a metaclass is used to
|
|
31
|
+
# intervene and provide behaviour we had previously with @classmethod and @property
|
|
32
|
+
exampleclass.name = "hello"
|
|
33
|
+
assert exampleclass.name == "hello"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from classicist import hybridmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture(scope="module", name="hybridcollection")
|
|
7
|
+
def test_hybridmethod_fixture() -> type:
|
|
8
|
+
"""Test the hybridmethod decorator on the 'hybridcollection' demonstration class."""
|
|
9
|
+
|
|
10
|
+
class hybridcollection(object):
|
|
11
|
+
"""This sample class provides hybrid methods that allow items to be added to and
|
|
12
|
+
removed from the class-level list and item-level list without needing to define
|
|
13
|
+
separate methods to manage the lists. The class also provides helper methods for
|
|
14
|
+
accessing the class-level list, instance-level list, and a combined list."""
|
|
15
|
+
|
|
16
|
+
items: list[object] = []
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
# Create an 'items' instance variable; note this shadows the class variable
|
|
20
|
+
# of the same name which can still be accessed via self.__class__.items
|
|
21
|
+
self.items: list[str] = []
|
|
22
|
+
|
|
23
|
+
@hybridmethod
|
|
24
|
+
def add_item(self, item: object):
|
|
25
|
+
# We can use the following line to differentiate between the call being made
|
|
26
|
+
# on an instance or directly on the class; isinstance(self, <class>) returns
|
|
27
|
+
# True if the method was called on an instance of the class, or False if the
|
|
28
|
+
# method was called on the class directly; the variable 'self' references
|
|
29
|
+
# either the instance or the class; although 'self' is traditionally used in
|
|
30
|
+
# Python as reference to the instance; the variable can be named anything:
|
|
31
|
+
if isinstance(self, hybridcollection):
|
|
32
|
+
self.items.append(item)
|
|
33
|
+
else:
|
|
34
|
+
self.items.append(item)
|
|
35
|
+
|
|
36
|
+
@hybridmethod
|
|
37
|
+
def remove_item(self, item: object):
|
|
38
|
+
if (index := self.items.index(item)) >= 0:
|
|
39
|
+
del self.items[index]
|
|
40
|
+
|
|
41
|
+
def get_class_items(self) -> list[object]:
|
|
42
|
+
return self.__class__.items
|
|
43
|
+
|
|
44
|
+
def get_instance_items(self) -> list[object]:
|
|
45
|
+
return self.items
|
|
46
|
+
|
|
47
|
+
def get_combined_items(self) -> list[object]:
|
|
48
|
+
return self.__class__.items + self.items
|
|
49
|
+
|
|
50
|
+
return hybridcollection
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_hybridmethod(hybridcollection: type):
|
|
54
|
+
"""Test the hybridmethod decorator through an example use case of a class providing
|
|
55
|
+
access to a class-level list and instance-level list that are separately held in
|
|
56
|
+
memory and can be added to and removed from without affecting the other, while also
|
|
57
|
+
offering a method that returns a combined list of the items held in both lists."""
|
|
58
|
+
|
|
59
|
+
# Ensure that the hybridcollection type is of the expected type
|
|
60
|
+
assert isinstance(hybridcollection, type)
|
|
61
|
+
|
|
62
|
+
# Ensure that the hybridcollection type has an items list
|
|
63
|
+
assert isinstance(hybridcollection.items, list)
|
|
64
|
+
|
|
65
|
+
# Ensure that the class' items list is empty to begin with
|
|
66
|
+
assert len(hybridcollection.items) == 0
|
|
67
|
+
|
|
68
|
+
# Add an item to the class' items list
|
|
69
|
+
hybridcollection.add_item("ABC")
|
|
70
|
+
|
|
71
|
+
# Ensure that the class' items list length now reflects the newly added item
|
|
72
|
+
assert len(hybridcollection.items) == 1
|
|
73
|
+
|
|
74
|
+
# Ensure that the class' items list has the expected contents
|
|
75
|
+
assert hybridcollection.items == ["ABC"]
|
|
76
|
+
|
|
77
|
+
# Create an instance of the class
|
|
78
|
+
collection = hybridcollection()
|
|
79
|
+
|
|
80
|
+
# Ensure that the instance is of the expected type
|
|
81
|
+
assert isinstance(collection, hybridcollection)
|
|
82
|
+
|
|
83
|
+
# Ensure that the instance has an items list
|
|
84
|
+
assert isinstance(collection.items, list)
|
|
85
|
+
|
|
86
|
+
# Ensure that the instance's items list is empty
|
|
87
|
+
assert len(collection.items) == 0
|
|
88
|
+
|
|
89
|
+
# Add an item to the instance's item list
|
|
90
|
+
collection.add_item("XYZ")
|
|
91
|
+
|
|
92
|
+
# Ensure that the instance's items list length now reflects the newly added item
|
|
93
|
+
assert len(collection.items) == 1
|
|
94
|
+
|
|
95
|
+
# Ensure that the instance's items list has the expected contents
|
|
96
|
+
assert collection.items == ["XYZ"]
|
|
97
|
+
|
|
98
|
+
# Ensure that the instance's items list has the expected contents, in this case
|
|
99
|
+
# as accessed via the class' get_instance_items helper method:
|
|
100
|
+
assert collection.get_instance_items() == ["XYZ"]
|
|
101
|
+
|
|
102
|
+
# Ensure that the class' items list still has the expected contents and was not
|
|
103
|
+
# affected by the addition of an item to the instance's items list, in this case
|
|
104
|
+
# as accessed via the class reference on the instance:
|
|
105
|
+
assert collection.__class__.items == ["ABC"]
|
|
106
|
+
|
|
107
|
+
# Ensure that the class' items list still has the expected contents and was not
|
|
108
|
+
# affected by the addition of an item to the instance's items list, in this case
|
|
109
|
+
# as accessed via the class' get_class_items helper method:
|
|
110
|
+
assert collection.get_class_items() == ["ABC"]
|
|
111
|
+
|
|
112
|
+
# Ensure that the combined items held in the class' and the instance's items list
|
|
113
|
+
# are as expected, in this case as accessed via the items lists directly:
|
|
114
|
+
assert collection.__class__.items + collection.items == ["ABC", "XYZ"]
|
|
115
|
+
|
|
116
|
+
# Ensure that the combined items held in the class' and the instance's items list
|
|
117
|
+
# are as expected, in this case accessed via the get_combined_items helper method:
|
|
118
|
+
assert collection.get_combined_items() == ["ABC", "XYZ"]
|
|
119
|
+
|
|
120
|
+
# Add another item to the instance's item list
|
|
121
|
+
collection.add_item(123)
|
|
122
|
+
|
|
123
|
+
# Ensure that the class' items list still contains the expected number of items
|
|
124
|
+
assert len(hybridcollection.items) == 1
|
|
125
|
+
|
|
126
|
+
# Ensure that the class' items list still contains the expected items
|
|
127
|
+
assert hybridcollection.items == ["ABC"]
|
|
128
|
+
|
|
129
|
+
# Ensure that the instance's items list contains the expected number of items
|
|
130
|
+
assert len(collection.items) == 2
|
|
131
|
+
|
|
132
|
+
# Ensure that the instance's items list contains the expected items
|
|
133
|
+
assert collection.items == ["XYZ", 123]
|
|
134
|
+
|
|
135
|
+
# Remove an item from the list
|
|
136
|
+
collection.remove_item("XYZ")
|
|
137
|
+
|
|
138
|
+
# Ensure that the instance's items list contains the expected number of items
|
|
139
|
+
assert len(collection.items) == 1
|
|
140
|
+
|
|
141
|
+
# Ensure that the instance's items list contains the expected items
|
|
142
|
+
assert collection.items == [123]
|
|
143
|
+
|
|
144
|
+
# Ensure that the class' items list still contains the expected number of items
|
|
145
|
+
assert len(hybridcollection.items) == 1
|
|
146
|
+
|
|
147
|
+
# Ensure that the class' items list still contains the expected items
|
|
148
|
+
assert hybridcollection.items == ["ABC"]
|