morphic 0.2.0__tar.gz → 0.2.1__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.
Files changed (47) hide show
  1. {morphic-0.2.0 → morphic-0.2.1}/.github/workflows/release.yml +0 -3
  2. {morphic-0.2.0 → morphic-0.2.1}/PKG-INFO +1 -1
  3. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/__init__.py +2 -0
  4. morphic-0.2.1/src/morphic/classproperty.py +373 -0
  5. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/typed.py +1 -40
  6. morphic-0.2.1/tests/test_classproperty.py +1531 -0
  7. {morphic-0.2.0 → morphic-0.2.1}/.cursor/rules/morphic-standards.mdc +0 -0
  8. {morphic-0.2.0 → morphic-0.2.1}/.cursor/rules/typed-registry-examples.mdc +0 -0
  9. {morphic-0.2.0 → morphic-0.2.1}/.github/workflows/docs.yml +0 -0
  10. {morphic-0.2.0 → morphic-0.2.1}/.github/workflows/linting.yml +0 -0
  11. {morphic-0.2.0 → morphic-0.2.1}/.github/workflows/tests.yml +0 -0
  12. {morphic-0.2.0 → morphic-0.2.1}/.gitignore +0 -0
  13. {morphic-0.2.0 → morphic-0.2.1}/LICENSE +0 -0
  14. {morphic-0.2.0 → morphic-0.2.1}/README.md +0 -0
  15. {morphic-0.2.0 → morphic-0.2.1}/docs/api/autoenum.md +0 -0
  16. {morphic-0.2.0 → morphic-0.2.1}/docs/api/index.md +0 -0
  17. {morphic-0.2.0 → morphic-0.2.1}/docs/api/registry.md +0 -0
  18. {morphic-0.2.0 → morphic-0.2.1}/docs/api/string.md +0 -0
  19. {morphic-0.2.0 → morphic-0.2.1}/docs/api/typed.md +0 -0
  20. {morphic-0.2.0 → morphic-0.2.1}/docs/examples.md +0 -0
  21. {morphic-0.2.0 → morphic-0.2.1}/docs/index.md +0 -0
  22. {morphic-0.2.0 → morphic-0.2.1}/docs/installation.md +0 -0
  23. {morphic-0.2.0 → morphic-0.2.1}/docs/stylesheets/extra.css +0 -0
  24. {morphic-0.2.0 → morphic-0.2.1}/docs/user-guide/autoenum.md +0 -0
  25. {morphic-0.2.0 → morphic-0.2.1}/docs/user-guide/getting-started.md +0 -0
  26. {morphic-0.2.0 → morphic-0.2.1}/docs/user-guide/registry.md +0 -0
  27. {morphic-0.2.0 → morphic-0.2.1}/docs/user-guide/string.md +0 -0
  28. {morphic-0.2.0 → morphic-0.2.1}/docs/user-guide/typed-registry-integration.md +0 -0
  29. {morphic-0.2.0 → morphic-0.2.1}/docs/user-guide/typed.md +0 -0
  30. {morphic-0.2.0 → morphic-0.2.1}/mkdocs.yml +0 -0
  31. {morphic-0.2.0 → morphic-0.2.1}/pyproject.toml +0 -0
  32. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/autoenum.py +0 -0
  33. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/function.py +0 -0
  34. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/imports.py +0 -0
  35. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/registry.py +0 -0
  36. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/string.py +0 -0
  37. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/string_data.py +0 -0
  38. {morphic-0.2.0 → morphic-0.2.1}/src/morphic/structs.py +0 -0
  39. {morphic-0.2.0 → morphic-0.2.1}/tests/__init__.py +0 -0
  40. {morphic-0.2.0 → morphic-0.2.1}/tests/test_autoenum.py +0 -0
  41. {morphic-0.2.0 → morphic-0.2.1}/tests/test_function.py +0 -0
  42. {morphic-0.2.0 → morphic-0.2.1}/tests/test_imports.py +0 -0
  43. {morphic-0.2.0 → morphic-0.2.1}/tests/test_registry.py +0 -0
  44. {morphic-0.2.0 → morphic-0.2.1}/tests/test_string.py +0 -0
  45. {morphic-0.2.0 → morphic-0.2.1}/tests/test_structs.py +0 -0
  46. {morphic-0.2.0 → morphic-0.2.1}/tests/test_typed.py +0 -0
  47. {morphic-0.2.0 → morphic-0.2.1}/tests/test_typed_registry_integration.py +0 -0
@@ -8,9 +8,6 @@ on:
8
8
  jobs:
