python-introspect 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.
@@ -0,0 +1,1148 @@
1
+ # File: python_introspect/signature_analyzer.py
2
+
3
+ import ast
4
+ import inspect
5
+ import dataclasses
6
+ import re
7
+ from typing import Any, Dict, Callable, get_type_hints, NamedTuple, Union, Optional, Type, List
8
+ from dataclasses import dataclass
9
+
10
+ # Plugin system for namespace and type resolution
11
+ # External packages can register their own providers/resolvers
12
+ _namespace_providers: List[Callable[[], Dict[str, Any]]] = []
13
+ _type_resolvers: List[Callable[[type], Optional[type]]] = []
14
+
15
+
16
+ def register_namespace_provider(provider: Callable[[], Dict[str, Any]]) -> None:
17
+ """Register a namespace provider for forward reference resolution.
18
+
19
+ Args:
20
+ provider: A callable that returns a dict of names to types/values
21
+ to be used when resolving forward references in type hints.
22
+
23
+ Example:
24
+ def my_namespace_provider():
25
+ import mypackage
26
+ return vars(mypackage)
27
+
28
+ register_namespace_provider(my_namespace_provider)
29
+ """
30
+ _namespace_providers.append(provider)
31
+
32
+
33
+ def register_type_resolver(resolver: Callable[[type], Optional[type]]) -> None:
34
+ """Register a type resolver for custom type transformations.
35
+
36
+ Args:
37
+ resolver: A callable that takes a type and returns either:
38
+ - A transformed type (e.g., LazyConfig -> Config)
39
+ - None to defer to other resolvers
40
+
41
+ Example:
42
+ def my_type_resolver(t):
43
+ if t.__name__.startswith('Lazy'):
44
+ return get_base_type(t)
45
+ return None
46
+
47
+ register_type_resolver(my_type_resolver)
48
+ """
49
+ _type_resolvers.append(resolver)
50
+
51
+
52
+ def _get_registered_namespaces() -> Dict[str, Any]:
53
+ """Get all registered namespaces merged together."""
54
+ result = {}
55
+ for provider in _namespace_providers:
56
+ try:
57
+ namespace = provider()
58
+ if namespace:
59
+ result.update(namespace)
60
+ except Exception:
61
+ # Silently skip providers that fail
62
+ pass
63
+ return result
64
+
65
+
66
+ def _resolve_type(t: type) -> type:
67
+ """Resolve a type using registered type resolvers.
68
+
69
+ Args:
70
+ t: The type to resolve
71
+
72
+ Returns:
73
+ The resolved type, or the original type if no resolver handled it
74
+ """
75
+ for resolver in _type_resolvers:
76
+ try:
77
+ resolved = resolver(t)
78
+ if resolved is not None:
79
+ return resolved
80
+ except Exception:
81
+ # Silently skip resolvers that fail
82
+ pass
83
+ return t
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class AnalysisConstants:
88
+ """Constants for signature analysis to eliminate magic strings."""
89
+ INIT_METHOD_SUFFIX: str = ".__init__"
90
+ SELF_PARAM: str = "self"
91
+ CLS_PARAM: str = "cls"
92
+ DUNDER_PREFIX: str = "__"
93
+ DUNDER_SUFFIX: str = "__"
94
+
95
+
96
+ # Create constants instance for use throughout the module
97
+ CONSTANTS = AnalysisConstants()
98
+
99
+
100
+ class ParameterInfo(NamedTuple):
101
+ """Information about a parameter."""
102
+ name: str
103
+ param_type: type
104
+ default_value: Any
105
+ is_required: bool
106
+ description: Optional[str] = None # Add parameter description from docstring
107
+
108
+ class DocstringInfo(NamedTuple):
109
+ """Information extracted from a docstring."""
110
+ summary: Optional[str] = None # First line or brief description
111
+ description: Optional[str] = None # Full description
112
+ parameters: Optional[Dict[str, str]] = None # Parameter name -> description mapping (None = empty)
113
+ returns: Optional[str] = None # Return value description
114
+ examples: Optional[str] = None # Usage examples
115
+
116
+ @property
117
+ def parameters_dict(self) -> Dict[str, str]:
118
+ """Get parameters as a dict, never None."""
119
+ return self.parameters if self.parameters is not None else {}
120
+
121
+ class DocstringExtractor:
122
+ """Extract structured information from docstrings."""
123
+
124
+ @staticmethod
125
+ def extract(target: Union[Callable, type]) -> DocstringInfo:
126
+ """Extract docstring information from function or class.
127
+
128
+ Args:
129
+ target: Function, method, or class to extract docstring from
130
+
131
+ Returns:
132
+ DocstringInfo with parsed docstring components
133
+ """
134
+ if not target:
135
+ return DocstringInfo(parameters={})
136
+
137
+ # ENHANCEMENT: Handle lazy dataclasses by extracting from their base class
138
+ actual_target = DocstringExtractor._resolve_lazy_target(target)
139
+
140
+ docstring = inspect.getdoc(actual_target)
141
+ if not docstring:
142
+ return DocstringInfo(parameters={})
143
+
144
+ # Try AST-based parsing first for better accuracy
145
+ try:
146
+ return DocstringExtractor._parse_docstring_ast(actual_target, docstring)
147
+ except Exception:
148
+ # Fall back to regex-based parsing
149
+ return DocstringExtractor._parse_docstring(docstring)
150
+
151
+ @staticmethod
152
+ def _resolve_lazy_target(target: Union[Callable, type]) -> Union[Callable, type]:
153
+ """Resolve lazy dataclass to its base class for docstring extraction.
154
+
155
+ Lazy dataclasses are dynamically created and may not have proper docstrings.
156
+ This method attempts to find the original base class that the lazy class
157
+ was created from.
158
+ """
159
+ if not hasattr(target, '__name__'):
160
+ return target
161
+
162
+ # Check if this looks like a lazy dataclass (starts with "Lazy")
163
+ if target.__name__.startswith('Lazy'):
164
+ # Try to find the base class in the MRO
165
+ for base in getattr(target, '__mro__', []):
166
+ if base != target and base.__name__ != 'object':
167
+ # Found a base class that's not the lazy class itself
168
+ if not base.__name__.startswith('Lazy'):
169
+ return base
170
+
171
+ return target
172
+
173
+ @staticmethod
174
+ def _parse_docstring_ast(target: Union[Callable, type], docstring: str) -> DocstringInfo:
175
+ """Parse docstring using AST for more accurate extraction.
176
+
177
+ This method uses AST to parse the source code and extract docstring
178
+ information more accurately, especially for complex multiline descriptions.
179
+ """
180
+ try:
181
+ # Get source code
182
+ source = inspect.getsource(target)
183
+ tree = ast.parse(source)
184
+
185
+ # Find the function/class node
186
+ for node in ast.walk(tree):
187
+ if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
188
+ if ast.get_docstring(node) == docstring:
189
+ return DocstringExtractor._parse_ast_docstring(node, docstring)
190
+
191
+ # Fallback to regex parsing if AST parsing fails
192
+ return DocstringExtractor._parse_docstring(docstring)
193
+
194
+ except Exception:
195
+ # Fallback to regex parsing
196
+ return DocstringExtractor._parse_docstring(docstring)
197
+
198
+ @staticmethod
199
+ def _parse_ast_docstring(node: Union[ast.FunctionDef, ast.ClassDef], docstring: str) -> DocstringInfo:
200
+ """Parse docstring from AST node with enhanced multiline support."""
201
+ # For now, use the improved regex parser
202
+ # This can be extended later with more sophisticated AST-based parsing
203
+ return DocstringExtractor._parse_docstring(docstring)
204
+
205
+ @staticmethod
206
+ def _parse_docstring(docstring: str) -> DocstringInfo:
207
+ """Parse a docstring into structured components with improved multiline support.
208
+
209
+ Supports multiple docstring formats:
210
+ - Google style (Args:, Returns:, Examples:)
211
+ - NumPy style (Parameters, Returns, Examples)
212
+ - Sphinx style (:param name:, :returns:)
213
+ - Simple format (just description)
214
+
215
+ Uses improved parsing for multiline parameter descriptions that continues
216
+ until a blank line or new parameter/section is encountered.
217
+ """
218
+ lines = docstring.strip().split('\n')
219
+
220
+ summary = None
221
+ description_lines = []
222
+ parameters = {}
223
+ returns = None
224
+ examples = None
225
+
226
+ current_section = 'description'
227
+ current_param = None
228
+ current_param_lines = []
229
+
230
+ def _finalize_current_param():
231
+ """Finalize the current parameter description."""
232
+ if current_param and current_param_lines:
233
+ param_desc = '\n'.join(current_param_lines).strip()
234
+ parameters[current_param] = param_desc
235
+
236
+ for i, line in enumerate(lines):
237
+ original_line = line
238
+ line = line.strip()
239
+
240
+ # Handle both Google/Sphinx style (with colons) and NumPy style (without colons)
241
+ if line.lower() in ('args:', 'arguments:', 'parameters:'):
242
+ _finalize_current_param()
243
+ current_param = None
244
+ current_param_lines = []
245
+ current_section = 'parameters'
246
+ if i + 1 < len(lines) and lines[i+1].strip().startswith('---'): # Skip NumPy style separator
247
+ continue
248
+ continue
249
+ elif line.lower() in ('args', 'arguments', 'parameters') and i + 1 < len(lines) and lines[i+1].strip().startswith('-'):
250
+ # NumPy-style section headers (without colons, followed by dashes)
251
+ _finalize_current_param()
252
+ current_param = None
253
+ current_param_lines = []
254
+ current_section = 'parameters'
255
+ continue
256
+ elif line.lower() in ('returns:', 'return:'):
257
+ _finalize_current_param()
258
+ current_param = None
259
+ current_param_lines = []
260
+ current_section = 'returns'
261
+ if i + 1 < len(lines) and lines[i+1].strip().startswith('---'): # Skip NumPy style separator
262
+ continue
263
+ continue
264
+ elif line.lower() in ('returns', 'return') and i + 1 < len(lines) and lines[i+1].strip().startswith('-'):
265
+ # NumPy-style returns section
266
+ _finalize_current_param()
267
+ current_param = None
268
+ current_param_lines = []
269
+ current_section = 'returns'
270
+ continue
271
+ elif line.lower() in ('examples:', 'example:'):
272
+ _finalize_current_param()
273
+ current_param = None
274
+ current_param_lines = []
275
+ current_section = 'examples'
276
+ if i + 1 < len(lines) and lines[i+1].strip().startswith('---'): # Skip NumPy style separator
277
+ continue
278
+ continue
279
+ elif line.lower() in ('examples', 'example') and i + 1 < len(lines) and lines[i+1].strip().startswith('-'):
280
+ # NumPy-style examples section
281
+ _finalize_current_param()
282
+ current_param = None
283
+ current_param_lines = []
284
+ current_section = 'examples'
285
+ continue
286
+
287
+ if current_section == 'description':
288
+ if not summary and line:
289
+ summary = line
290
+ else:
291
+ description_lines.append(original_line) # Keep original indentation
292
+
293
+ elif current_section == 'parameters':
294
+ # Enhanced parameter parsing to handle multiple formats
295
+ param_match_google = re.match(r'^(\w+):\s*(.+)', line)
296
+ param_match_sphinx = re.match(r'^:param\s+(\w+):\s*(.+)', line)
297
+ param_match_numpy = re.match(r'^(\w+)\s*:\s*(.+)', line)
298
+ # New: Handle pyclesperanto-style inline parameters (param_name: type description)
299
+ param_match_inline = re.match(r'^(\w+):\s*(\w+(?:\[.*?\])?|\w+(?:\s*\|\s*\w+)*)\s+(.+)', line)
300
+ # New: Handle parameters that start with bullet points or dashes
301
+ param_match_bullet = re.match(r'^[-•*]\s*(\w+):\s*(.+)', line)
302
+
303
+ if param_match_google or param_match_sphinx or param_match_numpy or param_match_inline or param_match_bullet:
304
+ _finalize_current_param()
305
+
306
+ if param_match_google:
307
+ param_name, param_desc = param_match_google.groups()
308
+ elif param_match_sphinx:
309
+ param_name, param_desc = param_match_sphinx.groups()
310
+ elif param_match_numpy:
311
+ param_name, param_desc = param_match_numpy.groups()
312
+ elif param_match_inline:
313
+ param_name, param_type, param_desc = param_match_inline.groups()
314
+ param_desc = f"{param_type} - {param_desc}" # Include type in description
315
+ elif param_match_bullet:
316
+ param_name, param_desc = param_match_bullet.groups()
317
+
318
+ current_param = param_name
319
+ current_param_lines = [param_desc.strip()]
320
+ elif current_param and (original_line.startswith(' ') or original_line.startswith('\t')):
321
+ # Indented continuation line
322
+ current_param_lines.append(line)
323
+ elif not line:
324
+ _finalize_current_param()
325
+ current_param = None
326
+ current_param_lines = []
327
+ elif current_param:
328
+ # Non-indented continuation line (part of the same block)
329
+ current_param_lines.append(line)
330
+ else:
331
+ # Try to parse inline parameter definitions in a single block
332
+ # This handles cases where parameters are listed without clear separation
333
+ inline_params = DocstringExtractor._parse_inline_parameters(line)
334
+ for param_name, param_desc in inline_params.items():
335
+ parameters[param_name] = param_desc
336
+
337
+ elif current_section == 'returns':
338
+ if returns is None:
339
+ returns = line
340
+ else:
341
+ returns += '\n' + line
342
+
343
+ elif current_section == 'examples':
344
+ if examples is None:
345
+ examples = line
346
+ else:
347
+ examples += '\n' + line
348
+
349
+ _finalize_current_param()
350
+
351
+ description = '\n'.join(description_lines).strip()
352
+ if description == summary:
353
+ description = None
354
+ # Treat empty string as None for cleaner API
355
+ if description == '':
356
+ description = None
357
+
358
+ return DocstringInfo(
359
+ summary=summary,
360
+ description=description,
361
+ parameters=parameters if parameters else {}, # Always return dict, never None
362
+ returns=returns,
363
+ examples=examples
364
+ ) if summary or description or parameters or returns or examples else DocstringInfo(parameters={})
365
+
366
+ @staticmethod
367
+ def _parse_inline_parameters(line: str) -> Dict[str, str]:
368
+ """Parse parameters from a single line containing multiple parameter definitions.
369
+
370
+ Handles formats like:
371
+ - "input_image: Image Input image to process. footprint: Image Structuring element..."
372
+ - "param1: type1 description1. param2: type2 description2."
373
+ """
374
+ parameters = {}
375
+
376
+ import re
377
+
378
+ # Strategy: Use a flexible pattern that works with the pyclesperanto format
379
+ # Pattern matches: param_name: everything up to the next param_name: or end of string
380
+ param_pattern = r'(\w+):\s*([^:]*?)(?=\s+\w+:|$)'
381
+ matches = re.findall(param_pattern, line)
382
+
383
+ for param_name, param_desc in matches:
384
+ if param_desc.strip():
385
+ # Clean up the description (remove trailing periods, extra whitespace)
386
+ clean_desc = param_desc.strip().rstrip('.')
387
+ parameters[param_name] = clean_desc
388
+
389
+ return parameters
390
+
391
+
392
+ class SignatureAnalyzer:
393
+ """Universal analyzer for extracting parameter information from any target."""
394
+
395
+ # Class-level cache for field documentation to avoid re-parsing
396
+ _field_docs_cache = {}
397
+
398
+ # Class-level cache for dataclass analysis results to avoid expensive AST parsing
399
+ _dataclass_analysis_cache = {}
400
+
401
+ @staticmethod
402
+ def analyze(target: Union[Callable, Type, object], skip_first_param: Optional[bool] = None) -> Dict[str, ParameterInfo]:
403
+ """Extract parameter information from any target: function, constructor, dataclass, or instance.
404
+
405
+ Args:
406
+ target: Function, constructor, dataclass type, or dataclass instance
407
+ skip_first_param: Whether to skip the first parameter (after self/cls).
408
+ If None, auto-detects based on context:
409
+ - False for step constructors (all params are configuration)
410
+ - True for image processing functions (first param is image data)
411
+
412
+ Returns:
413
+ Dict mapping parameter names to ParameterInfo
414
+ """
415
+ if not target:
416
+ return {}
417
+
418
+ # Dispatch based on target type
419
+ if inspect.isclass(target):
420
+ if dataclasses.is_dataclass(target):
421
+ return SignatureAnalyzer._analyze_dataclass(target)
422
+ else:
423
+ # Try to analyze constructor
424
+ return SignatureAnalyzer._analyze_callable(target.__init__, skip_first_param)
425
+ elif dataclasses.is_dataclass(target):
426
+ # Instance of dataclass
427
+ return SignatureAnalyzer._analyze_dataclass_instance(target)
428
+ else:
429
+ # Function, method, or other callable
430
+ return SignatureAnalyzer._analyze_callable(target, skip_first_param)
431
+
432
+ @staticmethod
433
+ def _analyze_callable(callable_obj: Callable, skip_first_param: Optional[bool] = None) -> Dict[str, ParameterInfo]:
434
+ """Extract parameter information from callable signature.
435
+
436
+ Args:
437
+ callable_obj: The callable to analyze
438
+ skip_first_param: Whether to skip the first parameter (after self/cls).
439
+ If None, auto-detects based on context.
440
+ """
441
+ sig = inspect.signature(callable_obj)
442
+ # Build comprehensive namespace for forward reference resolution
443
+ # Start with registered namespaces, then add function's globals
444
+ globalns = {
445
+ **_get_registered_namespaces(),
446
+ **getattr(callable_obj, '__globals__', {})
447
+ }
448
+
449
+ # For functions with a module, prioritize the function's actual module globals
450
+ if hasattr(callable_obj, '__module__') and callable_obj.__module__:
451
+ try:
452
+ import sys
453
+ actual_module = sys.modules.get(callable_obj.__module__)
454
+ if actual_module:
455
+ # Function's module globals should take precedence for type resolution
456
+ globalns = {
457
+ **_get_registered_namespaces(),
458
+ **vars(actual_module) # This overwrites with the actual module types
459
+ }
460
+ except Exception:
461
+ pass # Fall back to original globalns
462
+
463
+ import logging
464
+ logger = logging.getLogger(__name__)
465
+
466
+ try:
467
+ type_hints = get_type_hints(callable_obj, globalns=globalns)
468
+ logger.debug(f"🔍 SIG ANALYZER: get_type_hints succeeded for {callable_obj.__name__}: {type_hints}")
469
+ except (NameError, AttributeError) as e:
470
+ # If type hint resolution fails, try with just the function's original globals
471
+ try:
472
+ type_hints = get_type_hints(callable_obj, globalns=getattr(callable_obj, '__globals__', {}))
473
+ logger.debug(f"🔍 SIG ANALYZER: get_type_hints with __globals__ succeeded for {callable_obj.__name__}: {type_hints}")
474
+ except:
475
+ # If that still fails, fall back to __annotations__ directly
476
+ # This is critical for functions where type hints were added via docstring parsing
477
+ # (e.g., cucim functions where _enhance_annotations_from_docstring added types)
478
+ type_hints = getattr(callable_obj, '__annotations__', {})
479
+ logger.debug(f"🔍 SIG ANALYZER: Fell back to __annotations__ for {callable_obj.__name__}: {type_hints}")
480
+ except Exception as ex:
481
+ # For any other type hint resolution errors, fall back to __annotations__
482
+ # This ensures we don't lose type information that was added programmatically
483
+ type_hints = getattr(callable_obj, '__annotations__', {})
484
+ logger.debug(f"🔍 SIG ANALYZER: Exception {ex}, fell back to __annotations__ for {callable_obj.__name__}: {type_hints}")
485
+
486
+
487
+
488
+ # Extract docstring information (with fallback for robustness)
489
+ try:
490
+ docstring_info = DocstringExtractor.extract(callable_obj)
491
+ except:
492
+ docstring_info = None
493
+
494
+ if not docstring_info:
495
+ docstring_info = DocstringInfo()
496
+
497
+ parameters = {}
498
+ param_list = list(sig.parameters.items())
499
+
500
+ # Determine skip behavior: explicit parameter overrides auto-detection
501
+ should_skip_first_param = (
502
+ skip_first_param if skip_first_param is not None
503
+ else SignatureAnalyzer._should_skip_first_parameter(callable_obj)
504
+ )
505
+
506
+ first_param_after_self_skipped = False
507
+
508
+ for i, (param_name, param) in enumerate(param_list):
509
+ # Always skip self/cls
510
+ if param_name in (CONSTANTS.SELF_PARAM, CONSTANTS.CLS_PARAM):
511
+ continue
512
+
513
+ # Always skip dunder parameters (internal/reserved fields)
514
+ if param_name.startswith(CONSTANTS.DUNDER_PREFIX) and param_name.endswith(CONSTANTS.DUNDER_SUFFIX):
515
+ continue
516
+
517
+ # Skip first parameter for image processing functions only
518
+ if should_skip_first_param and not first_param_after_self_skipped:
519
+ first_param_after_self_skipped = True
520
+ continue
521
+
522
+ # Handle **kwargs parameters - try to extract original function signature
523
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
524
+ # Try to find the original function if this is a wrapper
525
+ original_params = SignatureAnalyzer._extract_original_parameters(callable_obj)
526
+ if original_params:
527
+ parameters.update(original_params)
528
+ continue
529
+
530
+ from typing import Any
531
+ param_type = type_hints.get(param_name, Any)
532
+ default_value = param.default if param.default != inspect.Parameter.empty else None
533
+ is_required = param.default == inspect.Parameter.empty
534
+
535
+
536
+
537
+ # Get parameter description from docstring
538
+ param_description = (
539
+ docstring_info.parameters.get(param_name)
540
+ if docstring_info and docstring_info.parameters
541
+ else None
542
+ )
543
+
544
+ parameters[param_name] = ParameterInfo(
545
+ name=param_name,
546
+ param_type=param_type,
547
+ default_value=default_value,
548
+ is_required=is_required,
549
+ description=param_description
550
+ )
551
+
552
+ return parameters
553
+
554
+ @staticmethod
555
+ def _should_skip_first_parameter(callable_obj: Callable) -> bool:
556
+ """
557
+ Determine if the first parameter should be skipped for any callable.
558
+
559
+ Universal logic that works with any object:
560
+ - Constructors (__init__ methods): don't skip (all params are configuration)
561
+ - Regular functions: don't skip (by default, analyze all parameters)
562
+
563
+ Note: This was originally designed for image processing functions where the
564
+ first parameter is typically the input image. For general-purpose use,
565
+ we default to NOT skipping parameters unless explicitly requested via
566
+ skip_first_param parameter.
567
+ """
568
+ # By default, don't skip any parameters for general-purpose introspection
569
+ return False
570
+
571
+ @staticmethod
572
+ def _extract_original_parameters(callable_obj: Callable) -> Dict[str, ParameterInfo]:
573
+ """
574
+ Extract parameters from the original function if this is a wrapper with **kwargs.
575
+
576
+ This handles cases where scikit-image or other auto-registered functions
577
+ are wrapped with (image, **kwargs) signatures.
578
+ """
579
+ try:
580
+ # Check if this function has access to the original function
581
+ # Common patterns: __wrapped__, closure variables, etc.
582
+
583
+ # Pattern 1: Check if it's a functools.wraps wrapper
584
+ if hasattr(callable_obj, '__wrapped__'):
585
+ return SignatureAnalyzer._analyze_callable(callable_obj.__wrapped__)
586
+
587
+ # Pattern 2: Check closure for original function reference
588
+ if hasattr(callable_obj, '__closure__') and callable_obj.__closure__:
589
+ for cell in callable_obj.__closure__:
590
+ if hasattr(cell.cell_contents, '__call__'):
591
+ # Found a callable in closure - might be the original function
592
+ try:
593
+ orig_sig = inspect.signature(cell.cell_contents)
594
+ # Skip if it also has **kwargs (avoid infinite recursion)
595
+ if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in orig_sig.parameters.values()):
596
+ continue
597
+ return SignatureAnalyzer._analyze_callable(cell.cell_contents)
598
+ except:
599
+ continue
600
+
601
+ # Pattern 3: Try to extract from function name and module
602
+ # This is a fallback for scikit-image functions
603
+ if hasattr(callable_obj, '__name__') and hasattr(callable_obj, '__module__'):
604
+ func_name = callable_obj.__name__
605
+ module_name = callable_obj.__module__
606
+
607
+ # Try to find the original function in scikit-image
608
+ if 'skimage' in module_name:
609
+ try:
610
+ import importlib
611
+ # Extract the actual module path (remove wrapper module parts)
612
+ if 'scikit_image_registry' in module_name:
613
+ # This is our wrapper, try to find the original in skimage
614
+ for skimage_module in ['skimage.filters', 'skimage.morphology',
615
+ 'skimage.segmentation', 'skimage.feature',
616
+ 'skimage.measure', 'skimage.transform',
617
+ 'skimage.restoration', 'skimage.exposure']:
618
+ try:
619
+ mod = importlib.import_module(skimage_module)
620
+ if hasattr(mod, func_name):
621
+ orig_func = getattr(mod, func_name)
622
+ return SignatureAnalyzer._analyze_callable(orig_func)
623
+ except:
624
+ continue
625
+ except:
626
+ pass
627
+
628
+ return {}
629
+
630
+ except Exception:
631
+ return {}
632
+
633
+ @staticmethod
634
+ def _analyze_dataclass(dataclass_type: type) -> Dict[str, ParameterInfo]:
635
+ """Extract parameter information from dataclass fields."""
636
+ import logging
637
+ logger = logging.getLogger(__name__)
638
+
639
+ # PERFORMANCE: Check cache first to avoid expensive AST parsing
640
+ # Use the class object itself as the key (classes are hashable and have stable identity)
641
+ cache_key = dataclass_type
642
+ if cache_key in SignatureAnalyzer._dataclass_analysis_cache:
643
+ logger.info(f"✅ CACHE HIT for {dataclass_type.__name__} (id={id(dataclass_type)})")
644
+ return SignatureAnalyzer._dataclass_analysis_cache[cache_key]
645
+
646
+ logger.info(f"❌ CACHE MISS for {dataclass_type.__name__} (id={id(dataclass_type)}), cache has {len(SignatureAnalyzer._dataclass_analysis_cache)} entries")
647
+
648
+ try:
649
+ # Try to get type hints, fall back to __annotations__ if resolution fails
650
+ try:
651
+ type_hints = get_type_hints(dataclass_type)
652
+ except Exception:
653
+ # Fall back to __annotations__ for robustness
654
+ type_hints = getattr(dataclass_type, '__annotations__', {})
655
+
656
+ # Extract docstring information from dataclass
657
+ docstring_info = DocstringExtractor.extract(dataclass_type)
658
+
659
+ # Extract inline field documentation using AST
660
+ inline_docs = SignatureAnalyzer._extract_inline_field_docs(dataclass_type)
661
+
662
+ # ENHANCEMENT: For dataclasses modified by decorators (like GlobalPipelineConfig),
663
+ # also extract field documentation from the field types themselves
664
+ field_type_docs = SignatureAnalyzer._extract_field_type_docs(dataclass_type)
665
+
666
+ parameters = {}
667
+
668
+ for field in dataclasses.fields(dataclass_type):
669
+ # Skip dunder fields (internal/reserved fields)
670
+ if field.name.startswith(CONSTANTS.DUNDER_PREFIX) and field.name.endswith(CONSTANTS.DUNDER_SUFFIX):
671
+ continue
672
+
673
+ param_type = type_hints.get(field.name, str)
674
+
675
+ # Get default value
676
+ if field.default != dataclasses.MISSING:
677
+ default_value = field.default
678
+ is_required = False
679
+ elif field.default_factory != dataclasses.MISSING:
680
+ default_value = field.default_factory()
681
+ is_required = False
682
+ else:
683
+ default_value = None
684
+ is_required = True
685
+
686
+ # Get field description from multiple sources (priority order)
687
+ field_description = None
688
+
689
+ # 1. Field metadata (highest priority)
690
+ if hasattr(field, 'metadata') and 'description' in field.metadata:
691
+ field_description = field.metadata['description']
692
+ # 2. Inline documentation strings (from AST parsing)
693
+ elif field.name in inline_docs:
694
+ field_description = inline_docs[field.name]
695
+ # 3. Field type documentation (for decorator-modified classes)
696
+ elif field.name in field_type_docs:
697
+ field_description = field_type_docs[field.name]
698
+ # 4. Docstring parameters (fallback)
699
+ elif docstring_info.parameters and field.name in docstring_info.parameters:
700
+ field_description = docstring_info.parameters.get(field.name)
701
+ # 5. CRITICAL FIX: Use inheritance-aware field documentation extraction
702
+ else:
703
+ field_description = SignatureAnalyzer.extract_field_documentation(dataclass_type, field.name)
704
+
705
+ parameters[field.name] = ParameterInfo(
706
+ name=field.name,
707
+ param_type=param_type,
708
+ default_value=default_value,
709
+ is_required=is_required,
710
+ description=field_description
711
+ )
712
+
713
+ # PERFORMANCE: Cache the result to avoid re-parsing
714
+ SignatureAnalyzer._dataclass_analysis_cache[cache_key] = parameters
715
+ return parameters
716
+
717
+ except Exception:
718
+ # Return empty dict on error (don't cache errors)
719
+ return {}
720
+
721
+ @staticmethod
722
+ def _extract_inline_field_docs(dataclass_type: type) -> Dict[str, str]:
723
+ """Extract inline field documentation strings using AST parsing.
724
+
725
+ This handles multiple patterns used for field documentation:
726
+
727
+ Pattern 1 - Next line string literal:
728
+ @dataclass
729
+ class Config:
730
+ field_name: str = "default"
731
+ '''Field description here.'''
732
+
733
+ Pattern 2 - Same line string literal (less common):
734
+ @dataclass
735
+ class Config:
736
+ field_name: str = "default" # '''Field description'''
737
+
738
+ Pattern 3 - Traditional docstring parameters (handled by DocstringExtractor):
739
+ @dataclass
740
+ class Config:
741
+ '''
742
+ Args:
743
+ field_name: Field description here.
744
+ '''
745
+ field_name: str = "default"
746
+ """
747
+ try:
748
+ import ast
749
+ import re
750
+
751
+ # Try to get source code - handle cases where it might not be available
752
+ source = None
753
+ try:
754
+ source = inspect.getsource(dataclass_type)
755
+ except (OSError, TypeError):
756
+ # ENHANCEMENT: For decorator-modified classes, try multiple source file strategies
757
+ try:
758
+ # Strategy 1: Try the file where the class is currently defined
759
+ source_file = inspect.getfile(dataclass_type)
760
+ with open(source_file, 'r', encoding='utf-8') as f:
761
+ file_content = f.read()
762
+ source = SignatureAnalyzer._extract_class_source_from_file(file_content, dataclass_type.__name__)
763
+
764
+ # Strategy 2: If that fails, try to find the original source file
765
+ # This handles decorator-modified classes where inspect.getfile() returns the wrong file
766
+ if not source:
767
+ try:
768
+ import os
769
+ source_dir = os.path.dirname(source_file)
770
+
771
+ # Try common source files in the same directory
772
+ candidate_files = []
773
+
774
+ # If the current file is lazy_config.py, try config.py
775
+ if source_file.endswith('lazy_config.py'):
776
+ candidate_files.append(os.path.join(source_dir, 'config.py'))
777
+
778
+ # Try other common patterns
779
+ for filename in os.listdir(source_dir):
780
+ if filename.endswith('.py') and filename != os.path.basename(source_file):
781
+ candidate_files.append(os.path.join(source_dir, filename))
782
+
783
+ # Try each candidate file
784
+ for candidate_file in candidate_files:
785
+ if os.path.exists(candidate_file):
786
+ with open(candidate_file, 'r', encoding='utf-8') as f:
787
+ candidate_content = f.read()
788
+ source = SignatureAnalyzer._extract_class_source_from_file(candidate_content, dataclass_type.__name__)
789
+ if source: # Found it!
790
+ break
791
+ except Exception:
792
+ pass
793
+ except Exception:
794
+ pass
795
+
796
+ if not source:
797
+ return {}
798
+
799
+ tree = ast.parse(source)
800
+
801
+ # Find the class definition - be more flexible with class name matching
802
+ class_node = None
803
+ target_class_name = dataclass_type.__name__
804
+
805
+ # Handle cases where the class might have been renamed or modified
806
+ for node in ast.walk(tree):
807
+ if isinstance(node, ast.ClassDef):
808
+ # Try exact match first
809
+ if node.name == target_class_name:
810
+ class_node = node
811
+ break
812
+ # Also try without common prefixes/suffixes that decorators might add
813
+ base_name = target_class_name.replace('Lazy', '').replace('Config', '')
814
+ node_base_name = node.name.replace('Lazy', '').replace('Config', '')
815
+ if base_name and node_base_name and base_name == node_base_name:
816
+ class_node = node
817
+ break
818
+
819
+ if not class_node:
820
+ return {}
821
+
822
+ field_docs = {}
823
+ source_lines = source.split('\n')
824
+
825
+ # Method 1: Look for field assignments followed by string literals (next line)
826
+ for i, node in enumerate(class_node.body):
827
+ if isinstance(node, ast.AnnAssign) and hasattr(node.target, 'id'):
828
+ field_name = node.target.id
829
+
830
+ # Check if the next node is a string literal (documentation)
831
+ if i + 1 < len(class_node.body):
832
+ next_node = class_node.body[i + 1]
833
+ if isinstance(next_node, ast.Expr):
834
+ # Handle both ast.Constant (Python 3.8+) and ast.Str (older versions)
835
+ if isinstance(next_node.value, ast.Constant) and isinstance(next_node.value.value, str):
836
+ field_docs[field_name] = next_node.value.value.strip()
837
+ continue
838
+ elif hasattr(ast, 'Str') and isinstance(next_node.value, ast.Str):
839
+ field_docs[field_name] = next_node.value.s.strip()
840
+ continue
841
+
842
+ # Method 2: Check for inline comments on the same line
843
+ # Get the line number of the field definition
844
+ field_line_num = node.lineno - 1 # Convert to 0-based indexing
845
+ if 0 <= field_line_num < len(source_lines):
846
+ line = source_lines[field_line_num]
847
+
848
+ # Look for string literals in comments on the same line
849
+ # Pattern: field: type = value # """Documentation"""
850
+ comment_match = re.search(r'#\s*["\']([^"\']+)["\']', line)
851
+ if comment_match:
852
+ field_docs[field_name] = comment_match.group(1).strip()
853
+ continue
854
+
855
+ # Look for triple-quoted strings on the same line
856
+ # Pattern: field: type = value """Documentation"""
857
+ triple_quote_match = re.search(r'"""([^"]+)"""|\'\'\'([^\']+)\'\'\'', line)
858
+ if triple_quote_match:
859
+ doc_text = triple_quote_match.group(1) or triple_quote_match.group(2)
860
+ field_docs[field_name] = doc_text.strip()
861
+
862
+ return field_docs
863
+
864
+ except Exception as e:
865
+ # Return empty dict if AST parsing fails
866
+ # Could add logging here for debugging: logger.debug(f"AST parsing failed: {e}")
867
+ return {}
868
+
869
+ @staticmethod
870
+ def _extract_field_type_docs(dataclass_type: type) -> Dict[str, str]:
871
+ """Extract field documentation from field types for decorator-modified dataclasses.
872
+
873
+ This handles cases where dataclasses have been modified by decorators (like @auto_create_decorator)
874
+ that inject fields from other dataclasses. In such cases, the AST parsing of the main class
875
+ won't find documentation for the injected fields, so we need to extract documentation from
876
+ the field types themselves.
877
+
878
+ For example, GlobalPipelineConfig has injected fields like 'path_planning_config' of type
879
+ PathPlanningConfig. We extract the class docstring from PathPlanningConfig to use as the
880
+ field description.
881
+ """
882
+ try:
883
+ import dataclasses
884
+
885
+ field_type_docs = {}
886
+
887
+ # Get all dataclass fields
888
+ if not dataclasses.is_dataclass(dataclass_type):
889
+ return {}
890
+
891
+ fields = dataclasses.fields(dataclass_type)
892
+
893
+ for field in fields:
894
+ # Check if this field's type is a dataclass
895
+ field_type = field.type
896
+
897
+ # Handle Optional types
898
+ if hasattr(field_type, '__origin__') and field_type.__origin__ is Union:
899
+ # Extract the non-None type from Optional[T]
900
+ args = field_type.__args__
901
+ non_none_types = [arg for arg in args if arg is not type(None)]
902
+ if len(non_none_types) == 1:
903
+ field_type = non_none_types[0]
904
+
905
+ # If the field type is a dataclass, extract its docstring as field documentation
906
+ if dataclasses.is_dataclass(field_type):
907
+ # ENHANCEMENT: Resolve lazy dataclasses to their base classes for documentation
908
+ resolved_field_type = SignatureAnalyzer._resolve_lazy_dataclass_for_docs(field_type)
909
+
910
+ docstring_info = DocstringExtractor.extract(resolved_field_type)
911
+ if docstring_info.summary:
912
+ field_type_docs[field.name] = docstring_info.summary
913
+ elif docstring_info.description:
914
+ # Use first line of description if no summary
915
+ first_line = docstring_info.description.split('\n')[0].strip()
916
+ if first_line:
917
+ field_type_docs[field.name] = first_line
918
+
919
+ return field_type_docs
920
+
921
+ except Exception as e:
922
+ # Return empty dict if extraction fails
923
+ return {}
924
+
925
+ @staticmethod
926
+ def _extract_class_source_from_file(file_content: str, class_name: str) -> Optional[str]:
927
+ """Extract the source code for a specific class from a file.
928
+
929
+ This method is used when inspect.getsource() fails (e.g., for decorator-modified classes)
930
+ to extract the class definition directly from the source file.
931
+
932
+ Args:
933
+ file_content: The content of the source file
934
+ class_name: The name of the class to extract
935
+
936
+ Returns:
937
+ The source code for the class, or None if not found
938
+ """
939
+ try:
940
+ lines = file_content.split('\n')
941
+ class_lines = []
942
+ in_class = False
943
+ class_indent = 0
944
+
945
+ for line in lines:
946
+ # Look for the class definition
947
+ if line.strip().startswith(f'class {class_name}'):
948
+ in_class = True
949
+ class_indent = len(line) - len(line.lstrip())
950
+ class_lines.append(line)
951
+ elif in_class:
952
+ # Check if we've reached the end of the class
953
+ if line.strip() and not line.startswith(' ') and not line.startswith('\t'):
954
+ # Non-indented line that's not empty - end of class
955
+ break
956
+ elif line.strip() and len(line) - len(line.lstrip()) <= class_indent:
957
+ # Line at same or less indentation than class - end of class
958
+ break
959
+ else:
960
+ # Still inside the class
961
+ class_lines.append(line)
962
+
963
+ if class_lines:
964
+ return '\n'.join(class_lines)
965
+ return None
966
+
967
+ except Exception:
968
+ return None
969
+
970
+ @staticmethod
971
+ def extract_field_documentation(dataclass_type: type, field_name: str) -> Optional[str]:
972
+ """Extract documentation for a specific field from a dataclass.
973
+
974
+ This method tries multiple approaches to find documentation for a specific field:
975
+ 1. Inline field documentation (AST parsing)
976
+ 2. Field type documentation (for nested dataclasses)
977
+ 3. Docstring parameters
978
+ 4. Field metadata
979
+
980
+ Args:
981
+ dataclass_type: The dataclass type containing the field
982
+ field_name: Name of the field to get documentation for
983
+
984
+ Returns:
985
+ Field documentation string, or None if not found
986
+ """
987
+ try:
988
+ import dataclasses
989
+
990
+ if not dataclasses.is_dataclass(dataclass_type):
991
+ return None
992
+
993
+ # ENHANCEMENT: Resolve lazy dataclasses to their base classes
994
+ # PipelineConfig should resolve to GlobalPipelineConfig for documentation
995
+ resolved_type = SignatureAnalyzer._resolve_lazy_dataclass_for_docs(dataclass_type)
996
+
997
+ # Check cache first for performance
998
+ cache_key = (resolved_type.__name__, resolved_type.__module__)
999
+ if cache_key not in SignatureAnalyzer._field_docs_cache:
1000
+ # Extract all field documentation for this dataclass and cache it
1001
+ SignatureAnalyzer._field_docs_cache[cache_key] = SignatureAnalyzer._extract_all_field_docs(resolved_type)
1002
+
1003
+ cached_docs = SignatureAnalyzer._field_docs_cache[cache_key]
1004
+ if field_name in cached_docs:
1005
+ return cached_docs[field_name]
1006
+
1007
+ return None
1008
+
1009
+ except Exception:
1010
+ return None
1011
+
1012
+ @staticmethod
1013
+ def _resolve_lazy_dataclass_for_docs(dataclass_type: type) -> type:
1014
+ """Resolve lazy dataclasses to their base classes for documentation extraction.
1015
+
1016
+ Uses registered type resolvers to handle custom type transformations.
1017
+
1018
+ Args:
1019
+ dataclass_type: The dataclass type (potentially lazy)
1020
+
1021
+ Returns:
1022
+ The resolved dataclass type for documentation extraction
1023
+ """
1024
+ return _resolve_type(dataclass_type)
1025
+
1026
+ @staticmethod
1027
+ def _extract_all_field_docs(dataclass_type: type) -> Dict[str, str]:
1028
+ """Extract all field documentation for a dataclass and return as a dictionary.
1029
+
1030
+ This method combines all documentation extraction approaches and caches the results.
1031
+
1032
+ Args:
1033
+ dataclass_type: The dataclass type to extract documentation from
1034
+
1035
+ Returns:
1036
+ Dictionary mapping field names to their documentation
1037
+ """
1038
+ all_docs = {}
1039
+
1040
+ try:
1041
+ import dataclasses
1042
+
1043
+ # Try inline field documentation first
1044
+ inline_docs = SignatureAnalyzer._extract_inline_field_docs(dataclass_type)
1045
+ all_docs.update(inline_docs)
1046
+
1047
+ # Try field type documentation (for nested dataclasses)
1048
+ field_type_docs = SignatureAnalyzer._extract_field_type_docs(dataclass_type)
1049
+ for field_name, doc in field_type_docs.items():
1050
+ if field_name not in all_docs: # Don't overwrite inline docs
1051
+ all_docs[field_name] = doc
1052
+
1053
+ # Try docstring parameters
1054
+ docstring_info = DocstringExtractor.extract(dataclass_type)
1055
+ if docstring_info.parameters:
1056
+ for field_name, doc in docstring_info.parameters.items():
1057
+ if field_name not in all_docs: # Don't overwrite previous docs
1058
+ all_docs[field_name] = doc
1059
+
1060
+ # Try field metadata
1061
+ fields = dataclasses.fields(dataclass_type)
1062
+ for field in fields:
1063
+ if field.name not in all_docs: # Don't overwrite previous docs
1064
+ if hasattr(field, 'metadata') and 'description' in field.metadata:
1065
+ all_docs[field.name] = field.metadata['description']
1066
+
1067
+ # ENHANCEMENT: Try inheritance - check parent classes for missing field documentation
1068
+ for field in fields:
1069
+ if field.name not in all_docs: # Only for fields still missing documentation
1070
+ # Walk up the inheritance chain
1071
+ for base_class in dataclass_type.__mro__[1:]: # Skip the class itself
1072
+ if base_class == object:
1073
+ continue
1074
+ if dataclasses.is_dataclass(base_class):
1075
+ # Check if this base class has the field with documentation
1076
+ try:
1077
+ base_fields = dataclasses.fields(base_class)
1078
+ base_field_names = [f.name for f in base_fields]
1079
+ if field.name in base_field_names:
1080
+ # Try to get documentation from the base class
1081
+ inherited_doc = SignatureAnalyzer.extract_field_documentation(base_class, field.name)
1082
+ if inherited_doc:
1083
+ all_docs[field.name] = inherited_doc
1084
+ break # Found documentation, stop looking
1085
+ except Exception:
1086
+ continue # Try next base class
1087
+
1088
+ except Exception:
1089
+ pass # Return whatever we managed to extract
1090
+
1091
+ return all_docs
1092
+
1093
+ @staticmethod
1094
+ def extract_field_documentation_from_context(field_name: str, context_types: list[type]) -> Optional[str]:
1095
+ """Extract field documentation by searching through multiple dataclass types.
1096
+
1097
+ This method is useful when you don't know exactly which dataclass contains
1098
+ a field, but you have a list of candidate types to search through.
1099
+
1100
+ Args:
1101
+ field_name: Name of the field to get documentation for
1102
+ context_types: List of dataclass types to search through
1103
+
1104
+ Returns:
1105
+ Field documentation string, or None if not found
1106
+ """
1107
+ for dataclass_type in context_types:
1108
+ if dataclass_type:
1109
+ doc = SignatureAnalyzer.extract_field_documentation(dataclass_type, field_name)
1110
+ if doc:
1111
+ return doc
1112
+ return None
1113
+
1114
+ @staticmethod
1115
+ def _analyze_dataclass_instance(instance: object) -> Dict[str, ParameterInfo]:
1116
+ """Extract parameter information from a dataclass instance."""
1117
+ try:
1118
+ # Get the type and analyze it
1119
+ dataclass_type = type(instance)
1120
+ parameters = SignatureAnalyzer._analyze_dataclass(dataclass_type)
1121
+
1122
+ # Update default values with current instance values
1123
+ # For lazy dataclasses, use object.__getattribute__ to preserve None values for placeholders
1124
+ for name, param_info in parameters.items():
1125
+ if hasattr(instance, name):
1126
+ # Check if this is a lazy dataclass that should preserve None values
1127
+ if hasattr(instance, '_resolve_field_value'):
1128
+ # This is a lazy dataclass - use object.__getattribute__ to get stored value
1129
+ current_value = object.__getattribute__(instance, name)
1130
+ else:
1131
+ # Regular dataclass - use normal getattr
1132
+ current_value = getattr(instance, name)
1133
+
1134
+ # Create new ParameterInfo with current value as default
1135
+ parameters[name] = ParameterInfo(
1136
+ name=param_info.name,
1137
+ param_type=param_info.param_type,
1138
+ default_value=current_value,
1139
+ is_required=param_info.is_required,
1140
+ description=param_info.description
1141
+ )
1142
+
1143
+ return parameters
1144
+
1145
+ except Exception:
1146
+ return {}
1147
+
1148
+ # Duplicate method removed - using the fixed version above