Coverage for src/objectstate/inspection.py: 90%
89 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 07:22 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 07:22 +0000
1"""Read-only inspection of registered ObjectStates and their fields."""
3from __future__ import annotations
5import logging
6from collections.abc import Callable
7from dataclasses import dataclass
9from objectstate.field_access import DottedFieldPath
10from objectstate.object_state import ObjectState
11from objectstate.object_state_registry import ObjectStateRegistry
13logger = logging.getLogger(__name__)
15DIRTY_MARKER = "*"
16"""Marks a field (or a group of fields) whose value differs from the saved value."""
18SIGNATURE_DIFF_MARKER = "_"
19"""Marks a field whose raw value differs from its declared signature default."""
22def field_markers(*, dirty: bool, signature_diff: bool) -> tuple[str, ...]:
23 """The markers views prefix to a field, or to a group of fields, in this order."""
25 return tuple(
26 marker
27 for marker, present in ((DIRTY_MARKER, dirty), (SIGNATURE_DIFF_MARKER, signature_diff))
28 if present
29 )
32def state_markers(state: ObjectState) -> tuple[str, ...]:
33 """The markers of a whole ObjectState: any dirty field, any signature difference."""
35 return field_markers(
36 dirty=bool(state.dirty_fields),
37 signature_diff=bool(state.signature_diff_fields),
38 )
41def type_name(value: object) -> str:
42 return "None" if value is None else type(value).__name__
45def type_qualname(value_type: type | Callable[..., object]) -> str:
46 return f"{value_type.__module__}.{value_type.__qualname__}"
49@dataclass(frozen=True, slots=True)
50class FieldProvenance:
51 """The ancestor scope and declaring type a field's inherited value comes from."""
53 source_scope_id: str | None
54 source_type: type
55 field_name: str
57 @property
58 def source_field_path(self) -> str | None:
59 """The field's path in the source scope's own state, if that scope is registered."""
61 source_state = ObjectStateRegistry.get_by_scope(self.source_scope_id)
62 if source_state is None:
63 return None
64 return source_state.project_ui_visible_field_path(self.source_type, self.field_name)
67@dataclass(frozen=True, slots=True)
68class FieldInspection:
69 """One field of one ObjectState: raw and resolved value, edit state and provenance."""
71 state: ObjectState
72 field_path: str
73 raw_value: object
74 resolved_value: object
75 resolved_value_available: bool
76 provenance: FieldProvenance | None
78 @classmethod
79 def of(cls, state: ObjectState, field_path: str):
80 """Inspect ``field_path``; a value that fails to resolve is reported unavailable."""
82 resolved_value = None
83 resolved_value_available = False
84 try:
85 resolved_value = state.get_resolved_value(field_path)
86 resolved_value_available = True
87 except Exception:
88 logger.debug(
89 "Failed to resolve ObjectState field %s on scope %r.",
90 field_path,
91 state.scope_id,
92 exc_info=True,
93 )
94 provenance = None
95 match state.get_provenance(field_path):
96 case (source_scope_id, source_type) if source_type is not None:
97 provenance = FieldProvenance(
98 source_scope_id=source_scope_id,
99 source_type=source_type,
100 field_name=DottedFieldPath(field_path).field_name,
101 )
102 return cls(
103 state=state,
104 field_path=field_path,
105 raw_value=state.parameters[field_path],
106 resolved_value=resolved_value,
107 resolved_value_available=resolved_value_available,
108 provenance=provenance,
109 )
111 @property
112 def dirty(self) -> bool:
113 return self.field_path in self.state.dirty_fields
115 @property
116 def signature_diff(self) -> bool:
117 return self.field_path in self.state.signature_diff_fields
119 @property
120 def last_changed(self) -> bool:
121 return self.field_path == self.state.last_changed_field
123 @property
124 def raw_value_is_none(self) -> bool:
125 return self.raw_value is None
127 @property
128 def resolved_value_is_none(self) -> bool:
129 return self.resolved_value_available and self.resolved_value is None
131 @property
132 def inherited_value(self) -> bool:
133 """The field holds no value of its own and inherits a resolved one."""
135 return self.raw_value is None and self.resolved_value_available and (
136 self.resolved_value is not None
137 )
139 @property
140 def semantic_markers(self) -> tuple[str, ...]:
141 return field_markers(dirty=self.dirty, signature_diff=self.signature_diff)
143 @property
144 def field_name(self) -> str:
145 return DottedFieldPath(self.field_path).field_name
147 @property
148 def container_path(self) -> str:
149 return DottedFieldPath(self.field_path).parent.value
151 @property
152 def raw_value_type(self) -> str:
153 return type_name(self.raw_value)
155 @property
156 def resolved_value_type(self) -> str | None:
157 return type_name(self.resolved_value) if self.resolved_value_available else None
159 @property
160 def declaring_type(self) -> str:
161 """Qualified name of the dataclass that declares this field."""
163 return type_qualname(self.state.type_for_path(self.field_path))