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
« 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.
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:
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.
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"""
27from __future__ import annotations
29import math
30from typing import Any
32# The longest a metric name may be, in characters.
33_MAX_METRIC_NAME_LEN = 128
35# The fallback key used when a source identifier sanitizes down to nothing.
36_DEFAULT_KEY_FALLBACK = "metric"
39class ErrorCode:
40 """Stable, machine-readable failure codes a metric reader can surface.
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 """
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"
83class MetricReaderError(Exception):
84 """Raised internally when a reader cannot produce a metric.
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 """
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)
106def validate_metric_name(name: str) -> str:
107 """Return ``name`` unchanged when it is a single well-formed path segment.
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
150def default_metric_key(source_hint: str) -> str:
151 """Derive a deterministic, well-formed key from a source identifier.
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
168def is_numeric_value(x: object) -> bool:
169 """Return True only for a real, finite number.
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
187def metrics_result(key: str, value: float, **provenance: object) -> dict[str, Any]:
188 """Assemble the canonical success shape: ``{"metrics": {key: value}, ...}``.
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
206def error_envelope(code: str, **details: object) -> dict[str, Any]:
207 """Assemble the structured failure shape: ``{"code": code, "details": {...}}``.
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)}