morphic 0.1.0__py3-none-any.whl
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.
- morphic/__init__.py +7 -0
- morphic/autoenum.py +397 -0
- morphic/registry.py +955 -0
- morphic/typed.py +1327 -0
- morphic-0.1.0.dist-info/METADATA +333 -0
- morphic-0.1.0.dist-info/RECORD +8 -0
- morphic-0.1.0.dist-info/WHEEL +4 -0
- morphic-0.1.0.dist-info/licenses/LICENSE +21 -0
morphic/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Morphic: Dynamic Python utilities for class registration, creation, and type checking."""
|
|
2
|
+
|
|
3
|
+
from .registry import Registry
|
|
4
|
+
from .autoenum import AutoEnum, alias, auto
|
|
5
|
+
from .typed import Typed, validate, ValidationError
|
|
6
|
+
|
|
7
|
+
__all__ = ["Registry", "AutoEnum", "alias", "auto", "Typed", "validate", "ValidationError"]
|
morphic/autoenum.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Fast fuzzy-matched enums with aliases and type safety."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import threading
|
|
5
|
+
import warnings
|
|
6
|
+
from enum import Enum, auto
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class alias(auto):
|
|
12
|
+
"""Create an alias for AutoEnum members with fuzzy matching support."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *aliases):
|
|
15
|
+
if len(aliases) == 0:
|
|
16
|
+
raise ValueError("Cannot have empty alias() call.")
|
|
17
|
+
for a in aliases:
|
|
18
|
+
if not isinstance(a, str):
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"All aliases must be strings; found alias of type {type(a)} having value: {a}"
|
|
21
|
+
)
|
|
22
|
+
self.names = aliases
|
|
23
|
+
self.enum_name = None
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return str(self)
|
|
27
|
+
|
|
28
|
+
def __str__(self):
|
|
29
|
+
if self.enum_name is not None:
|
|
30
|
+
return self.enum_name
|
|
31
|
+
return self.alias_repr
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def alias_repr(self) -> str:
|
|
35
|
+
return str(f"alias:{list(self.names)}")
|
|
36
|
+
|
|
37
|
+
def __setattr__(self, attr_name: str, attr_value: Any):
|
|
38
|
+
if attr_name == "value":
|
|
39
|
+
# because alias subclasses auto and does not set value, enum.py:143 will try to set value
|
|
40
|
+
self.enum_name = attr_value
|
|
41
|
+
else:
|
|
42
|
+
super(alias, self).__setattr__(attr_name, attr_value)
|
|
43
|
+
|
|
44
|
+
def __getattribute__(self, attr_name: str):
|
|
45
|
+
if attr_name == "value":
|
|
46
|
+
if object.__getattribute__(self, "enum_name") is None:
|
|
47
|
+
# Gets _auto_null as alias inherits auto class but does not set `value`
|
|
48
|
+
try:
|
|
49
|
+
return object.__getattribute__(self, "value")
|
|
50
|
+
except Exception:
|
|
51
|
+
from enum import _auto_null
|
|
52
|
+
return _auto_null
|
|
53
|
+
return self
|
|
54
|
+
return object.__getattribute__(self, attr_name)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_DEFAULT_REMOVAL_TABLE = str.maketrans(
|
|
58
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
59
|
+
"abcdefghijklmnopqrstuvwxyz",
|
|
60
|
+
" -_.:;,", # Will be removed
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AutoEnum(str, Enum):
|
|
65
|
+
"""
|
|
66
|
+
Ultra-fast AutoEnum with fuzzy matching, aliases, and collection conversion.
|
|
67
|
+
|
|
68
|
+
AutoEnum provides powerful string-to-enum conversion with case-insensitive matching,
|
|
69
|
+
automatic normalization, and comprehensive alias support. Optimized for performance
|
|
70
|
+
with O(1) lookups and thread-safe initialization.
|
|
71
|
+
|
|
72
|
+
Features:
|
|
73
|
+
- **Ultra-fast lookups**: O(1) performance with cached normalization
|
|
74
|
+
- **Fuzzy matching**: Case-insensitive with space/dash/underscore normalization
|
|
75
|
+
- **Rich aliases**: Multiple aliases per enum member with fuzzy matching
|
|
76
|
+
- **Collection conversion**: Convert lists, dicts, and sets between strings and enums
|
|
77
|
+
- **Display names**: Human-readable formatting for UI display
|
|
78
|
+
- **Thread-safe**: Safe concurrent access and initialization
|
|
79
|
+
- **Type safety**: Full typing support with IDE integration
|
|
80
|
+
|
|
81
|
+
Basic Usage:
|
|
82
|
+
```python
|
|
83
|
+
from morphic import AutoEnum, alias, auto
|
|
84
|
+
|
|
85
|
+
class Priority(AutoEnum):
|
|
86
|
+
HIGH = alias("urgent", "critical", "high_priority")
|
|
87
|
+
MEDIUM = alias("normal", "standard", "medium_priority")
|
|
88
|
+
LOW = alias("minor", "low_priority")
|
|
89
|
+
|
|
90
|
+
# Direct string conversion with fuzzy matching
|
|
91
|
+
p1 = Priority("high") # HIGH
|
|
92
|
+
p2 = Priority("URGENT") # HIGH (alias, case insensitive)
|
|
93
|
+
p3 = Priority("high-priority") # HIGH (fuzzy matching)
|
|
94
|
+
p4 = Priority("Normal") # MEDIUM (alias, case insensitive)
|
|
95
|
+
|
|
96
|
+
# Safe conversion with error handling
|
|
97
|
+
p5 = Priority.from_str("invalid", raise_error=False) # None
|
|
98
|
+
p6 = Priority.from_str("critical") # HIGH
|
|
99
|
+
|
|
100
|
+
# Check membership and matching
|
|
101
|
+
assert Priority.matches_any("urgent") # True
|
|
102
|
+
assert Priority.HIGH.matches("CRITICAL") # True
|
|
103
|
+
assert not Priority.matches_any("invalid") # False
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Advanced Features:
|
|
107
|
+
```python
|
|
108
|
+
# Collection conversion methods
|
|
109
|
+
status_strings = ["high", "normal", "urgent", "minor"]
|
|
110
|
+
statuses = Priority.convert_list(status_strings)
|
|
111
|
+
# Result: [Priority.HIGH, Priority.MEDIUM, Priority.HIGH, Priority.LOW]
|
|
112
|
+
|
|
113
|
+
# Dictionary key/value conversion
|
|
114
|
+
counts = {"high": 10, "normal": 25, "low": 5}
|
|
115
|
+
enum_counts = Priority.convert_keys(counts)
|
|
116
|
+
# Result: {Priority.HIGH: 10, Priority.MEDIUM: 25, Priority.LOW: 5}
|
|
117
|
+
|
|
118
|
+
# Display names for UI
|
|
119
|
+
for priority in Priority:
|
|
120
|
+
print(f"{priority}: {priority.display_name()}")
|
|
121
|
+
# Output:
|
|
122
|
+
# HIGH: High
|
|
123
|
+
# MEDIUM: Medium
|
|
124
|
+
# LOW: Low
|
|
125
|
+
|
|
126
|
+
# Custom display formatting
|
|
127
|
+
print(Priority.HIGH.display_name(sep="-")) # "High"
|
|
128
|
+
print(Priority.display_names()) # ["High", "Medium", "Low"]
|
|
129
|
+
|
|
130
|
+
# Dynamic enum creation
|
|
131
|
+
Color = AutoEnum.create("Color", ["red", "green grass", "Blue33"])
|
|
132
|
+
red = Color("red") # Color.Red
|
|
133
|
+
green = Color("green grass") # Color.Green_Grass
|
|
134
|
+
blue = Color("Blue33") # Color.Blue33
|
|
135
|
+
|
|
136
|
+
# Performance characteristics
|
|
137
|
+
# - 1M+ lookups per second with warm cache
|
|
138
|
+
# - Thread-safe initialization and access
|
|
139
|
+
# - Consistent O(1) performance regardless of enum size
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
String Normalization:
|
|
143
|
+
AutoEnum automatically normalizes strings by:
|
|
144
|
+
- Converting to lowercase
|
|
145
|
+
- Removing spaces, dashes, underscores, dots, colons, semicolons, commas
|
|
146
|
+
- Handling various naming conventions automatically
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
class Protocol(AutoEnum):
|
|
150
|
+
HTTP_SECURE = alias("HTTPS", "http-secure", "HTTP Secure")
|
|
151
|
+
|
|
152
|
+
# All variations work due to normalization
|
|
153
|
+
p1 = Protocol("HTTP-SECURE") # HTTP_SECURE
|
|
154
|
+
p2 = Protocol("http_secure") # HTTP_SECURE
|
|
155
|
+
p3 = Protocol("HTTP Secure") # HTTP_SECURE
|
|
156
|
+
p4 = Protocol("httpsecure") # HTTP_SECURE (spaces removed)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Error Handling:
|
|
160
|
+
```python
|
|
161
|
+
try:
|
|
162
|
+
status = Priority("invalid_priority")
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
print(f"Error: {e}")
|
|
165
|
+
# Output: Could not find enum with value 'invalid_priority';
|
|
166
|
+
# available: ['HIGH', 'MEDIUM', 'LOW']
|
|
167
|
+
|
|
168
|
+
# Safe conversion patterns
|
|
169
|
+
def safe_priority(value: str) -> Optional[Priority]:
|
|
170
|
+
return Priority.from_str(value, raise_error=False)
|
|
171
|
+
|
|
172
|
+
priority = safe_priority("maybe_valid") # Returns None if invalid
|
|
173
|
+
if priority:
|
|
174
|
+
print(f"Valid priority: {priority}")
|
|
175
|
+
```
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
__slots__ = () # no per-instance attrs beyond those in Enum/str
|
|
179
|
+
|
|
180
|
+
def __init__(self, value: Union[str, alias]):
|
|
181
|
+
# store aliases tuple for each member
|
|
182
|
+
object.__setattr__(self, "aliases", tuple(value.names) if isinstance(value, alias) else ())
|
|
183
|
+
|
|
184
|
+
def _generate_next_value_(name, start, count, last_values):
|
|
185
|
+
# keep the enum member's *name* as its value
|
|
186
|
+
return name
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def __init_subclass__(cls, **kwargs):
|
|
190
|
+
super().__init_subclass__(**kwargs)
|
|
191
|
+
setattr(cls, "_lookup_lock", threading.Lock())
|
|
192
|
+
cls._initialize_lookup()
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def _initialize_lookup(cls):
|
|
196
|
+
# quick check to avoid locking if already built
|
|
197
|
+
if "_value2member_map_normalized_" in cls.__dict__:
|
|
198
|
+
return
|
|
199
|
+
with cls._lookup_lock:
|
|
200
|
+
if "_value2member_map_normalized_" in cls.__dict__:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
mapping: Dict[str, "AutoEnum"] = {}
|
|
204
|
+
|
|
205
|
+
def _register(e: "AutoEnum", norm: str):
|
|
206
|
+
if norm in mapping:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f'Cannot register enum "{e.name}"; normalized name "{norm}" already exists.'
|
|
209
|
+
)
|
|
210
|
+
mapping[norm] = e
|
|
211
|
+
|
|
212
|
+
# walk every member exactly once
|
|
213
|
+
for e in cls:
|
|
214
|
+
# register its own name
|
|
215
|
+
_register(e, cls._normalize(e.name))
|
|
216
|
+
# register alias repr
|
|
217
|
+
if e.aliases:
|
|
218
|
+
# inline alias_repr
|
|
219
|
+
alias_repr = f"alias:{list(e.aliases)}"
|
|
220
|
+
_register(e, cls._normalize(alias_repr))
|
|
221
|
+
# register each plain alias
|
|
222
|
+
for a in e.aliases:
|
|
223
|
+
_register(e, cls._normalize(a))
|
|
224
|
+
|
|
225
|
+
# stash it on the class
|
|
226
|
+
setattr(cls, "_value2member_map_normalized_", mapping)
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
@lru_cache(maxsize=None)
|
|
230
|
+
def _normalize(cls, x: str) -> str:
|
|
231
|
+
# C-level translate is very fast; caching makes repeated lookups O(1)
|
|
232
|
+
return str(x).translate(_DEFAULT_REMOVAL_TABLE)
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def _missing_(cls, enum_value: Any):
|
|
236
|
+
# invoked by Enum machinery when auto-casting fails
|
|
237
|
+
return cls.from_str(enum_value, raise_error=True)
|
|
238
|
+
|
|
239
|
+
def __str__(self) -> str:
|
|
240
|
+
return self.name
|
|
241
|
+
|
|
242
|
+
def __repr__(self) -> str:
|
|
243
|
+
return self.name
|
|
244
|
+
|
|
245
|
+
def __hash__(self) -> int:
|
|
246
|
+
return hash(self.__class__.__name__ + "." + self.name)
|
|
247
|
+
|
|
248
|
+
def __eq__(self, other: Any) -> bool:
|
|
249
|
+
# identity check is fastest and correct for singletons
|
|
250
|
+
return self is other
|
|
251
|
+
|
|
252
|
+
def __ne__(self, other: Any) -> bool:
|
|
253
|
+
return self is not other
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def from_str(cls, enum_value: Any, raise_error: bool = True) -> Optional["AutoEnum"]:
|
|
257
|
+
"""Convert string to AutoEnum with fuzzy matching."""
|
|
258
|
+
# short‐circuit if it's already the right type
|
|
259
|
+
if isinstance(enum_value, cls):
|
|
260
|
+
return enum_value
|
|
261
|
+
# None tolerated?
|
|
262
|
+
if enum_value is None:
|
|
263
|
+
if raise_error:
|
|
264
|
+
raise ValueError("Cannot convert None to enum")
|
|
265
|
+
return None
|
|
266
|
+
# wrong type?
|
|
267
|
+
if not isinstance(enum_value, str):
|
|
268
|
+
if raise_error:
|
|
269
|
+
raise ValueError(f"Input must be str or {cls.__name__}; got {type(enum_value)}")
|
|
270
|
+
return None
|
|
271
|
+
# one normalized dict lookup
|
|
272
|
+
norm = cls._normalize(enum_value)
|
|
273
|
+
e = cls._value2member_map_normalized_.get(norm)
|
|
274
|
+
if e is None and raise_error:
|
|
275
|
+
raise ValueError(f"Could not find enum with value {enum_value!r}; available: {list(cls)}")
|
|
276
|
+
return e
|
|
277
|
+
|
|
278
|
+
def matches(self, enum_value: str) -> bool:
|
|
279
|
+
"""Check if this enum matches the given string value."""
|
|
280
|
+
return self is self.from_str(enum_value, raise_error=False)
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def matches_any(cls, enum_value: str) -> bool:
|
|
284
|
+
"""Check if any enum member matches the given string value."""
|
|
285
|
+
return cls.from_str(enum_value, raise_error=False) is not None
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def does_not_match_any(cls, enum_value: str) -> bool:
|
|
289
|
+
"""Check if no enum member matches the given string value."""
|
|
290
|
+
return not cls.matches_any(enum_value)
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def display_names(cls, **kwargs) -> str:
|
|
294
|
+
"""Get display names of all enum members."""
|
|
295
|
+
return str([e.display_name(**kwargs) for e in cls])
|
|
296
|
+
|
|
297
|
+
def display_name(self, *, sep: str = " ") -> str:
|
|
298
|
+
"""Get human-readable display name for this enum member."""
|
|
299
|
+
return sep.join(
|
|
300
|
+
word.lower() if word.lower() in ("of", "in", "the") else word.capitalize()
|
|
301
|
+
for word in self.name.split("_")
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# -------------- conversion utilities --------------
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def convert_keys(cls, d: Dict) -> Dict:
|
|
308
|
+
"""Convert string dictionary keys to enum values where possible."""
|
|
309
|
+
out = {}
|
|
310
|
+
for k, v in d.items():
|
|
311
|
+
if isinstance(k, str):
|
|
312
|
+
e = cls.from_str(k, raise_error=False)
|
|
313
|
+
out[e if e else k] = v
|
|
314
|
+
else:
|
|
315
|
+
out[k] = v
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
@classmethod
|
|
319
|
+
def convert_keys_to_str(cls, d: Dict) -> Dict:
|
|
320
|
+
"""Convert enum dictionary keys to strings."""
|
|
321
|
+
return {(str(k) if isinstance(k, cls) else k): v for k, v in d.items()}
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def convert_values(
|
|
325
|
+
cls, d: Union[Dict, Set, List, Tuple], raise_error: bool = False
|
|
326
|
+
) -> Union[Dict, Set, List, Tuple]:
|
|
327
|
+
"""Convert string values to enum values where possible."""
|
|
328
|
+
if isinstance(d, dict):
|
|
329
|
+
return cls.convert_dict_values(d)
|
|
330
|
+
if isinstance(d, list):
|
|
331
|
+
return cls.convert_list(d)
|
|
332
|
+
if isinstance(d, tuple):
|
|
333
|
+
return tuple(cls.convert_list(list(d)))
|
|
334
|
+
if isinstance(d, set):
|
|
335
|
+
return cls.convert_set(d)
|
|
336
|
+
if raise_error:
|
|
337
|
+
raise ValueError(f"Unsupported type: {type(d)}")
|
|
338
|
+
return d
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def convert_dict_values(cls, d: Dict) -> Dict:
|
|
342
|
+
"""Convert string dictionary values to enum values where possible."""
|
|
343
|
+
return {k: (cls.from_str(v, raise_error=False) if isinstance(v, str) else v) for k, v in d.items()}
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def convert_list(cls, l: List) -> List:
|
|
347
|
+
"""Convert string list items to enum values where possible."""
|
|
348
|
+
return [
|
|
349
|
+
(cls.from_str(item) if isinstance(item, str) and cls.matches_any(item) else item) for item in l
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def convert_set(cls, s: Set) -> Set:
|
|
354
|
+
"""Convert string set items to enum values where possible."""
|
|
355
|
+
out = set()
|
|
356
|
+
for item in s:
|
|
357
|
+
if isinstance(item, str) and cls.matches_any(item):
|
|
358
|
+
out.add(cls.from_str(item))
|
|
359
|
+
else:
|
|
360
|
+
out.add(item)
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def convert_values_to_str(cls, d: Dict) -> Dict:
|
|
365
|
+
"""Convert enum dictionary values to strings."""
|
|
366
|
+
return {k: (str(v) if isinstance(v, cls) else v) for k, v in d.items()}
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def create(name: str, values: List[str]) -> type["AutoEnum"]:
|
|
370
|
+
"""
|
|
371
|
+
Dynamically creates an AutoEnum subclass named `name` from a list of strings.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
name: The name for the new enum class
|
|
375
|
+
values: List of string values to become enum members
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
A new AutoEnum subclass
|
|
379
|
+
|
|
380
|
+
Example:
|
|
381
|
+
Status = AutoEnum.create('Status', ['pending', 'running', 'complete'])
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
# sanitize Python identifiers: letters, digits and underscores only
|
|
385
|
+
def to_identifier(s: str) -> str:
|
|
386
|
+
# replace non-word chars with underscore, strip leading digits
|
|
387
|
+
ident: str = re.sub(r"\W+", "_", s).lstrip("0123456789").lstrip("_").rstrip("_")
|
|
388
|
+
ident_capitalize: str = "_".join([x.capitalize() for x in ident.split("_")])
|
|
389
|
+
if s != ident:
|
|
390
|
+
warnings.warn(
|
|
391
|
+
f"We have converted '{s}' to '{ident_capitalize}' to make it a valid Python identifier"
|
|
392
|
+
)
|
|
393
|
+
return ident_capitalize
|
|
394
|
+
|
|
395
|
+
members = {to_identifier(v): auto() for v in values}
|
|
396
|
+
# Enum functional constructor:
|
|
397
|
+
return AutoEnum(name, members)
|