"""Qt-free parameter help introspection shared by forms, help windows, and agents."""
from __future__ import annotations
import ast
import inspect
import logging
import re
from dataclasses import dataclass, fields, is_dataclass
from enum import Enum
from types import UnionType
from typing import Annotated, Callable, Optional, Union, get_args, get_origin
from objectstate.lazy_factory import get_base_type_for_lazy, is_lazy_dataclass
from python_introspect import DocstringExtractor, DocstringInfo, UnifiedParameterAnalyzer
logger = logging.getLogger(__name__)
NO_PARAMETER_DESCRIPTION = "No description available"
[docs]
@dataclass(frozen=True, slots=True)
class ParameterHelpContent:
"""Display-ready content for one parameter help popup."""
summary: str
description: str
[docs]
@dataclass(frozen=True, slots=True)
class ParsedParameterDescription:
"""Structured projection of the generated parameter documentation prefix."""
type_name: str | None
default_value: str | None
description: str
[docs]
@dataclass(frozen=True, slots=True)
class ParameterDescriptionBody:
"""Formatted body fields for one parsed parameter description."""
setting_name: str | None
description: str
[docs]
class DataclassDocstringResolutionKind(Enum):
"""Provenance for source-authored dataclass docstring resolution."""
FOUND = "found"
CLASS_NOT_FOUND = "class_not_found"
DOCSTRING_MISSING = "docstring_missing"
[docs]
@dataclass(frozen=True, slots=True)
class DataclassDocstringResolution:
"""Result of resolving a source-authored dataclass docstring."""
kind: DataclassDocstringResolutionKind
docstring: str | None
PARAMETER_DESCRIPTION_FORMATTER = ParameterDescriptionFormatter()
[docs]
def dataclass_type_for_target(target: Union[Callable, type, None]) -> type | None:
"""Return the dataclass type represented by a help target."""
if target is None:
return None
if inspect.isclass(target) and is_dataclass(target):
return target
target_type = type(target)
if is_dataclass(target_type):
return target_type
return None
[docs]
def source_dataclass_type(dataclass_type: type) -> type:
"""Return the source-authored dataclass for generated lazy dataclass types."""
if is_lazy_dataclass(dataclass_type):
base_type = get_base_type_for_lazy(dataclass_type)
if base_type is None:
raise RuntimeError(
f"Lazy dataclass {dataclass_type.__qualname__} is missing its "
"registered source dataclass type."
)
if not is_dataclass(base_type):
raise RuntimeError(
f"Lazy dataclass {dataclass_type.__qualname__} resolves to "
f"non-dataclass source type {base_type!r}."
)
return base_type
return dataclass_type
[docs]
def source_class_docstring_resolution(dataclass_type: type) -> DataclassDocstringResolution:
"""Extract the authored class docstring instead of dataclass' rendered signature."""
source_type = source_dataclass_type(dataclass_type)
try:
source = inspect.getsource(source_type)
except (OSError, TypeError):
return DataclassDocstringResolution(
kind=DataclassDocstringResolutionKind.CLASS_NOT_FOUND,
docstring=None,
)
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == source_type.__name__:
docstring = ast.get_docstring(node)
if docstring is None:
return DataclassDocstringResolution(
kind=DataclassDocstringResolutionKind.DOCSTRING_MISSING,
docstring=None,
)
return DataclassDocstringResolution(
kind=DataclassDocstringResolutionKind.FOUND,
docstring=docstring,
)
return DataclassDocstringResolution(
kind=DataclassDocstringResolutionKind.CLASS_NOT_FOUND,
docstring=None,
)
[docs]
def is_signature_docstring(text: str | None, type_name: str) -> bool:
"""Return whether a dataclass docstring is just the generated constructor signature."""
if text is None:
return False
return text.lstrip().startswith(f"{type_name}(")
[docs]
def class_docstring_text(dataclass_type: type) -> str | None:
"""Return a human-authored dataclass docstring when one is available."""
source_type = source_dataclass_type(dataclass_type)
source_docstring = source_class_docstring_resolution(source_type)
if source_docstring.kind is DataclassDocstringResolutionKind.FOUND:
return source_docstring.docstring
logger.debug(
"Dataclass source docstring resolution for %s ended with %s.",
source_type.__qualname__,
source_docstring.kind.value,
)
inspected_docstring = inspect.getdoc(source_type)
if is_signature_docstring(inspected_docstring, source_type.__name__):
return None
return inspected_docstring
[docs]
def split_docstring_summary(docstring: str | None, fallback: str) -> tuple[str, str | None]:
"""Split a docstring into summary and optional remaining description."""
if not docstring:
return fallback, None
lines = docstring.strip().splitlines()
if not lines:
summary = fallback
else:
summary = lines[0].strip()
description = "\n".join(line.rstrip() for line in lines[1:]).strip()
if description == "":
return summary, None
return summary, description
[docs]
def dataclass_type_from_annotation(annotation) -> type | None:
"""Return a dataclass type represented by a field annotation."""
if inspect.isclass(annotation) and is_dataclass(annotation):
return annotation
origin = get_origin(annotation)
if origin is Annotated:
annotated_args = get_args(annotation)
if not annotated_args:
return None
return dataclass_type_from_annotation(annotated_args[0])
if origin in (Union, UnionType):
dataclass_args = tuple(
arg for arg in get_args(annotation)
if inspect.isclass(arg) and is_dataclass(arg)
)
if len(dataclass_args) == 1:
return dataclass_args[0]
return None
[docs]
def dataclass_field_description(description: str | None, annotation) -> str | None:
"""Return displayable docs for one dataclass field."""
field_type = dataclass_type_from_annotation(annotation)
signature_type_names: tuple[str, ...] = ()
if field_type is not None:
source_type = source_dataclass_type(field_type)
signature_type_names = tuple(
dict.fromkeys((field_type.__name__, source_type.__name__))
)
if description and (
field_type is None
or not any(
is_signature_docstring(description, type_name)
for type_name in signature_type_names
)
):
return description
if field_type is None:
return description
class_docstring = class_docstring_text(field_type)
summary, description_body = split_docstring_summary(
class_docstring,
f"{source_dataclass_type(field_type).__name__} configuration fields.",
)
if description_body:
return f"{summary}\n\n{description_body}"
return summary
[docs]
def dataclass_parameter_descriptions(dataclass_type: type) -> dict[str, str]:
"""Return field descriptions for a dataclass using the shared introspection path."""
analyzed = UnifiedParameterAnalyzer.analyze(dataclass_type)
dataclass_fields = {field.name: field for field in fields(dataclass_type)}
descriptions: dict[str, str] = {}
for name, info in analyzed.items():
if name in dataclass_fields:
annotation = dataclass_fields[name].type
else:
annotation = None
description = dataclass_field_description(info.description, annotation)
if description:
descriptions[name] = description
return descriptions
[docs]
def docstring_info_for_target(target: Union[Callable, type]) -> DocstringInfo:
"""Return help-window documentation, using field docs for dataclass targets."""
dataclass_type = dataclass_type_for_target(target)
if dataclass_type is None:
return DocstringExtractor.extract(target)
source_type = source_dataclass_type(dataclass_type)
summary, description = split_docstring_summary(
class_docstring_text(dataclass_type),
source_type.__name__,
)
return DocstringInfo(
summary=summary,
description=description,
parameters=dataclass_parameter_descriptions(dataclass_type),
returns="",
examples="",
)
[docs]
def parameter_description_from_target(
help_target: Union[Callable, type, None],
param_name: str,
) -> Optional[str]:
"""Return parsed documentation for one parameter from a callable/class target."""
if help_target is None:
return None
dataclass_type = dataclass_type_for_target(help_target)
if dataclass_type is not None:
descriptions = dataclass_parameter_descriptions(dataclass_type)
if param_name not in descriptions:
return None
return descriptions[param_name]
docstring_info = DocstringExtractor.extract(help_target)
if not docstring_info.parameters:
return None
if param_name not in docstring_info.parameters:
return None
return docstring_info.parameters[param_name]
[docs]
def resolved_parameter_description(
*,
help_target: Union[Callable, type, None],
param_name: str,
widget_description: str,
) -> str:
"""Resolve parameter help text from target docs, then explicit widget metadata."""
target_description = parameter_description_from_target(help_target, param_name)
if target_description:
return target_description
if widget_description:
return widget_description
return NO_PARAMETER_DESCRIPTION
[docs]
def parameter_type_display(param_type: type | None) -> str:
"""Return the compact type label used by parameter help."""
if param_type is None:
return ""
if isinstance(param_type, type):
return f" ({param_type.__name__})"
return f" ({param_type})"
[docs]
def split_default_prefix(text: str) -> tuple[str, str]:
"""Split a rendered default literal from the following sentence body."""
sentence_separator = ". "
separator_index = text.find(sentence_separator)
if separator_index >= 0:
return (
text[:separator_index],
text[separator_index + len(sentence_separator) :],
)
return text.rstrip("."), ""
[docs]
def parse_parameter_description(description: str) -> ParsedParameterDescription:
"""Parse the type/default prefix emitted by DocstringExtractor parameter docs."""
if not description.startswith("'"):
unquoted_match = re.match(
r"^(?P<type>[^.;]+);\s+default\s+(?P<default_and_body>.*)$",
description,
)
if unquoted_match is not None:
default_value, body = split_default_prefix(
unquoted_match.group("default_and_body"),
)
return ParsedParameterDescription(
type_name=unquoted_match.group("type").strip(),
default_value=default_value.strip(),
description=body.strip(),
)
return ParsedParameterDescription(
type_name=None,
default_value=None,
description=description,
)
closing_quote_index = description.find("'", 1)
if closing_quote_index < 0:
return ParsedParameterDescription(
type_name=None,
default_value=None,
description=description,
)
type_name = description[1:closing_quote_index]
remainder = description[closing_quote_index + 1 :].lstrip()
if remainder.startswith("."):
remainder = remainder[1:].lstrip()
default_value = None
default_prefix = "; default "
if remainder.startswith(default_prefix):
default_value, remainder = split_default_prefix(remainder[len(default_prefix) :])
return ParsedParameterDescription(
type_name=type_name,
default_value=default_value,
description=remainder,
)
[docs]
def parameter_description_body(description: str) -> ParameterDescriptionBody:
"""Split CellProfiler setting metadata from the prose body."""
setting_prefix = "CellProfiler setting '"
if not description.startswith(setting_prefix):
return ParameterDescriptionBody(
setting_name=None,
description=PARAMETER_DESCRIPTION_FORMATTER.format_body(description),
)
setting_start = len(setting_prefix)
setting_end = description.find("'", setting_start)
if setting_end < 0:
return ParameterDescriptionBody(
setting_name=None,
description=PARAMETER_DESCRIPTION_FORMATTER.format_body(description),
)
setting_name = description[setting_start:setting_end]
remainder = description[setting_end + 1 :].lstrip()
if remainder.startswith("."):
remainder = remainder[1:].lstrip()
return ParameterDescriptionBody(
setting_name=setting_name,
description=PARAMETER_DESCRIPTION_FORMATTER.format_body(remainder),
)
def _default_values_match(left: str, right: str) -> bool:
"""Return whether two rendered default literals describe the same value."""
if left == right:
return True
numeric_pattern = r"[-+]?\d+(?:\.\d+)?"
if re.fullmatch(numeric_pattern, left) and re.fullmatch(numeric_pattern, right):
return float(left) == float(right)
return False
[docs]
def remove_duplicate_default_sentence(description: str, default_value: str | None) -> str:
"""Remove body-level default sentence already shown in the default section."""
if default_value is None:
return description
def replacement(match: re.Match[str]) -> str:
rendered_default = match.group("value").strip()
if _default_values_match(default_value, rendered_default):
return ""
return match.group(0)
return re.sub(
r"(?:\s+|^)Default is (?P<value>[-+]?\d+(?:\.\d+)?|[^.]+)\.",
replacement,
description,
).strip()
[docs]
def parameter_help_content(
*,
param_name: str,
param_type: type | None,
description: str,
) -> ParameterHelpContent:
"""Build compact popup content without leaking raw Python annotations."""
parsed = parse_parameter_description(description)
type_str = f" ({parsed.type_name})" if parsed.type_name else parameter_type_display(param_type)
body = parameter_description_body(parsed.description)
lines: list[str] = []
if parsed.default_value:
lines.append(f"Default: {parsed.default_value}")
if body.setting_name:
lines.append(f"CellProfiler setting: {body.setting_name}")
body_description = remove_duplicate_default_sentence(
body.description,
parsed.default_value,
)
if body_description:
lines.append(body_description)
return ParameterHelpContent(
summary=f"• {param_name}{type_str}",
description="\n\n".join(lines) if lines else NO_PARAMETER_DESCRIPTION,
)