levelapp 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.

Potentially problematic release.


This version of levelapp might be problematic. Click here for more details.

Files changed (46) hide show
  1. levelapp/__init__.py +0 -0
  2. levelapp/aspects/__init__.py +8 -0
  3. levelapp/aspects/loader.py +253 -0
  4. levelapp/aspects/logger.py +59 -0
  5. levelapp/aspects/monitor.py +614 -0
  6. levelapp/aspects/sanitizer.py +168 -0
  7. levelapp/clients/__init__.py +119 -0
  8. levelapp/clients/anthropic.py +112 -0
  9. levelapp/clients/ionos.py +116 -0
  10. levelapp/clients/mistral.py +106 -0
  11. levelapp/clients/openai.py +102 -0
  12. levelapp/comparator/__init__.py +5 -0
  13. levelapp/comparator/comparator.py +232 -0
  14. levelapp/comparator/extractor.py +108 -0
  15. levelapp/comparator/schemas.py +61 -0
  16. levelapp/comparator/scorer.py +271 -0
  17. levelapp/comparator/utils.py +136 -0
  18. levelapp/config/__init__.py +5 -0
  19. levelapp/config/endpoint.py +190 -0
  20. levelapp/config/prompts.py +35 -0
  21. levelapp/core/__init__.py +0 -0
  22. levelapp/core/base.py +386 -0
  23. levelapp/core/session.py +214 -0
  24. levelapp/evaluator/__init__.py +3 -0
  25. levelapp/evaluator/evaluator.py +265 -0
  26. levelapp/metrics/__init__.py +67 -0
  27. levelapp/metrics/embedding.py +2 -0
  28. levelapp/metrics/exact.py +182 -0
  29. levelapp/metrics/fuzzy.py +80 -0
  30. levelapp/metrics/token.py +103 -0
  31. levelapp/plugins/__init__.py +0 -0
  32. levelapp/repository/__init__.py +3 -0
  33. levelapp/repository/firestore.py +282 -0
  34. levelapp/simulator/__init__.py +3 -0
  35. levelapp/simulator/schemas.py +89 -0
  36. levelapp/simulator/simulator.py +441 -0
  37. levelapp/simulator/utils.py +201 -0
  38. levelapp/workflow/__init__.py +5 -0
  39. levelapp/workflow/base.py +113 -0
  40. levelapp/workflow/factory.py +51 -0
  41. levelapp/workflow/registration.py +6 -0
  42. levelapp/workflow/schemas.py +121 -0
  43. levelapp-0.1.0.dist-info/METADATA +254 -0
  44. levelapp-0.1.0.dist-info/RECORD +46 -0
  45. levelapp-0.1.0.dist-info/WHEEL +4 -0
  46. levelapp-0.1.0.dist-info/licenses/LICENSE +0 -0
