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.
- python_introspect/__init__.py +48 -0
- python_introspect/exceptions.py +21 -0
- python_introspect/signature_analyzer.py +1148 -0
- python_introspect/unified_parameter_analyzer.py +275 -0
- python_introspect-0.1.0.dist-info/METADATA +133 -0
- python_introspect-0.1.0.dist-info/RECORD +9 -0
- python_introspect-0.1.0.dist-info/WHEEL +5 -0
- python_introspect-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_introspect-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|