Coverage for mcp/metric_readers/shape.py: 95%

67 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-15 15:07 +0000

1"""Building blocks shared by every metric-reader tool. 

2 

3A metric reader either produces one numeric scalar wrapped in a canonical 

4result shape, or it fails and produces a structured error envelope. This 

5module owns the small, pure pieces both outcomes are built from: 

6 

7* :class:`ErrorCode` — the frozen set of stable, machine-readable failure 

8 codes a reader can surface. 

9* :class:`MetricReaderError` — the exception readers raise internally; 

10 tool wrappers translate it into an error envelope. 

11* :func:`validate_metric_name` — guards the caller-supplied metric name so 

12 the resulting key is a single, well-formed path segment. 

13* :func:`default_metric_key` — derives a deterministic, well-formed key from 

14 a source identifier when the caller supplies no explicit name. 

15* :func:`is_numeric_value` — the single source of truth for "is this a real, 

16 finite number?". 

17* :func:`metrics_result` — assembles the canonical 

18 ``{"metrics": {key: value}, ...}`` success shape. 

19* :func:`error_envelope` — assembles the ``{"code", "details"}`` failure 

20 shape. 

21 

22Everything here is pure: no I/O, no clocks, no environment lookups. That 

23keeps the pieces trivial to test in isolation and safe to call from both 

24async tool handlers and synchronous code. 

25""" 

26 

27from __future__ import annotations 

28 

29import math 

30from typing import Any 

31 

32# The longest a metric name may be, in characters. 

33_MAX_METRIC_NAME_LEN = 128 

34 

35# The fallback key used when a source identifier sanitizes down to nothing. 

36_DEFAULT_KEY_FALLBACK = "metric" 

37 

38 

39class ErrorCode: 

40 """Stable, machine-readable failure codes a metric reader can surface. 

41 

42 Each value is a short string an operator (or an automated caller) can 

43 branch on without parsing a human message. The values are deliberately 

44 frozen: callers and tests may depend on the exact strings. 

45 """ 

46 

47 METRIC_NAME_INVALID = "metric_name_invalid" 

48 INVALID_AGGREGATION_MODE = "invalid_aggregation_mode" 

49 EMPTY_SEQUENCE = "empty_sequence" 

50 # A sequence had entries, but none survived the numeric filter. 

51 NO_NUMERIC_VALUE = "no_numeric_value" 

52 # A single resolved value was not a real, finite number. 

53 NON_NUMERIC_VALUE = "non_numeric_value" 

54 NO_DATAPOINTS = "no_datapoints" 

55 # details.kind discriminates unreachable / unauthorized / client_error. 

56 AWS_UNREACHABLE = "aws_unreachable" 

57 INVALID_EXTRACTION_MODE = "invalid_extraction_mode" 

58 INVALID_REGEX = "invalid_regex" 

59 NO_MATCH = "no_match" 

60 # details.kind discriminates unknown_job / unreachable. 

61 LOG_RETRIEVAL_FAILED = "log_retrieval_failed" 

62 FILE_NOT_FOUND = "file_not_found" 

63 MALFORMED_FILE = "malformed_file" 

64 # Covers both a missing object field and a missing table column. 

65 FIELD_NOT_FOUND = "field_not_found" 

66 # The same failure class as NON_NUMERIC_VALUE, named for file readers. 

67 NON_NUMERIC_VALUE_FILE = NON_NUMERIC_VALUE 

68 FILE_TOO_LARGE = "file_too_large" 

69 UNSUPPORTED_FORMAT = "unsupported_format" 

70 FORMAT_DEPENDENCY_UNAVAILABLE = "format_dependency_unavailable" 

71 # Local-filesystem reader confinement codes. Kept distinct so an 

72 # operator can tell a traversal attempt, a symlink escape, and an 

73 # unconfigured root apart without inspecting the details payload. 

74 # 

75 # A supplied path's ".." segments escaped the allowlisted root. 

76 PATH_TRAVERSAL_ESCAPE = "path_traversal_escape" 

77 # A symlink within the path resolved to a target outside the root. 

78 SYMLINK_ESCAPE = "symlink_escape" 

79 # The reader is enabled but no allowlisted root is configured. 

80 LOCAL_ROOT_NOT_CONFIGURED = "local_root_not_configured" 

81 

82 

83class MetricReaderError(Exception): 

84 """Raised internally when a reader cannot produce a metric. 

85 

86 Carries a stable short ``code`` (one of :class:`ErrorCode`) and an 

87 optional structured ``details`` dict the tool wrapper renders into an 

88 error envelope. The constructor accepts ``(code, details=None, *, 

89 message=None)``; when ``message`` is omitted the exception's string 

90 form falls back to ``code`` so logs always show something meaningful. 

91 """ 

92 

93 def __init__( 

94 self, 

95 code: str, 

96 details: dict[str, Any] | None = None, 

97 *, 

98 message: str | None = None, 

99 ) -> None: 

