actionable-errors 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- actionable_errors/__init__.py +28 -0
- actionable_errors/classifier.py +61 -0
- actionable_errors/error.py +227 -0
- actionable_errors/guidance.py +49 -0
- actionable_errors/py.typed +0 -0
- actionable_errors/result.py +92 -0
- actionable_errors/sanitizer.py +149 -0
- actionable_errors/types.py +25 -0
- actionable_errors-0.1.0.dist-info/METADATA +131 -0
- actionable_errors-0.1.0.dist-info/RECORD +12 -0
- actionable_errors-0.1.0.dist-info/WHEEL +4 -0
- actionable_errors-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""actionable-errors: Three-audience error framework.
|
|
2
|
+
|
|
3
|
+
Every ActionableError speaks to three audiences simultaneously:
|
|
4
|
+
- Calling code: typed ErrorType for routing (retry? escalate? ignore?)
|
|
5
|
+
- Human operator: suggestion + Troubleshooting steps
|
|
6
|
+
- AI agent: AIGuidance with concrete next tool calls
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from actionable_errors.classifier import from_exception
|
|
12
|
+
from actionable_errors.error import ActionableError
|
|
13
|
+
from actionable_errors.guidance import AIGuidance, Troubleshooting
|
|
14
|
+
from actionable_errors.result import ToolResult
|
|
15
|
+
from actionable_errors.sanitizer import CredentialSanitizer, register_pattern, sanitize
|
|
16
|
+
from actionable_errors.types import ErrorType
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AIGuidance",
|
|
20
|
+
"ActionableError",
|
|
21
|
+
"CredentialSanitizer",
|
|
22
|
+
"ErrorType",
|
|
23
|
+
"ToolResult",
|
|
24
|
+
"Troubleshooting",
|
|
25
|
+
"from_exception",
|
|
26
|
+
"register_pattern",
|
|
27
|
+
"sanitize",
|
|
28
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""from_exception() keyword-based auto-classifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from actionable_errors.error import ActionableError
|
|
6
|
+
from actionable_errors.types import ErrorType
|
|
7
|
+
|
|
8
|
+
# Keyword → ErrorType mapping. First match wins, so order matters.
|
|
9
|
+
# Each tuple is (keywords, target_type).
|
|
10
|
+
_KEYWORD_RULES: list[tuple[list[str], ErrorType]] = [
|
|
11
|
+
(["unauthorized", "unauthenticated", "credential", "login", "auth", "401"],
|
|
12
|
+
ErrorType.AUTHENTICATION),
|
|
13
|
+
(["forbidden", "permission", "denied", "access", "403"],
|
|
14
|
+
ErrorType.PERMISSION),
|
|
15
|
+
(["timeout", "timed out", "deadline"],
|
|
16
|
+
ErrorType.TIMEOUT),
|
|
17
|
+
(["connection refused", "connection error", "unreachable", "dns", "connect"],
|
|
18
|
+
ErrorType.CONNECTION),
|
|
19
|
+
(["not found", "no such", "missing", "does not exist", "404"],
|
|
20
|
+
ErrorType.NOT_FOUND),
|
|
21
|
+
(["invalid", "validation", "malformed", "schema", "constraint"],
|
|
22
|
+
ErrorType.VALIDATION),
|
|
23
|
+
(["config", "configuration", "setting", "environment variable"],
|
|
24
|
+
ErrorType.CONFIGURATION),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def from_exception(
|
|
29
|
+
exc: Exception,
|
|
30
|
+
service: str,
|
|
31
|
+
operation: str,
|
|
32
|
+
*,
|
|
33
|
+
suggestion: str | None = None,
|
|
34
|
+
) -> ActionableError:
|
|
35
|
+
"""Classify an arbitrary exception into an ActionableError.
|
|
36
|
+
|
|
37
|
+
Scans ``str(exc)`` against keyword rules (case-insensitive) and assigns
|
|
38
|
+
the matching :class:`ErrorType`. Falls back to ``INTERNAL`` when no
|
|
39
|
+
rule matches.
|
|
40
|
+
|
|
41
|
+
If *suggestion* is provided it is passed through to the resulting error.
|
|
42
|
+
"""
|
|
43
|
+
text = str(exc).lower()
|
|
44
|
+
|
|
45
|
+
for keywords, error_type in _KEYWORD_RULES:
|
|
46
|
+
for kw in keywords:
|
|
47
|
+
if kw in text:
|
|
48
|
+
return ActionableError(
|
|
49
|
+
error=str(exc),
|
|
50
|
+
error_type=error_type,
|
|
51
|
+
service=service,
|
|
52
|
+
suggestion=suggestion,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Fallback: unrecognised → INTERNAL
|
|
56
|
+
return ActionableError(
|
|
57
|
+
error=str(exc),
|
|
58
|
+
error_type=ErrorType.INTERNAL,
|
|
59
|
+
service=service,
|
|
60
|
+
suggestion=suggestion,
|
|
61
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""ActionableError dataclass with factory classmethods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, fields
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from actionable_errors.guidance import AIGuidance, Troubleshooting
|
|
10
|
+
from actionable_errors.types import ErrorType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ActionableError(Exception):
|
|
15
|
+
"""Three-audience error — code routing, human suggestion, AI guidance.
|
|
16
|
+
|
|
17
|
+
Always carries ``success = False`` and a UTC ``timestamp``.
|
|
18
|
+
Construct via factory classmethods for consistency.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
error: str
|
|
22
|
+
error_type: ErrorType | str
|
|
23
|
+
service: str
|
|
24
|
+
suggestion: str | None = None
|
|
25
|
+
ai_guidance: AIGuidance | None = None
|
|
26
|
+
troubleshooting: Troubleshooting | None = None
|
|
27
|
+
context: dict[str, Any] | None = None
|
|
28
|
+
success: bool = field(default=False, init=False)
|
|
29
|
+
timestamp: str = field(default="", init=False)
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
super().__init__(self.error)
|
|
33
|
+
self.success = False
|
|
34
|
+
self.timestamp = datetime.now(tz=UTC).isoformat()
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
# Serialization
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
"""Serialize to dict, excluding None-valued optional fields."""
|
|
42
|
+
result: dict[str, Any] = {}
|
|
43
|
+
for f in fields(self):
|
|
44
|
+
value = getattr(self, f.name)
|
|
45
|
+
if value is None:
|
|
46
|
+
continue
|
|
47
|
+
if f.name == "error_type":
|
|
48
|
+
result["error_type"] = str(value)
|
|
49
|
+
elif isinstance(value, (AIGuidance, Troubleshooting)):
|
|
50
|
+
result[f.name] = value.to_dict()
|
|
51
|
+
else:
|
|
52
|
+
result[f.name] = value
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Factory classmethods
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def authentication(
|
|
61
|
+
cls,
|
|
62
|
+
service: str,
|
|
63
|
+
raw_error: str,
|
|
64
|
+
*,
|
|
65
|
+
suggestion: str | None = None,
|
|
66
|
+
ai_guidance: AIGuidance | None = None,
|
|
67
|
+
troubleshooting: Troubleshooting | None = None,
|
|
68
|
+
) -> ActionableError:
|
|
69
|
+
"""Create an AUTHENTICATION error."""
|
|
70
|
+
return cls(
|
|
71
|
+
error=f"{service} authentication failed: {raw_error}",
|
|
72
|
+
error_type=ErrorType.AUTHENTICATION,
|
|
73
|
+
service=service,
|
|
74
|
+
suggestion=suggestion or "Check your credentials and try again.",
|
|
75
|
+
ai_guidance=ai_guidance or AIGuidance(
|
|
76
|
+
action_required="Re-authenticate",
|
|
77
|
+
command="az login",
|
|
78
|
+
),
|
|
79
|
+
troubleshooting=troubleshooting,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def configuration(
|
|
84
|
+
cls,
|
|
85
|
+
field_name: str,
|
|
86
|
+
reason: str,
|
|
87
|
+
*,
|
|
88
|
+
suggestion: str | None = None,
|
|
89
|
+
ai_guidance: AIGuidance | None = None,
|
|
90
|
+
troubleshooting: Troubleshooting | None = None,
|
|
91
|
+
) -> ActionableError:
|
|
92
|
+
"""Create a CONFIGURATION error."""
|
|
93
|
+
return cls(
|
|
94
|
+
error=f"Configuration error — {field_name}: {reason}",
|
|
95
|
+
error_type=ErrorType.CONFIGURATION,
|
|
96
|
+
service="configuration",
|
|
97
|
+
suggestion=suggestion or f"Check the '{field_name}' setting.",
|
|
98
|
+
ai_guidance=ai_guidance,
|
|
99
|
+
troubleshooting=troubleshooting,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def connection(
|
|
104
|
+
cls,
|
|
105
|
+
service: str,
|
|
106
|
+
url: str,
|
|
107
|
+
raw_error: str,
|
|
108
|
+
*,
|
|
109
|
+
suggestion: str | None = None,
|
|
110
|
+
ai_guidance: AIGuidance | None = None,
|
|
111
|
+
troubleshooting: Troubleshooting | None = None,
|
|
112
|
+
) -> ActionableError:
|
|
113
|
+
"""Create a CONNECTION error."""
|
|
114
|
+
return cls(
|
|
115
|
+
error=f"{service} connection to {url} failed: {raw_error}",
|
|
116
|
+
error_type=ErrorType.CONNECTION,
|
|
117
|
+
service=service,
|
|
118
|
+
suggestion=suggestion or f"Verify {service} is running at {url}.",
|
|
119
|
+
ai_guidance=ai_guidance,
|
|
120
|
+
troubleshooting=troubleshooting,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def timeout(
|
|
125
|
+
cls,
|
|
126
|
+
service: str,
|
|
127
|
+
operation: str,
|
|
128
|
+
timeout_seconds: int | float,
|
|
129
|
+
*,
|
|
130
|
+
suggestion: str | None = None,
|
|
131
|
+
ai_guidance: AIGuidance | None = None,
|
|
132
|
+
troubleshooting: Troubleshooting | None = None,
|
|
133
|
+
) -> ActionableError:
|
|
134
|
+
"""Create a TIMEOUT error."""
|
|
135
|
+
return cls(
|
|
136
|
+
error=f"{service} {operation} timed out after {timeout_seconds}s",
|
|
137
|
+
error_type=ErrorType.TIMEOUT,
|
|
138
|
+
service=service,
|
|
139
|
+
suggestion=suggestion or f"Increase the timeout or optimize the {operation}.",
|
|
140
|
+
ai_guidance=ai_guidance,
|
|
141
|
+
troubleshooting=troubleshooting,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def permission(
|
|
146
|
+
cls,
|
|
147
|
+
service: str,
|
|
148
|
+
resource: str,
|
|
149
|
+
raw_error: str,
|
|
150
|
+
*,
|
|
151
|
+
suggestion: str | None = None,
|
|
152
|
+
ai_guidance: AIGuidance | None = None,
|
|
153
|
+
troubleshooting: Troubleshooting | None = None,
|
|
154
|
+
) -> ActionableError:
|
|
155
|
+
"""Create a PERMISSION error."""
|
|
156
|
+
return cls(
|
|
157
|
+
error=f"{service} permission denied on {resource}: {raw_error}",
|
|
158
|
+
error_type=ErrorType.PERMISSION,
|
|
159
|
+
service=service,
|
|
160
|
+
suggestion=suggestion or f"Request access to {resource}.",
|
|
161
|
+
ai_guidance=ai_guidance,
|
|
162
|
+
troubleshooting=troubleshooting,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def validation(
|
|
167
|
+
cls,
|
|
168
|
+
service: str,
|
|
169
|
+
field_name: str,
|
|
170
|
+
reason: str,
|
|
171
|
+
*,
|
|
172
|
+
suggestion: str | None = None,
|
|
173
|
+
ai_guidance: AIGuidance | None = None,
|
|
174
|
+
troubleshooting: Troubleshooting | None = None,
|
|
175
|
+
) -> ActionableError:
|
|
176
|
+
"""Create a VALIDATION error."""
|
|
177
|
+
return cls(
|
|
178
|
+
error=f"{service} validation failed — {field_name}: {reason}",
|
|
179
|
+
error_type=ErrorType.VALIDATION,
|
|
180
|
+
service=service,
|
|
181
|
+
suggestion=suggestion or f"Fix the '{field_name}' value: {reason}.",
|
|
182
|
+
ai_guidance=ai_guidance,
|
|
183
|
+
troubleshooting=troubleshooting,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def not_found(
|
|
188
|
+
cls,
|
|
189
|
+
service: str,
|
|
190
|
+
resource_type: str,
|
|
191
|
+
resource_id: str,
|
|
192
|
+
raw_error: str,
|
|
193
|
+
*,
|
|
194
|
+
suggestion: str | None = None,
|
|
195
|
+
ai_guidance: AIGuidance | None = None,
|
|
196
|
+
troubleshooting: Troubleshooting | None = None,
|
|
197
|
+
) -> ActionableError:
|
|
198
|
+
"""Create a NOT_FOUND error."""
|
|
199
|
+
return cls(
|
|
200
|
+
error=f"{service} {resource_type} '{resource_id}' not found: {raw_error}",
|
|
201
|
+
error_type=ErrorType.NOT_FOUND,
|
|
202
|
+
service=service,
|
|
203
|
+
suggestion=suggestion or f"Verify the {resource_type} identifier.",
|
|
204
|
+
ai_guidance=ai_guidance,
|
|
205
|
+
troubleshooting=troubleshooting,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def internal(
|
|
210
|
+
cls,
|
|
211
|
+
service: str,
|
|
212
|
+
operation: str,
|
|
213
|
+
raw_error: str,
|
|
214
|
+
*,
|
|
215
|
+
suggestion: str | None = None,
|
|
216
|
+
ai_guidance: AIGuidance | None = None,
|
|
217
|
+
troubleshooting: Troubleshooting | None = None,
|
|
218
|
+
) -> ActionableError:
|
|
219
|
+
"""Create an INTERNAL error."""
|
|
220
|
+
return cls(
|
|
221
|
+
error=f"{service} unexpected error in {operation}: {raw_error}",
|
|
222
|
+
error_type=ErrorType.INTERNAL,
|
|
223
|
+
service=service,
|
|
224
|
+
suggestion=suggestion or "Check logs for details.",
|
|
225
|
+
ai_guidance=ai_guidance,
|
|
226
|
+
troubleshooting=troubleshooting,
|
|
227
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""AIGuidance and Troubleshooting frozen dataclasses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, fields
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class AIGuidance:
|
|
11
|
+
"""Structured guidance for AI agents consuming error responses.
|
|
12
|
+
|
|
13
|
+
Only ``action_required`` is mandatory. Optional fields let producers
|
|
14
|
+
provide richer context without burdening simple callers.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
action_required: str
|
|
18
|
+
command: str | None = None
|
|
19
|
+
discovery_tool: str | None = None
|
|
20
|
+
checks: list[str] | None = None
|
|
21
|
+
steps: list[str] | None = None
|
|
22
|
+
optimization_tips: list[str] | None = None
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
"""Serialize to dict, excluding None-valued optional fields."""
|
|
26
|
+
return {
|
|
27
|
+
f.name: getattr(self, f.name)
|
|
28
|
+
for f in fields(self)
|
|
29
|
+
if getattr(self, f.name) is not None
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Troubleshooting:
|
|
35
|
+
"""Ordered human-readable troubleshooting steps.
|
|
36
|
+
|
|
37
|
+
``steps`` must contain at least one entry.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
steps: list[str]
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
if not self.steps:
|
|
44
|
+
msg = "Troubleshooting requires at least one step."
|
|
45
|
+
raise ValueError(msg)
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
"""Serialize to dict."""
|
|
49
|
+
return {"steps": list(self.steps)}
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""ToolResult typed envelope — success/failure with data/error fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from actionable_errors.error import ActionableError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ToolResult:
|
|
13
|
+
"""Structured envelope for MCP tool responses.
|
|
14
|
+
|
|
15
|
+
Use the ``.ok()`` and ``.fail()`` factory classmethods instead of
|
|
16
|
+
constructing directly.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
success: bool
|
|
20
|
+
data: Any | None = None
|
|
21
|
+
error: str | None = None
|
|
22
|
+
error_type: str | None = None
|
|
23
|
+
suggestion: str | None = None
|
|
24
|
+
ai_guidance: dict[str, Any] | None = None
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
# Factories
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def ok(
|
|
32
|
+
cls,
|
|
33
|
+
data: Any | None = None,
|
|
34
|
+
*,
|
|
35
|
+
suggestion: str | None = None,
|
|
36
|
+
ai_guidance: dict[str, Any] | None = None,
|
|
37
|
+
) -> ToolResult:
|
|
38
|
+
"""Create a success result."""
|
|
39
|
+
return cls(
|
|
40
|
+
success=True,
|
|
41
|
+
data=data,
|
|
42
|
+
suggestion=suggestion,
|
|
43
|
+
ai_guidance=ai_guidance,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def fail(
|
|
48
|
+
cls,
|
|
49
|
+
error: str | ActionableError,
|
|
50
|
+
*,
|
|
51
|
+
suggestion: str | None = None,
|
|
52
|
+
ai_guidance: dict[str, Any] | None = None,
|
|
53
|
+
) -> ToolResult:
|
|
54
|
+
"""Create a failure result from a string or :class:`ActionableError`.
|
|
55
|
+
|
|
56
|
+
When *error* is an ``ActionableError``, ``error_type`` and
|
|
57
|
+
``suggestion`` are extracted automatically. An explicit
|
|
58
|
+
*suggestion* kwarg overrides the one carried by the error.
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(error, ActionableError):
|
|
61
|
+
return cls(
|
|
62
|
+
success=False,
|
|
63
|
+
error=error.error,
|
|
64
|
+
error_type=str(error.error_type),
|
|
65
|
+
suggestion=suggestion if suggestion is not None else error.suggestion,
|
|
66
|
+
ai_guidance=ai_guidance,
|
|
67
|
+
)
|
|
68
|
+
return cls(
|
|
69
|
+
success=False,
|
|
70
|
+
error=error,
|
|
71
|
+
suggestion=suggestion,
|
|
72
|
+
ai_guidance=ai_guidance,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Serialization
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
"""Serialize to dict, excluding None-valued optional fields."""
|
|
81
|
+
d: dict[str, Any] = {"success": self.success}
|
|
82
|
+
if self.data is not None:
|
|
83
|
+
d["data"] = self.data
|
|
84
|
+
if self.error is not None:
|
|
85
|
+
d["error"] = self.error
|
|
86
|
+
if self.error_type is not None:
|
|
87
|
+
d["error_type"] = self.error_type
|
|
88
|
+
if self.suggestion is not None:
|
|
89
|
+
d["suggestion"] = self.suggestion
|
|
90
|
+
if self.ai_guidance is not None:
|
|
91
|
+
d["ai_guidance"] = self.ai_guidance
|
|
92
|
+
return d
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Credential redaction — regex-based, 8+ patterns, configurable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class _Pattern:
|
|
11
|
+
"""Internal representation of a registered redaction pattern."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
regex: re.Pattern[str]
|
|
15
|
+
replacement: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ------------------------------------------------------------------ #
|
|
19
|
+
# Built-in patterns #
|
|
20
|
+
# ------------------------------------------------------------------ #
|
|
21
|
+
|
|
22
|
+
_BUILTIN_PATTERNS: list[_Pattern] = [
|
|
23
|
+
# password="...", password=..., Password=...
|
|
24
|
+
_Pattern(
|
|
25
|
+
name="password_assignment",
|
|
26
|
+
regex=re.compile(
|
|
27
|
+
r'((?:password|passwd|pwd)\s*[=:]\s*)"?([^";,\s]+)"?',
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
),
|
|
30
|
+
replacement=r"\1***",
|
|
31
|
+
),
|
|
32
|
+
# api_key="...", apikey=...
|
|
33
|
+
_Pattern(
|
|
34
|
+
name="api_key_assignment",
|
|
35
|
+
regex=re.compile(
|
|
36
|
+
r'((?:api[_-]?key)\s*[=:]\s*)"?([^";,\s]+)"?',
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
),
|
|
39
|
+
replacement=r"\1***",
|
|
40
|
+
),
|
|
41
|
+
# token="...", access_token=...
|
|
42
|
+
_Pattern(
|
|
43
|
+
name="token_assignment",
|
|
44
|
+
regex=re.compile(
|
|
45
|
+
r'((?:token|access_token)\s*[=:]\s*)"?([^";,\s]+)"?',
|
|
46
|
+
re.IGNORECASE,
|
|
47
|
+
),
|
|
48
|
+
replacement=r"\1***",
|
|
49
|
+
),
|
|
50
|
+
# client_secret="...", secret=...
|
|
51
|
+
_Pattern(
|
|
52
|
+
name="secret_assignment",
|
|
53
|
+
regex=re.compile(
|
|
54
|
+
r'((?:(?:client_)?secret)\s*[=:]\s*)"?([^";,\s]+)"?',
|
|
55
|
+
re.IGNORECASE,
|
|
56
|
+
),
|
|
57
|
+
replacement=r"\1***",
|
|
58
|
+
),
|
|
59
|
+
# Authorization: Bearer <token>
|
|
60
|
+
_Pattern(
|
|
61
|
+
name="bearer_token",
|
|
62
|
+
regex=re.compile(
|
|
63
|
+
r"(Bearer\s+)\S+",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
),
|
|
66
|
+
replacement=r"\1***",
|
|
67
|
+
),
|
|
68
|
+
# SAS token sig= parameter
|
|
69
|
+
_Pattern(
|
|
70
|
+
name="sas_signature",
|
|
71
|
+
regex=re.compile(
|
|
72
|
+
r"(sig=)[^&\s]+",
|
|
73
|
+
re.IGNORECASE,
|
|
74
|
+
),
|
|
75
|
+
replacement=r"\1***",
|
|
76
|
+
),
|
|
77
|
+
# Connection string Password=...;
|
|
78
|
+
_Pattern(
|
|
79
|
+
name="connection_string_password",
|
|
80
|
+
regex=re.compile(
|
|
81
|
+
r"(Password\s*=\s*)([^;]+)",
|
|
82
|
+
re.IGNORECASE,
|
|
83
|
+
),
|
|
84
|
+
replacement=r"\1***",
|
|
85
|
+
),
|
|
86
|
+
# AccountKey=...;
|
|
87
|
+
_Pattern(
|
|
88
|
+
name="account_key",
|
|
89
|
+
regex=re.compile(
|
|
90
|
+
r"(AccountKey\s*=\s*)([^;]+)",
|
|
91
|
+
re.IGNORECASE,
|
|
92
|
+
),
|
|
93
|
+
replacement=r"\1***",
|
|
94
|
+
),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class CredentialSanitizer:
|
|
100
|
+
"""Regex-based credential sanitizer with built-in + custom patterns."""
|
|
101
|
+
|
|
102
|
+
_patterns: list[_Pattern] = field(default_factory=lambda: list(_BUILTIN_PATTERNS))
|
|
103
|
+
|
|
104
|
+
def register_pattern(
|
|
105
|
+
self,
|
|
106
|
+
name: str,
|
|
107
|
+
pattern: str | re.Pattern[str],
|
|
108
|
+
replacement: str = "***",
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Register an additional redaction pattern.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
name:
|
|
115
|
+
Human-readable label for logging/debugging.
|
|
116
|
+
pattern:
|
|
117
|
+
Raw string or compiled ``re.Pattern``.
|
|
118
|
+
replacement:
|
|
119
|
+
Replacement text (may use back-references).
|
|
120
|
+
"""
|
|
121
|
+
compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
|
|
122
|
+
self._patterns.append(_Pattern(name=name, regex=compiled, replacement=replacement))
|
|
123
|
+
|
|
124
|
+
def sanitize(self, text: str) -> str:
|
|
125
|
+
"""Apply all registered patterns to *text* and return the redacted result."""
|
|
126
|
+
for p in self._patterns:
|
|
127
|
+
text = p.regex.sub(p.replacement, text)
|
|
128
|
+
return text
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------ #
|
|
132
|
+
# Module-level convenience API (uses a shared default instance) #
|
|
133
|
+
# ------------------------------------------------------------------ #
|
|
134
|
+
|
|
135
|
+
_default = CredentialSanitizer()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sanitize(text: str) -> str:
|
|
139
|
+
"""Redact credentials from *text* using built-in patterns."""
|
|
140
|
+
return _default.sanitize(text)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def register_pattern(
|
|
144
|
+
name: str,
|
|
145
|
+
pattern: str | re.Pattern[str],
|
|
146
|
+
replacement: str = "***",
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Register an additional redaction pattern on the default sanitizer."""
|
|
149
|
+
_default.register_pattern(name=name, pattern=pattern, replacement=replacement)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""ErrorType base categories — extensible by consumers via standard Python enum extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorType(StrEnum):
|
|
9
|
+
"""Broad error categories for routing decisions.
|
|
10
|
+
|
|
11
|
+
Consumers extend this via standard Python enum inheritance::
|
|
12
|
+
|
|
13
|
+
class MyErrorType(ErrorType):
|
|
14
|
+
EMBEDDING = "embedding"
|
|
15
|
+
RATE_LIMIT = "rate_limit"
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
AUTHENTICATION = "authentication"
|
|
19
|
+
CONFIGURATION = "configuration"
|
|
20
|
+
CONNECTION = "connection"
|
|
21
|
+
TIMEOUT = "timeout"
|
|
22
|
+
PERMISSION = "permission"
|
|
23
|
+
VALIDATION = "validation"
|
|
24
|
+
NOT_FOUND = "not_found"
|
|
25
|
+
INTERNAL = "internal"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: actionable-errors
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Three-audience error framework: typed errors for code, actionable suggestions for humans, tool guidance for AI agents
|
|
5
|
+
Project-URL: Repository, https://github.com/grimlor/actionable-errors
|
|
6
|
+
Author: grimlor
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: mypy<2,>=1.13; extra == 'dev'
|
|
19
|
+
Requires-Dist: pre-commit<5,>=4; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-cov<7,>=6; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest<9,>=8; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff<1,>=0.8; extra == 'dev'
|
|
24
|
+
Requires-Dist: taskipy<2,>=1.14; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# actionable-errors
|
|
28
|
+
|
|
29
|
+
Three-audience error framework for Python.
|
|
30
|
+
|
|
31
|
+
Every `ActionableError` speaks to three audiences simultaneously:
|
|
32
|
+
|
|
33
|
+
| Audience | Uses | Provided by |
|
|
34
|
+
|----------|------|-------------|
|
|
35
|
+
| **Calling code** | Typed `ErrorType` for routing (retry? escalate? ignore?) | `ErrorType(StrEnum)` — 8 base categories |
|
|
36
|
+
| **Human operator** | `suggestion` + `Troubleshooting` steps | Frozen dataclasses with actionable text |
|
|
37
|
+
| **AI agent** | `AIGuidance` with concrete next tool calls | Frozen dataclass with tool suggestions |
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install actionable-errors
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Zero runtime dependencies** — stdlib only. Sits at the bottom of every dependency tree.
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from actionable_errors import (
|
|
51
|
+
ActionableError,
|
|
52
|
+
AIGuidance,
|
|
53
|
+
ToolResult,
|
|
54
|
+
from_exception,
|
|
55
|
+
sanitize,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Domain-specific factory with built-in defaults
|
|
59
|
+
error = ActionableError.authentication(
|
|
60
|
+
service="Azure DevOps",
|
|
61
|
+
raw_error="401 Unauthorized",
|
|
62
|
+
)
|
|
63
|
+
print(error.suggestion) # "Check your credentials and try again."
|
|
64
|
+
print(error.ai_guidance) # AIGuidance(action_required="Re-authenticate", command="az login")
|
|
65
|
+
|
|
66
|
+
# Auto-classify any exception
|
|
67
|
+
try:
|
|
68
|
+
raise ConnectionError("Connection refused")
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
ae = from_exception(exc, service="Kusto", operation="query")
|
|
71
|
+
print(ae.error_type) # "connection"
|
|
72
|
+
|
|
73
|
+
# Typed result envelope for MCP tool responses
|
|
74
|
+
result = ToolResult.ok(data={"items": 42})
|
|
75
|
+
result = ToolResult.fail(error=error) # extracts error_type + suggestion
|
|
76
|
+
|
|
77
|
+
# Credential sanitization
|
|
78
|
+
clean = sanitize('password="hunter2" token=abc123')
|
|
79
|
+
# → 'password="***" token=***'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Extending ErrorType
|
|
83
|
+
|
|
84
|
+
Python `StrEnum` can't be subclassed once it has members, so extend via composition:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from enum import StrEnum
|
|
88
|
+
from actionable_errors import ActionableError
|
|
89
|
+
|
|
90
|
+
class RAGErrorType(StrEnum):
|
|
91
|
+
EMBEDDING = "embedding"
|
|
92
|
+
INDEX = "index"
|
|
93
|
+
|
|
94
|
+
error = ActionableError(
|
|
95
|
+
error="Vector store unavailable",
|
|
96
|
+
error_type=RAGErrorType.INDEX,
|
|
97
|
+
service="pinecone",
|
|
98
|
+
suggestion="Check Pinecone cluster status.",
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Factories
|
|
103
|
+
|
|
104
|
+
Eight domain-specific factories with sensible defaults:
|
|
105
|
+
|
|
106
|
+
| Factory | Key Parameters |
|
|
107
|
+
|---------|---------------|
|
|
108
|
+
| `.authentication(service, raw_error)` | Default suggestion + AI guidance |
|
|
109
|
+
| `.configuration(field_name, reason)` | — |
|
|
110
|
+
| `.connection(service, url, raw_error)` | — |
|
|
111
|
+
| `.timeout(service, operation, timeout_seconds)` | — |
|
|
112
|
+
| `.permission(service, resource, raw_error)` | — |
|
|
113
|
+
| `.validation(service, field_name, reason)` | — |
|
|
114
|
+
| `.not_found(service, resource_type, resource_id, raw_error)` | — |
|
|
115
|
+
| `.internal(service, operation, raw_error)` | — |
|
|
116
|
+
|
|
117
|
+
All factories accept optional `suggestion`, `ai_guidance`, and `troubleshooting` kwargs.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Install dev dependencies
|
|
123
|
+
uv sync --extra dev
|
|
124
|
+
|
|
125
|
+
# Run all checks
|
|
126
|
+
task check # lint → type → test (90 tests, 100% coverage)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
actionable_errors/__init__.py,sha256=YmKO5TqKSjn4wZNYOXVhS85coSLeGovLlYiqRIzfb8M,897
|
|
2
|
+
actionable_errors/classifier.py,sha256=hAogr_MsCK2L_rt8LzuBIe8YPexkerN3-cFoVKhF2u8,2034
|
|
3
|
+
actionable_errors/error.py,sha256=NcN472xNNCIwVT2lmRfR8l4ShKfRxojUnE8YQwhfLFE,7529
|
|
4
|
+
actionable_errors/guidance.py,sha256=3jjbZB5xoqYpSLn7T3xX98f2eBur7pMUh9G1DdmisP0,1355
|
|
5
|
+
actionable_errors/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
actionable_errors/result.py,sha256=yvw1z-juXj9mI9-y27QkG0j2AiLxGTpoXe-x3C-1xYk,2882
|
|
7
|
+
actionable_errors/sanitizer.py,sha256=fj8MUJ60nGpjbrDNVE7laTRnc6thAJMhN9wNJhVj6gw,4189
|
|
8
|
+
actionable_errors/types.py,sha256=SiQHzlibjpBAdLXVJPLrP-h7zPg58Drv5fyNgjqXREM,669
|
|
9
|
+
actionable_errors-0.1.0.dist-info/METADATA,sha256=tqjeRDYiMEpWCffBZdV77TMW-wLNV-6lCI0h1WkVla0,3994
|
|
10
|
+
actionable_errors-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
actionable_errors-0.1.0.dist-info/licenses/LICENSE,sha256=PdpS05B102voqmZt6nyk_9xpy4CGoToVhf4mkqe0yIs,1064
|
|
12
|
+
actionable_errors-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 grimlor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|