9
9
  pypi:
10
10
  runs-on: ubuntu-latest
11
- if: >
12
- ${{ github.event.workflow_run.conclusion == 'success' &&
13
- github.event.workflow_run.head_branch == 'main' }}
14
11
  steps:
15
12
  - name: Checkout Repository
16
13
  uses: actions/checkout@v4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphic
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Dynamic Python utilities for class registration, creation, and type checking
5
5
  Author-email: Abhishek Divekar <adivekar@utexas.edu>
6
6
  License-File: LICENSE
@@ -1,6 +1,7 @@
1
1
  """Morphic: Dynamic Python utilities for class registration, creation, and type checking."""
2
2
 
3
3
  from .autoenum import AutoEnum, alias, auto
4
+ from .classproperty import classproperty
4
5
  from .function import (
5
6
  FunctionSpec,
6
7
  call_str_to_params,
@@ -98,6 +99,7 @@ __all__ = [
98
99
  "MutableTyped",
99
100
  "validate",
100
101
  "ValidationError",
102
+ "classproperty",
101
103
  # Import utilities
102
104
  "optional_dependency",
103
105
  # Language utilities
@@ -0,0 +1,373 @@
1
+ """Descriptor that allows properties to be accessed at the class level.
2
+
3
+ Supports ``@abstractmethod`` stacking: when ``@abstractmethod`` wraps a
4
+ ``@classproperty``, ABC enforcement works exactly as it does for regular
5
+ abstract methods — subclasses that fail to override the classproperty cannot
6
+ be instantiated.
7
+
8
+ Usage::
9
+
10
+ from abc import ABC, abstractmethod
11
+ from morphic import classproperty
12
+
13
+ class Base(ABC):
14
+ @abstractmethod
15
+ @classproperty
16
+ def my_value(cls) -> int:
17
+ pass
18
+
19
+ class Concrete(Base):
20
+ @classproperty
21
+ def my_value(cls) -> int:
22
+ return 42
23
+ """
24
+
25
+
26
+ class classproperty(property):
27
+ """Descriptor that allows properties to be accessed at the class level.
28
+
29
+ A ``classproperty`` is the class-level analogue of Python's built-in
30
+ ``@property``. Where ``@property`` computes a value from an *instance*
31
+ (``self``), ``@classproperty`` computes a value from the *class* (``cls``).
32
+ The decorated function receives the class as its first argument — not an
33
+ instance — and the result is available both on the class itself and on any
34
+ instance of that class.
35
+
36
+ ``classproperty`` inherits from the built-in ``property`` type. The key
37
+ difference is in ``__get__``: when Python resolves attribute access,
38
+ ``property.__get__(obj=None, objtype=cls)`` returns the descriptor object
39
+ itself (because there is no instance to pass to ``fget``), whereas
40
+ ``classproperty.__get__`` passes the *class* to ``fget`` and returns the
41
+ computed value. This is what makes class-level access work.
42
+
43
+ Basic usage::
44
+
45
+ class Circle:
46
+ _pi = 3.14159
47
+
48
+ @classproperty
49
+ def pi(cls) -> float:
50
+ return cls._pi
51
+
52
+ Circle.pi # 3.14159 (class-level access)
53
+ Circle().pi # 3.14159 (instance-level access — same result)
54
+
55
+ Class-level and instance-level access
56
+ ======================================
57
+
58
+ A ``classproperty`` is accessible from both the class and any instance of
59
+ that class. In both cases the decorated function receives the **class**
60
+ (not the instance) as its first argument, so the return value is always
61
+ the same regardless of which instance you access it through::
62
+
63
+ class Config:
64
+ _mode = "fast"
65
+
66
+ @classproperty
67
+ def mode(cls) -> str:
68
+ return cls._mode
69
+
70
+ # All three return the same value:
71
+ Config.mode # "fast" (class-level)
72
+ Config().mode # "fast" (instance-level)
73
+ Config().mode # "fast" (different instance, same result)
74
+
75
+ This holds true for all class types — plain classes, ``Typed``, ``Typed +
76
+ Registry``, and ABC subclasses::
77
+
78
+ from morphic import Typed
79
+
80
+ class Metric(Typed):
81
+ value: float
82
+
83
+ @classproperty
84
+ def display_range(cls) -> tuple:
85
+ return (0.0, 1.0)
86
+
87
+ Metric.display_range # (0.0, 1.0)
88
+ Metric(value=0.5).display_range # (0.0, 1.0)
89
+
90
+ When a subclass overrides a ``classproperty``, each class and its instances
91
+ see their own version::
92
+
93
+ class Accuracy(Metric):
94
+ @classproperty
95
+ def display_range(cls) -> tuple:
96
+ return (0.0, 100.0)
97
+
98
+ Metric.display_range # (0.0, 1.0)
99
+ Metric(value=0.5).display_range # (0.0, 1.0)
100
+ Accuracy.display_range # (0.0, 100.0)
101
+ Accuracy(value=95.0).display_range # (0.0, 100.0)
102
+
103
+ Subclass polymorphism::
104
+
105
+ class Base:
106
+ @classproperty
107
+ def tag(cls) -> str:
108
+ return cls.__name__.lower()
109
+
110
+ class Child(Base):
111
+ pass
112
+
113
+ Base.tag # "base"
114
+ Child.tag # "child" (cls is Child, not Base)
115
+
116
+ Because the function receives the actual class through the MRO, subclasses
117
+ automatically get their own ``cls`` without overriding anything.
118
+
119
+ Overriding in subclasses::
120
+
121
+ class Metric:
122
+ @classproperty
123
+ def display_range(cls) -> tuple:
124
+ return (0.0, 1.0)
125
+
126
+ class Perplexity(Metric):
127
+ @classproperty
128
+ def display_range(cls) -> tuple:
129
+ return (0.0, float("inf"))
130
+
131
+ Metric.display_range # (0.0, 1.0)
132
+ Perplexity.display_range # (0.0, inf)
133
+
134
+ Abstract classproperties
135
+ ========================
136
+
137
+ ``classproperty`` supports the standard ``@abstractmethod`` decorator from
138
+ ``abc``. The correct stacking order places ``@abstractmethod`` on the
139
+ outside::
140
+
141
+ from abc import ABC, abstractmethod
142
+
143
+ class Base(ABC):
144
+ @abstractmethod # outer — marks the descriptor as abstract
145
+ @classproperty # inner — creates the descriptor
146
+ def value(cls) -> int:
147
+ pass
148
+
149
+ With this stacking:
150
+
151
+ - ``Base()`` raises ``TypeError`` ("Can't instantiate abstract class …").
152
+ - A subclass that does **not** override ``value`` with a concrete
153
+ ``@classproperty`` also raises ``TypeError`` on instantiation.
154
+ - A subclass that overrides ``value`` with a concrete ``@classproperty``
155
+ can be instantiated normally.
156
+
157
+ This works identically for plain classes, ``Typed`` classes, and
158
+ ``Typed + Registry`` classes::
159
+
160
+ from morphic import Typed, Registry
161
+
162
+ class Metric(Typed, Registry, ABC):
163
+ value: float
164
+
165
+ @abstractmethod
166
+ @classproperty
167
+ def display_range(cls) -> tuple:
168
+ pass
169
+
170
+ class Accuracy(Metric):
171
+ aliases = ["acc"]
172
+
173
+ @classproperty
174
+ def display_range(cls) -> tuple:
175
+ return (0.0, 1.0)
176
+
177
+ Accuracy(value=0.95) # works
178
+ Metric.of("acc", value=0.95) # works via Registry
179
+
180
+ Intermediate abstract classes can add more abstract classproperties
181
+ without overriding existing ones::
182
+
183
+ class Metric(ABC):
184
+ @abstractmethod
185
+ @classproperty
186
+ def display_range(cls) -> tuple:
187
+ pass
188
+
189
+ class RankedMetric(Metric, ABC):
190
+ @abstractmethod
191
+ @classproperty
192
+ def optimization_direction(cls) -> str:
193
+ pass
194
+
195
+ class Accuracy(RankedMetric):
196
+ @classproperty
197
+ def display_range(cls) -> tuple:
198
+ return (0.0, 1.0)
199
+
200
+ @classproperty
201
+ def optimization_direction(cls) -> str:
202
+ return "maximize"
203
+
204
+ Grandchild classes that inherit from a concrete parent do **not** need to
205
+ re-override the classproperty::
206
+
207
+ class BinaryAccuracy(Accuracy):
208
+ pass
209
+
210
+ BinaryAccuracy.display_range # (0.0, 1.0) — inherited
211
+
212
+ Concrete classproperties on ABC classes
213
+ =======================================
214
+
215
+ An ABC can have **both** abstract and concrete classproperties. A concrete
216
+ ``@classproperty`` (without ``@abstractmethod``) on an ABC works exactly
217
+ like a classproperty on any other class: it is inherited by all subclasses
218
+ and is accessible from both the class and any instance. Only the abstract
219
+ classproperties require overriding::
220
+
221
+ class Metric(ABC):
222
+ @classproperty
223
+ def api_version(cls) -> int:
224
+ return 3 # concrete — inherited as-is
225
+
226
+ @abstractmethod
227
+ @classproperty
228
+ def display_range(cls) -> tuple:
229
+ pass # abstract — must override
230
+
231
+ class Accuracy(Metric):
232
+ @classproperty
233
+ def display_range(cls) -> tuple:
234
+ return (0.0, 1.0)
235
+
236
+ Accuracy.api_version # 3 (inherited, class-level)
237
+ Accuracy(value=0.95).api_version # 3 (inherited, instance-level)
238
+ Accuracy.display_range # (0.0, 1.0)
239
+ Accuracy(value=0.95).display_range # (0.0, 1.0)
240
+
241
+ Concrete classproperties on ABCs participate in normal MRO-based
242
+ polymorphism. A classproperty that reads ``cls.__name__`` will return the
243
+ actual subclass name when accessed on a child class or its instances::
244
+
245
+ class Base(ABC):
246
+ @classproperty
247
+ def label(cls) -> str:
248
+ return cls.__name__.lower()
249
+
250
+ @abstractmethod
251
+ def run(self) -> None:
252
+ pass
253
+
254
+ class Worker(Base):
255
+ def run(self) -> None:
256
+ pass
257
+
258
+ Worker.label # "worker" (class-level)
259
+ Worker().label # "worker" (instance-level)
260
+ Base.label # "base" (class-level on the ABC itself)
261
+
262
+ Subclasses can also override a concrete classproperty from an ABC parent::
263
+
264
+ class FastWorker(Worker):
265
+ @classproperty
266
+ def label(cls) -> str:
267
+ return "fast-" + cls.__name__.lower()
268
+
269
+ FastWorker.label # "fast-fastworker"
270
+ FastWorker().label # "fast-fastworker"
271
+ Worker.label # "worker" (unchanged)
272
+
273
+ Decorator stacking order
274
+ ========================
275
+
276
+ Only one stacking order is correct::
277
+
278
+ @abstractmethod # outer
279
+ @classproperty # inner
280
+ def value(cls) -> int:
281
+ pass
282
+
283
+ The reverse order (``@classproperty`` wrapping ``@abstractmethod``) does
284
+ not raise an error, but silently defeats ABC enforcement: the
285
+ ``classproperty`` executes ``fget`` on every ``getattr``, returning
286
+ ``None`` from the abstract body, and ABCMeta sees a concrete value.
287
+ Subclasses that forget to override will not be caught.
288
+
289
+ Note:
290
+ ``classproperty`` is used internally by ``Typed`` for built-in
291
+ class-level properties: ``class_name``, ``param_names``,
292
+ ``param_default_values``, and ``_constructor``.
293
+
294
+ Reference:
295
+ Original ``classproperty`` pattern:
296
+ https://stackoverflow.com/a/13624858/4900327
297
+ """
298
+
299
+ # ======================
300
+ # Implementation details
301
+ # ======================
302
+
303
+ # Two problems prevent naive ``@abstractmethod`` + ``@classproperty``
304
+ # stacking from working with CPython's ``property``:
305
+
306
+ # **Problem 1 — read-only ``__isabstractmethod__``:**
307
+ # ``@abstractmethod`` stamps ``funcobj.__isabstractmethod__ = True`` on the
308
+ # object it wraps. CPython's ``property`` exposes ``__isabstractmethod__``
309
+ # as a read-only C-level descriptor (delegating to ``fget.__isabstractmethod__``).
310
+ # Writing to it raises ``AttributeError: attribute '__isabstractmethod__' of
311
+ # 'property' objects is not writable``.
312
+
313
+ # **Solution:** ``classproperty`` shadows the inherited C-level descriptor
314
+ # with a Python-level ``@property``/``@setter`` pair backed by the instance
315
+ # attribute ``_is_abstract``. This makes ``__isabstractmethod__`` writable,
316
+ # so ``@abstractmethod`` can set it.
317
+
318
+ # **Problem 2 — ``__get__`` defeats ABCMeta enforcement:**
319
+ # ABCMeta checks whether abstract methods are overridden by doing
320
+ # ``value = getattr(cls, name)`` followed by
321
+ # ``getattr(value, "__isabstractmethod__", False)``. For a regular
322
+ # ``property``, ``getattr(cls, name)`` with no instance returns the
323
+ # descriptor object itself (because ``property.__get__(obj=None)`` returns
324
+ # ``self``). ABCMeta then sees ``descriptor.__isabstractmethod__ == True``
325
+ # and knows the method is still abstract.
326
+
327
+ # But ``classproperty.__get__`` is designed to execute ``fget(cls)`` even
328
+ # when accessed on the class — that is its raison d'être. So
329
+ # ``getattr(SubClass, name)`` calls ``fget(SubClass)`` and returns a
330
+ # concrete value (e.g. ``None`` from the abstract body). ABCMeta sees a
331
+ # plain value with no ``__isabstractmethod__`` attribute, concludes the
332
+ # method is overridden, and allows instantiation. The abstract contract
333
+ # is silently broken.
334
+
335
+ # **Solution:** ``__get__`` checks ``self._is_abstract``. When ``True``,
336
+ # it returns ``self`` (the descriptor) instead of executing ``fget``. This
337
+ # gives ABCMeta the descriptor it needs to see the abstract flag. When a
338
+ # subclass provides a concrete ``@classproperty`` (which has
339
+ # ``_is_abstract = False``), ``__get__`` executes ``fget`` normally and
340
+ # returns the computed value.
341
+
342
+ # Instance-level storage for __isabstractmethod__.
343
+ # CPython's property exposes __isabstractmethod__ as a read-only C-level
344
+ # descriptor that delegates to fget.__isabstractmethod__. We shadow it
345
+ # with an instance attribute so that @abstractmethod (which does
346
+ # ``funcobj.__isabstractmethod__ = True``) can write to it.
347
+ _is_abstract: bool = False
348
+
349
+ @property # type: ignore[override]
350
+ def __isabstractmethod__(self) -> bool:
351
+ return self._is_abstract
352
+
353
+ @__isabstractmethod__.setter
354
+ def __isabstractmethod__(self, value: bool) -> None:
355
+ self._is_abstract = value
356
+
357
+ def __get__(self, obj, objtype=None):
358
+ # When the classproperty is abstract, return the descriptor itself
359
+ # rather than executing fget. This is required so that ABCMeta's
360
+ # enforcement loop (which does ``getattr(cls, name)`` and then checks
361
+ # ``value.__isabstractmethod__``) can see the flag and correctly track
362
+ # the method as unimplemented.
363
+ #
364
+ # For concrete (non-abstract) classproperties, execute fget as usual.
365
+ if self._is_abstract:
366
+ return self
367
+ return super(classproperty, self).__get__(objtype)
368
+
369
+ def __set__(self, obj, value):
370
+ super(classproperty, self).__set__(type(obj), value)
371
+
372
+ def __delete__(self, obj):
373
+ super(classproperty, self).__delete__(type(obj))
@@ -23,51 +23,12 @@ from pydantic.errors import PydanticSchemaGenerationError
23
23
  from pydantic_core import PydanticUndefined
24
24
 
25
25
  from .autoenum import AutoEnum
26
+ from .classproperty import classproperty
26
27
  from .registry import Registry
27
28
  from .string import format_exception_msg
28
29
  from .structs import INBUILT_COLLECTIONS, map_collection
29
30
 
30
31
 
31
- class classproperty(property):
32
- """
33
- Descriptor that allows properties to be accessed at the class level.
34
-
35
- Similar to the built-in `property` decorator, but works on classes rather than instances.
36
- This allows defining computed properties that can be accessed directly on the class
37
- without requiring an instance.
38
-
39
- Examples:
40
- ```python
41
- class MyClass:
42
- _name = "Example"
43
-
44
- @classproperty
45
- def name(cls):
46
- return cls._name
47
-
48
- # Access directly on class
49
- print(MyClass.name) # "Example"
50
-
51
- # Also works on instances
52
- instance = MyClass()
53
- print(instance.name) # "Example"
54
- ```
55
-
56
- Note:
57
- This is used internally by Typed for class-level properties like `class_name`
58
- and `param_names`. Reference: https://stackoverflow.com/a/13624858/4900327
59
- """
60
-
61
- def __get__(self, obj, objtype=None):
62
- return super(classproperty, self).__get__(objtype)
63
-
64
- def __set__(self, obj, value):
65
- super(classproperty, self).__set__(type(obj), value)
66
-
67
- def __delete__(self, obj):
68
- super(classproperty, self).__delete__(type(obj))
69
-
70
-
71
32
  def _Typed_pformat(data: Any) -> str:
72
33
  """
73
34
  Pretty-format data structures for enhanced error messages.