100 self.code: str = code 

101 self.details: dict[str, Any] | None = details 

102 rendered = message if message is not None else code 

103 super().__init__(rendered) 

104 

105 

106def validate_metric_name(name: str) -> str: 

107 """Return ``name`` unchanged when it is a single well-formed path segment. 

108 

109 A valid name is a non-empty string of at most 128 characters that 

110 contains neither a ``.`` separator nor any whitespace character, so the 

111 resulting metric path is exactly ``metrics.<name>``. Any other input — 

112 empty, too long, containing a ``.``, or containing whitespace — raises 

113 :class:`MetricReaderError` with code 

114 :attr:`ErrorCode.METRIC_NAME_INVALID` and a ``details`` payload that 

115 names the specific reason. 

116 """ 

117 if not isinstance(name, str): 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 raise MetricReaderError( 

119 ErrorCode.METRIC_NAME_INVALID, 

120 {"reason": "not_a_string"}, 

121 ) 

122 if not name: 

123 raise MetricReaderError( 

124 ErrorCode.METRIC_NAME_INVALID, 

125 {"reason": "empty", "name": name}, 

126 ) 

127 if len(name) > _MAX_METRIC_NAME_LEN: 

128 raise MetricReaderError( 

129 ErrorCode.METRIC_NAME_INVALID, 

130 { 

131 "reason": "too_long", 

132 "name": name, 

133 "max_length": _MAX_METRIC_NAME_LEN, 

134 "actual_length": len(name), 

135 }, 

136 ) 

137 if "." in name: 

138 raise MetricReaderError( 

139 ErrorCode.METRIC_NAME_INVALID, 

140 {"reason": "contains_separator", "name": name}, 

141 ) 

142 if any(ch.isspace() for ch in name): 

143 raise MetricReaderError( 

144 ErrorCode.METRIC_NAME_INVALID, 

145 {"reason": "contains_whitespace", "name": name}, 

146 ) 

147 return name 

148 

149 

150def default_metric_key(source_hint: str) -> str: 

151 """Derive a deterministic, well-formed key from a source identifier. 

152 

153 Used when the caller supplies no explicit metric name. Every character 

154 that a metric name may not contain — a ``.`` separator or any whitespace 

155 character — is replaced with an underscore, the result is capped at 128 

156 characters, and an identifier that sanitizes down to nothing falls back 

157 to a fixed placeholder. The same input always yields the same key, and 

158 the key always satisfies :func:`validate_metric_name`. 

159 """ 

160 text = str(source_hint) 

161 sanitized = "".join("_" if (ch == "." or ch.isspace()) else ch for ch in text) 

162 sanitized = sanitized[:_MAX_METRIC_NAME_LEN] 

163 if not sanitized: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 return _DEFAULT_KEY_FALLBACK 

165 return sanitized 

166 

167 

168def is_numeric_value(x: object) -> bool: 

169 """Return True only for a real, finite number. 

170 

171 An integer qualifies; a float qualifies when it is finite. A boolean is 

172 rejected even though ``bool`` is a subclass of ``int``, and NaN and the 

173 infinities are rejected. Everything else — strings, ``None``, containers 

174 — is rejected too. This is the single gate a value must pass before it 

175 can stand in for a metric a threshold comparison will read. 

176 """ 

177 if isinstance(x, bool): 

178 return False 

179 if isinstance(x, int): 

180 return True 

181 if isinstance(x, float): 

182 # math.isfinite is False for NaN and +/-inf. 

183 return math.isfinite(x) 

184 return False 

185 

186 

187def metrics_result(key: str, value: float, **provenance: object) -> dict[str, Any]: 

188 """Assemble the canonical success shape: ``{"metrics": {key: value}, ...}``. 

189 

190 The single metric lives under the top-level ``metrics`` object; every 

191 provenance field (source identifier, region, timestamp, aggregation mode 

192 applied, and so on) is placed beside ``metrics`` at the top level, never 

193 inside it, so the merged view contains only the numeric value. ``value`` 

194 must already be a real, finite number — callers coerce and check before 

195 reaching this builder. 

196 """ 

197 assert is_numeric_value(value), "metrics_result requires a finite numeric value" 

198 # Lay down provenance first, then the metrics object, so the top-level 

199 # ``metrics`` key is always the canonical numeric map even if a 

200 # provenance field happens to share that name. 

201 result: dict[str, Any] = dict(provenance) 

202 result["metrics"] = {key: value} 

203 return result 

204 

205 

206def error_envelope(code: str, **details: object) -> dict[str, Any]: 

207 """Assemble the structured failure shape: ``{"code": code, "details": {...}}``. 

208 

209 The returned object never carries a top-level ``metrics`` key, so a 

210 consumer that merges only ``metrics``-shaped results skips it and leaves 

211 the corresponding check undecided rather than acting on bad data. Any 

212 diagnostic context is nested under ``details``. 

213 """ 

214 return {"code": code, "details": dict(details)}