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 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)