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

1"""Read-only inspection of registered ObjectStates and their fields.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from collections.abc import Callable 

7from dataclasses import dataclass 

8 

9from objectstate.field_access import DottedFieldPath 

10from objectstate.object_state import ObjectState 

11from objectstate.object_state_registry import ObjectStateRegistry 

12 

13logger = logging.getLogger(__name__) 

14 

15DIRTY_MARKER = "*" 

16"""Marks a field (or a group of fields) whose value differs from the saved value.""" 

17 

18SIGNATURE_DIFF_MARKER = "_" 

19"""Marks a field whose raw value differs from its declared signature default.""" 

20 

21 

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

24 

25 return tuple( 

26 marker 

27 for marker, present in ((DIRTY_MARKER, dirty), (SIGNATURE_DIFF_MARKER, signature_diff)) 

28 if present 

29 ) 

30 

31 

32def state_markers(state: ObjectState) -> tuple[str, ...]: 

33 """The markers of a whole ObjectState: any dirty field, any signature difference.""" 

34 

35 return field_markers( 

36 dirty=bool(state.dirty_fields), 

37 signature_diff=bool(state.signature_diff_fields), 

38 ) 

39 

40 

41def type_name(value: object) -> str: 

42 return "None" if value is None else type(value).__name__ 

43 

44 

45def type_qualname(value_type: type | Callable[..., object]) -> str: 

46 return f"{value_type.__module__}.{value_type.__qualname__}" 

47 

48 

49@dataclass(frozen=True, slots=True) 

50class FieldProvenance: 

51 """The ancestor scope and declaring type a field's inherited value comes from.""" 

52 

53 source_scope_id: str | None 

54 source_type: type 

55 field_name: str 

56 

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

60 

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) 

65 

66 

67@dataclass(frozen=True, slots=True) 

68class FieldInspection: 

69 """One field of one ObjectState: raw and resolved value, edit state and provenance.""" 

70 

71 state: ObjectState 

72 field_path: str 

73 raw_value: object 

74 resolved_value: object 

75 resolved_value_available: bool 

76 provenance: FieldProvenance | None 

77 

78 @classmethod 

79 def of(cls, state: ObjectState, field_path: str): 

80 """Inspect ``field_path``; a value that fails to resolve is reported unavailable.""" 

81 

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 ) 

110 

111 @property 

112 def dirty(self) -> bool: 

113 return self.field_path in self.state.dirty_fields 

114 

115 @property 

116 def signature_diff(self) -> bool: 

117 return self.field_path in self.state.signature_diff_fields 

118 

119 @property 

120 def last_changed(self) -> bool: 

121 return self.field_path == self.state.last_changed_field 

122 

123 @property 

124 def raw_value_is_none(self) -> bool: 

125 return self.raw_value is None 

126 

127 @property 

128 def resolved_value_is_none(self) -> bool: 

129 return self.resolved_value_available and self.resolved_value is None 

130 

131 @property 

132 def inherited_value(self) -> bool: 

133 """The field holds no value of its own and inherits a resolved one.""" 

134 

135 return self.raw_value is None and self.resolved_value_available and ( 

136 self.resolved_value is not None 

137 ) 

138 

139 @property 

140 def semantic_markers(self) -> tuple[str, ...]: 

141 return field_markers(dirty=self.dirty, signature_diff=self.signature_diff) 

142 

143 @property 

144 def field_name(self) -> str: 

145 return DottedFieldPath(self.field_path).field_name 

146 

147 @property 

148 def container_path(self) -> str: 

149 return DottedFieldPath(self.field_path).parent.value 

150 

151 @property 

152 def raw_value_type(self) -> str: 

153 return type_name(self.raw_value) 

154 

155 @property 

156 def resolved_value_type(self) -> str | None: 

157 return type_name(self.resolved_value) if self.resolved_value_available else None 

158 

159 @property 

160 def declaring_type(self) -> str: 

161 """Qualified name of the dataclass that declares this field.""" 

162 

163 return type_qualname(self.state.type_for_path(self.field_path))