@@ -0,0 +1,614 @@
1
+ """levelapp/aspects.monitor.py"""
2
+ import threading
3
+ import tracemalloc
4
+ from contextlib import contextmanager
5
+
6
+ from enum import Enum
7
+ from collections import defaultdict
8
+ from dataclasses import dataclass, fields
9
+ from typing import List, Dict, Callable, Any, Union, ParamSpec, TypeVar, runtime_checkable, Protocol, Type
10
+
11
+ from threading import RLock
12
+ from functools import wraps
13
+ from datetime import datetime, timedelta
14
+ from humanize import precisedelta, naturalsize
15
+
16
+ from levelapp.aspects import logger
17
+
18
+
19
+ P = ParamSpec('P')
20
+ T = TypeVar('T')
21
+
22
+
23
+ class MetricType(Enum):
24
+ """Types of metrics that can be collected."""
25
+ SETUP = "setup"
26
+ DATA_LOADING = "data_loading"
27
+ EXECUTION = "execution"
28
+ RESULTS_COLLECTION = "results_collection"
29
+
30
+ API_CALL = "api_call"
31
+ SCORING = "scoring"
32
+ CUSTOM = "custom"
33
+
34
+
35
+ @dataclass
36
+ class ExecutionMetrics:
37
+ """Comprehensive metrics for a function execution."""
38
+ procedure: str
39
+ category: MetricType = MetricType.CUSTOM
40
+ start_time: datetime | None = None
41
+ end_time: datetime | None = None
42
+ duration: float | None = None
43
+ total_api_calls: int = 0
44
+ memory_before: int | None = None
45
+ memory_after: int | None = None
46
+ memory_peak: int | None = None
47
+ cache_hit: bool = False
48
+ error: str | None = None
49
+
50
+ def finalize(self) -> None:
51
+ """Finalize metrics calculation."""
52
+ if self.end_time and self.start_time:
53
+ self.duration = (self.end_time - self.start_time).total_seconds()
54
+
55
+ def update_duration(self, value: float) -> None:
56
+ """Update duration with explicit value."""
57
+ if value < 0:
58
+ raise ValueError("Duration value cannot be negative.")
59
+ self.duration = value
60
+
61
+ def to_dict(self) -> dict:
62
+ """Returns the content of the ExecutionMetrics as a structured dictionary."""
63
+ metrics_dict = {}
64
+ for field_info in fields(self):
65
+ value = getattr(self, field_info.name)
66
+
67
+ # Special handling for enum types to convert them to their value
68
+ if isinstance(value, Enum):
69
+ metrics_dict[field_info.name] = value.name
70
+ elif isinstance(value, datetime):
71
+ metrics_dict[field_info.name] = value.isoformat()
72
+ else:
73
+ metrics_dict[field_info.name] = value
74
+
75
+ return metrics_dict
76
+
77
+
78
+ @dataclass
79
+ class AggregatedStats:
80
+ """Aggregated metrics for monitored functions."""
81
+ total_calls: int = 0
82
+ total_duration: float = 0.0
83
+ min_duration: float = float('inf')
84
+ max_duration: float = 0.0
85
+ error_count: int = 0
86
+ cache_hits: int = 0
87
+ memory_peak: int = 0
88
+ recent_call: datetime | None = None
89
+
90
+ def update(self, metrics: ExecutionMetrics) -> None:
91
+ """Update aggregated metrics with new execution metrics."""
92
+ self.recent_call = datetime.now()
93
+ self.total_calls += 1
94
+
95
+ if metrics.duration is not None:
96
+ self.total_duration += metrics.duration
97
+
98
+ if self.min_duration == float('inf'):
99
+ self.min_duration = metrics.duration
100
+ else:
101
+ self.min_duration = min(self.min_duration, metrics.duration)
102
+
103
+ self.max_duration = max(self.max_duration, metrics.duration)
104
+
105
+ if metrics.error:
106
+ self.error_count += 1
107
+
108
+ if metrics.cache_hit:
109
+ self.cache_hits += 1
110
+
111
+ if metrics.memory_peak:
112
+ self.memory_peak = max(self.memory_peak, metrics.memory_peak)
113
+
114
+ @property
115
+ def average_duration(self) -> float:
116
+ """Average execution duration."""
117
+ return (self.total_duration / self.total_calls) if self.total_calls > 0 else 0.0
118
+
119
+ @property
120
+ def cache_hit_rate(self) -> float:
121
+ """Cache hit rate as a percentage."""
122
+ return (self.cache_hits / self.total_calls * 100) if self.total_calls > 0 else 0.0
123
+
124
+ @property
125
+ def error_rate(self) -> float:
126
+ """Error rate as a percentage."""
127
+ return (self.error_count / self.total_calls * 100) if self.total_calls > 0 else 0.0
128
+
129
+
130
+ @runtime_checkable
131
+ class MetricsCollector(Protocol):
132
+ """Protocol for custom metrics collectors."""
133
+
134
+ def collect_before(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
135
+ """Collect metrics before function execution."""
136
+ ...
137
+
138
+ def collect_after(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
139
+ """Collect metrics after function execution."""
140
+ ...
141
+
142
+
143
+ class MemoryTracker(MetricsCollector):
144
+ """Memory usage metrics collector."""
145
+ def __init__(self):
146
+ self._tracking = False
147
+ self._lock = threading.Lock()
148
+
149
+ @contextmanager
150
+ def _ensure_tracking(self):
151
+ """Context manager to ensure tracemalloc is properly managed."""
152
+ with self._lock:
153
+ if not self._tracking:
154
+ tracemalloc.start()
155
+ self._tracking = True
156
+ try:
157
+ yield
158
+ finally:
159
+ pass
160
+
161
+ def collect_before(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
162
+ with self._ensure_tracking():
163
+ try:
164
+ current, _ = tracemalloc.get_traced_memory()
165
+ collected_metrics.memory_before = current
166
+
167
+ except Exception as e:
168
+ logger.warning(f"[MemoryTracker] Memory tracking failed: {e}")
169
+
170
+ return collected_metrics
171
+
172
+ def collect_after(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
173
+ if self._tracking:
174
+ try:
175
+ current, peak = tracemalloc.get_traced_memory()
176
+ collected_metrics.memory_after = current
177
+ collected_metrics.memory_peak = peak
178
+ return collected_metrics
179
+
180
+ except Exception as e:
181
+ logger.warning(f"Memory tracking failed: {e}")
182
+ return collected_metrics
183
+
184
+ def cleanup(self):
185
+ """Explicit cleanup method."""
186
+ with self._lock:
187
+ if self._tracking:
188
+ try:
189
+ tracemalloc.stop()
190
+ except Exception as e:
191
+ logger.warning(f"Failed to stop tracemalloc: {e}")
192
+ finally:
193
+ self._tracking = False
194
+
195
+
196
+ class APICallTracker(MetricsCollector):
197
+ """API call metrics collector for LLM clients."""
198
+
199
+ def __init__(self):
200
+ self._api_calls = defaultdict(int)
201
+ self._lock = threading.Lock()
202
+
203
+ def collect_before(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
204
+ return collected_metrics
205
+
206
+ def collect_after(self, collected_metrics: ExecutionMetrics) -> ExecutionMetrics:
207
+ with self._lock:
208
+ if collected_metrics.category == MetricType.API_CALL:
209
+ self._api_calls[collected_metrics.procedure] += 1
210
+ collected_metrics.total_api_calls = self._api_calls[collected_metrics.procedure]
211
+
212
+ return collected_metrics
213
+
214
+
215
+ class FunctionMonitor:
216
+ """Core function monitoring system."""
217
+
218
+ def __init__(self, max_history: int = 1000):
219
+ self._lock = RLock()
220
+ self._max_history = max_history
221
+ self._monitored_procedures: Dict[str, Callable[..., Any]] = {}
222
+ self._execution_history: Dict[str, List[ExecutionMetrics]] = defaultdict(list)
223
+ self._aggregated_stats: Dict[str, AggregatedStats] = defaultdict(AggregatedStats)
224
+
225
+ self._collectors: List[MetricsCollector] = []
226
+
227
+ self.add_collector(MemoryTracker())
228
+ self.add_collector(APICallTracker())
229
+
230
+ def update_procedure_duration(self, name: str, value: float) -> None:
231
+ """
232
+ Update the duration of a monitored procedure by name.
233
+
234
+ Args:
235
+ name: The name of the procedure to retrieve.
236
+ value: The value to retrieve for the procedure.
237
+ """
238
+ with self._lock:
239
+ history = self._execution_history.get(name, [])
240
+ if not history:
241
+ logger.warning(f"[FunctionMonitor] ne execution history found for proc: {name}")
242
+ return
243
+
244
+ latest_entry = history[-1]
245
+ latest_entry.update_duration(value=value)
246
+ self._aggregated_stats[name] = AggregatedStats()
247
+
248
+ for entry in history:
249
+ self._aggregated_stats[name].update(metrics=entry)
250
+
251
+ def add_collector(self, collector: MetricsCollector) -> None:
252
+ """
253
+ Add a custom metrics collector to the monitor.
254
+
255
+ Args:
256
+ collector: An instance of a class implementing MetricsCollector protocol.
257
+ """
258
+ with self._lock:
259
+ self._collectors.append(collector)
260
+
261
+ def remove_collector(self, collector: MetricsCollector) -> None:
262
+ """
263
+ Remove a custom metrics collector from the monitor.
264
+
265
+ Args:
266
+ collector: An instance of a class implementing MetricsCollector protocol.
267
+ """
268
+ with self._lock:
269
+ if collector in self._collectors:
270
+ self._collectors.remove(collector)
271
+
272
+ def _collect_metrics_before(self, execution_metrics: ExecutionMetrics) -> ExecutionMetrics:
273
+ """Collect metrics before function execution using registered collectors."""
274
+ result_metrics = execution_metrics
275
+ for collector in self._collectors:
276
+ try:
277
+ result_metrics = collector.collect_before(result_metrics)
278
+ except Exception as e:
279
+ logger.warning(f"Metrics collector {type(collector).__name__} failed: {e}")
280
+
281
+ return result_metrics
282
+
283
+ def _collect_metrics_after(self, execution_metrics: ExecutionMetrics) -> ExecutionMetrics:
284
+ """Collect metrics after function execution using registered collectors."""
285
+ result_metrics = execution_metrics
286
+ for collector in self._collectors:
287
+ try:
288
+ result_metrics = collector.collect_after(result_metrics)
289
+ except Exception as e:
290
+ logger.warning(f"Metrics collector {type(collector).__name__} failed: {e}")
291
+ return result_metrics
292
+
293
+ @staticmethod
294
+ def _apply_caching(func: Callable[P, T], maxsize: int | None) -> Callable[P, T]:
295
+ if maxsize is None:
296
+ return func
297
+
298
+ def make_args_hashable(args, kwargs):
299
+ hashable_args = tuple(_make_hashable(a) for a in args)
300
+ hashable_kwargs = tuple(sorted((k, _make_hashable(v)) for k, v in kwargs.items()))
301
+ return hashable_args, hashable_kwargs
302
+
303
+ cache = {}
304
+ cache_info = {"hits": 0, "misses": 0}
305
+
306
+ @wraps(func)
307
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
308
+ nonlocal cache_info
309
+ cache_key = make_args_hashable(args, kwargs)
310
+
311
+ if cache_key in cache:
312
+ cache_info["hits"] += 1
313
+ return cache[cache_key]
314
+
315
+ cache_info["misses"] += 1
316
+ result = func(*args, **kwargs)
317
+ cache[cache_key] = result
318
+
319
+ # Enforce maxsize
320
+ if len(cache) > maxsize:
321
+ cache.pop(next(iter(cache)))
322
+ return result
323
+
324
+ # Add cache management methods
325
+ def get_cache_info():
326
+ return dict(cache_info)
327
+
328
+ def clear_cache():
329
+ nonlocal cache, cache_info
330
+ cache.clear()
331
+ cache_info = {"hits": 0, "misses": 0}
332
+
333
+ wrapper.cache_info = get_cache_info
334
+ wrapper.cache_clear = clear_cache
335
+ wrapper._cache = cache # For debugging/inspection
336
+
337
+ return wrapper
338
+
339
+ def _wrap_execution(
340
+ self,
341
+ func: Callable[P, T],
342
+ name: str,
343
+ category: MetricType,
344
+ enable_timing: bool,
345
+ track_memory: bool,
346
+ ) -> Callable[P, T]:
347
+ """
348
+ Wrap function execution with timing and error handling.
349
+
350
+ Args:
351
+ func: Function to be wrapped
352
+ name: Unique identifier for the function
353
+ enable_timing: Enable execution time logging
354
+ track_memory: Enable memory tracking
355
+
356
+ Returns:
357
+ Wrapped function
358
+ """
359
+ @wraps(func)
360
+ def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
361
+ metrics = ExecutionMetrics(
362
+ procedure=name,
363
+ category=category,
364
+ )
365
+
366
+ if enable_timing:
367
+ metrics.start_time = datetime.now()
368
+
369
+ # Collect pre-execution metrics
370
+ if track_memory:
371
+ self._collect_metrics_before(execution_metrics=metrics)
372
+
373
+ try:
374
+ result = func(*args, **kwargs)
375
+
376
+ cache_hit_info = getattr(func, 'cache_hit_info', None)
377
+ if hasattr(func, 'cache_info') and cache_hit_info is not None:
378
+ metrics.cache_hit = getattr(cache_hit_info, 'is_hit', False)
379
+
380
+ return result
381
+
382
+ except Exception as e:
383
+ metrics.error = str(e)
384
+ logger.error(f"Error in '{name}': {str(e)}", exc_info=True)
385
+ raise
386
+
387
+ finally:
388
+ if enable_timing:
389
+ metrics.end_time = datetime.now()
390
+ metrics.finalize()
391
+
392
+ if track_memory:
393
+ metrics = self._collect_metrics_after(execution_metrics=metrics)
394
+
395
+ # store metrics
396
+ with self._lock:
397
+ history = self._execution_history[name]
398
+ history.append(metrics)
399
+
400
+ if len(history) > self._max_history:
401
+ history.pop(0)
402
+
403
+ self._aggregated_stats[name].update(metrics=metrics)
404
+
405
+ if enable_timing and metrics.duration is not None:
406
+ log_message = f"[FunctionMonitor] Executed '{name}' in {metrics.duration:.4f}s"
407
+ if metrics.cache_hit:
408
+ log_message += " (cache hit)"
409
+ if metrics.memory_peak:
410
+ log_message += f" (memory peak: {metrics.memory_peak / 1024 / 1024:.2f} MB)"
411
+ logger.info(log_message)
412
+
413
+ return wrapped
414
+
415
+ def monitor(
416
+ self,
417
+ name: str,
418
+ category: MetricType = MetricType.CUSTOM,
419
+ cached: bool = False,
420
+ maxsize: int | None = 128,
421
+ enable_timing: bool = True,
422
+ track_memory: bool = True,
423
+ collectors: List[Type[MetricsCollector]] | None = None
424
+ ) -> Callable[[Callable[P, T]], Callable[P, T]]:
425
+ """
426
+ Decorator factory for monitoring functions.
427
+
428
+ Args:
429
+ name: Unique identifier for the function
430
+ category: Category of the metric (e.g., API_CALL, SCORING)
431
+ cached: Enable LRU caching
432
+ maxsize: Maximum cache size
433
+ enable_timing: Record execution time
434
+ track_memory: Track memory usage
435
+ collectors: Optional list of custom metrics collectors
436
+
437
+ Returns:
438
+ Callable[[Callable[P, T]], Callable[P, T]]: Decorator function
439
+ """
440
+ def decorator(func: Callable[P, T]) -> Callable[P, T]:
441
+ if collectors:
442
+ for collector in collectors:
443
+ self.add_collector(collector)
444
+
445
+ if cached:
446
+ func = self._apply_caching(func=func, maxsize=maxsize)
447
+
448
+ monitored_func = self._wrap_execution(
449
+ func=func,
450
+ name=name,
451
+ category=category,
452
+ enable_timing=enable_timing,
453
+ track_memory=track_memory,
454
+ )
455
+
456
+ with self._lock:
457
+ if name in self._monitored_procedures:
458
+ raise ValueError(f"Function '{name}' is already registered.")
459
+
460
+ self._monitored_procedures[name] = monitored_func
461
+
462
+ return monitored_func
463
+
464
+ return decorator
465
+
466
+ def list_monitored_functions(self) -> Dict[str, Callable[..., Any]]:
467
+ """
468
+ List all registered monitored functions.
469
+
470
+ Returns:
471
+ List[str]: Names of all registered functions
472
+ """
473
+ with self._lock:
474
+ return dict(self._monitored_procedures)
475
+
476
+ def get_stats(self, name: str) -> Dict[str, Any] | None:
477
+ """
478
+ Get comprehensive statistics for a monitored function.
479
+
480
+ Args:
481
+ name (str): Name of the monitored function.
482
+
483
+ Returns:
484
+ Dict[str, Any] | None: Dictionary containing function statistics or None if not found.
485
+ """
486
+ with self._lock:
487
+ if name not in self._monitored_procedures:
488
+ return None
489
+
490
+ func = self._monitored_procedures[name]
491
+ stats = self._aggregated_stats[name]
492
+
493
+ min_duration = stats.min_duration if stats.min_duration != float('inf') else 0.0
494
+
495
+ return {
496
+ 'name': name,
497
+ 'total_calls': stats.total_calls,
498
+ 'avg_duration': precisedelta(
499
+ timedelta(seconds=stats.average_duration),
500
+ suppress=['minutes'],
501
+ format='%.4f'
502
+ ) if stats.average_duration > 0 else "0.000s",
503
+ 'min_duration': precisedelta(
504
+ timedelta(seconds=min_duration),
505
+ suppress=['minutes'],
506
+ format='%.4f'
507
+ ),
508
+ 'max_duration': precisedelta(
509
+ timedelta(seconds=stats.max_duration),
510
+ suppress=['minutes'],
511
+ format='%.4f'
512
+ ),
513
+ 'error_rate': f"{stats.error_rate:.2f}%",
514
+ 'cache_hit_rate': f"{stats.cache_hit_rate}%",
515
+ 'memory_peak_mb': naturalsize(stats.memory_peak) if stats.memory_peak > 0 else "0 B",
516
+ 'last_called': stats.recent_call.isoformat() if stats.recent_call else None,
517
+ 'recent_execution': stats.recent_call.isoformat() if stats.recent_call else None,
518
+ 'is_cached': hasattr(func, 'cache_info'),
519
+ 'cache_info': func.cache_info() if hasattr(func, 'cache_info') else None
520
+ }
521
+
522
+ def get_all_stats(self) -> Dict[str, Dict[str, Any]]:
523
+ """Get statistics for all monitored functions."""
524
+ with self._lock:
525
+ return {
526
+ name: self.get_stats(name)
527
+ for name in self._monitored_procedures.keys()
528
+ }
529
+
530
+ def get_execution_history(
531
+ self,
532
+ name: str | None = None,
533
+ category: MetricType | None = None,
534
+ limit: int | None = None
535
+ ) -> list[ExecutionMetrics]:
536
+ """Get execution history filtered by procedure name or category."""
537
+ with self._lock:
538
+ if name:
539
+ history = self._execution_history.get(name, [])
540
+ else:
541
+ history = [m for h in self._execution_history.values() for m in h]
542
+
543
+ if category:
544
+ history = [m for m in history if m.category == category]
545
+
546
+ history.sort(key=lambda m: m.start_time or 0)
547
+ return history[-limit:] if limit else history
548
+
549
+ def clear_history(self, procedure: str | None = None) -> None:
550
+ """Clear execution history."""
551
+ with self._lock:
552
+ if procedure:
553
+ if procedure in self._execution_history:
554
+ self._execution_history[procedure].clear()
555
+ if procedure in self._aggregated_stats:
556
+ self._aggregated_stats[procedure] = AggregatedStats()
557
+ else:
558
+ self._execution_history.clear()
559
+ self._aggregated_stats.clear()
560
+
561
+ def export_metrics(self, output_format: str = 'dict') -> Union[Dict[str, Any], str]:
562
+ """
563
+ Export all metrics in various formats.
564
+
565
+ Args:
566
+ output_format (str): Format for exporting metrics ('dict' or 'json').
567
+
568
+ Returns:
569
+ Union[Dict[str, Any], str]: Exported metrics in the specified format.
570
+ """
571
+ with self._lock:
572
+ data = {
573
+ 'timestamp': datetime.now().isoformat(),
574
+ 'functions': self.get_all_stats(),
575
+ 'total_executions': sum(
576
+ len(history) for history in self._execution_history.values()
577
+ ),
578
+ 'collectors': [type(c).__name__ for c in self._collectors]
579
+ }
580
+
581
+ if output_format == 'dict':
582
+ return data
583
+ elif output_format == 'json':
584
+ import json
585
+ return json.dumps(data, indent=2, default=str)
586
+ else:
587
+ raise ValueError(f"Unsupported format: {output_format}")
588
+
589
+ def cleanup(self):
590
+ """Cleanup resources."""
591
+ with self._lock:
592
+ for collector in self._collectors:
593
+ if hasattr(collector, 'cleanup'):
594
+ try:
595
+ collector.cleanup()
596
+ except Exception as e:
597
+ logger.warning(f"Collector cleanup failed: {e}")
598
+
599
+
600
+ MonitoringAspect = FunctionMonitor()
601
+
602
+
603
+ def _make_hashable(obj):
604
+ """Convert potentially unhashable objects to a hashable representation."""
605
+ if isinstance(obj, defaultdict):
606
+ return 'defaultdict', _make_hashable(dict(obj))
607
+
608
+ elif isinstance(obj, dict):
609
+ return tuple(sorted((k, _make_hashable(v)) for k, v in obj.items()))
610
+
611
+ elif isinstance(obj, (list, set, tuple)):
612
+ return tuple(_make_hashable(v) for v in obj)
613
+
614
+ return obj