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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.