modgud 0.2.1__tar.gz
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.
- modgud-0.2.1/LICENSE +21 -0
- modgud-0.2.1/PKG-INFO +18 -0
- modgud-0.2.1/modgud/__init__.py +28 -0
- modgud-0.2.1/modgud/guarded_expression/__init__.py +13 -0
- modgud-0.2.1/modgud/guarded_expression/ast_transform.py +230 -0
- modgud-0.2.1/modgud/guarded_expression/common_guards.py +164 -0
- modgud-0.2.1/modgud/guarded_expression/decorator.py +130 -0
- modgud-0.2.1/modgud/guarded_expression/guard_runtime.py +73 -0
- modgud-0.2.1/modgud/py.typed +0 -0
- modgud-0.2.1/modgud/shared/__init__.py +21 -0
- modgud-0.2.1/modgud/shared/errors.py +51 -0
- modgud-0.2.1/modgud/shared/types.py +10 -0
- modgud-0.2.1/pyproject.toml +92 -0
modgud-0.2.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [2025] [Steven Miers]
|
|
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.
|
modgud-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modgud
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Guard clauses are validation checks at the beginning of functions that exit early when preconditions aren't met. They prevent deeply nested conditional logic and make the "happy path" of your code more readable.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: guards,validation,decorators,defensive-programming,single-return-point,implicit-return
|
|
8
|
+
Author: steven.miers@gmail.com
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Topic :: System :: Systems Administration
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Modgud - Modern Guard Clauses for Python.
|
|
2
|
+
|
|
3
|
+
A library for implementing guard clause decorators with single return point architecture.
|
|
4
|
+
|
|
5
|
+
Primary API:
|
|
6
|
+
- guarded_expression: Unified decorator combining guards + implicit return
|
|
7
|
+
- CommonGuards: Pre-built guard validators
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .guarded_expression import CommonGuards, guarded_expression
|
|
11
|
+
from .shared.errors import (
|
|
12
|
+
ExplicitReturnDisallowedError,
|
|
13
|
+
GuardClauseError,
|
|
14
|
+
ImplicitReturnError,
|
|
15
|
+
MissingImplicitReturnError,
|
|
16
|
+
UnsupportedConstructError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = '0.2.0'
|
|
20
|
+
__all__ = [
|
|
21
|
+
'guarded_expression',
|
|
22
|
+
'CommonGuards',
|
|
23
|
+
'GuardClauseError',
|
|
24
|
+
'ImplicitReturnError',
|
|
25
|
+
'ExplicitReturnDisallowedError',
|
|
26
|
+
'MissingImplicitReturnError',
|
|
27
|
+
'UnsupportedConstructError',
|
|
28
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Guarded Expression - Unified decorator combining guard clauses with implicit return.
|
|
2
|
+
|
|
3
|
+
This package provides the primary interface for the modgud library, unifying
|
|
4
|
+
guard clause validation and implicit return transformation into a single decorator.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .common_guards import CommonGuards
|
|
8
|
+
from .decorator import guarded_expression
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'guarded_expression',
|
|
12
|
+
'CommonGuards',
|
|
13
|
+
]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Pure AST transformation logic for implicit return functionality.
|
|
2
|
+
|
|
3
|
+
Extracts the AST rewriting logic from implicit_return into a composable,
|
|
4
|
+
testable pure function that transforms function nodes to enforce implicit
|
|
5
|
+
return semantics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from ..shared.errors import (
|
|
14
|
+
ExplicitReturnDisallowedError,
|
|
15
|
+
MissingImplicitReturnError,
|
|
16
|
+
UnsupportedConstructError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _NoExplicitReturnChecker(ast.NodeVisitor):
|
|
21
|
+
|
|
22
|
+
"""Check for explicit return statements in top-level function body.
|
|
23
|
+
|
|
24
|
+
Ensures no explicit `return` appears in the *top-level* body of the decorated
|
|
25
|
+
function. We deliberately do NOT descend into nested function/async def/lambda
|
|
26
|
+
bodies so those can use normal Python semantics independently.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.found: Optional[Tuple[int, int]] = None # (lineno, col)
|
|
31
|
+
|
|
32
|
+
def visit_Return(self, node: ast.Return) -> None: # type: ignore[override]
|
|
33
|
+
# If we are called, it means we're at top-level (we never recurse into nested defs)
|
|
34
|
+
self.found = (getattr(node, 'lineno', 0), getattr(node, 'col_offset', 0))
|
|
35
|
+
|
|
36
|
+
# Block traversal into nested defs/lambdas
|
|
37
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # type: ignore[override]
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # type: ignore[override]
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
def visit_Lambda(self, node: ast.Lambda) -> None: # type: ignore[override]
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _TailRewriter:
|
|
48
|
+
|
|
49
|
+
"""Rewrite tail positions to assign to implicit result variable.
|
|
50
|
+
|
|
51
|
+
Rewrites *tail positions* (the final statement of a block that determines the
|
|
52
|
+
branch's return value) by replacing a final expression with an assignment to a
|
|
53
|
+
hidden result variable. After transforming the top-level body, we append a single
|
|
54
|
+
`return __implicit_result` to the function.
|
|
55
|
+
|
|
56
|
+
Supported tail forms:
|
|
57
|
+
- Expr -> assign to result
|
|
58
|
+
- If -> both body and orelse must set result via their own tails
|
|
59
|
+
- Try -> body and each except must set result; else (if present) also sets it
|
|
60
|
+
- Match -> each case body must set result via its tail
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, result_name: str) -> None:
|
|
64
|
+
self.result_name = result_name
|
|
65
|
+
|
|
66
|
+
def _assign(self, value: ast.expr) -> ast.Assign:
|
|
67
|
+
return ast.Assign(targets=[ast.Name(id=self.result_name, ctx=ast.Store())], value=value)
|
|
68
|
+
|
|
69
|
+
def rewrite_tail_stmt(self, stmt: ast.stmt) -> List[ast.stmt]:
|
|
70
|
+
"""Rewrite tail statement to assign to result variable.
|
|
71
|
+
|
|
72
|
+
Return a list of statements that replace the given tail statement,
|
|
73
|
+
ensuring the result variable is set on all runtime paths.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(stmt, ast.Expr):
|
|
76
|
+
return [self._assign(stmt.value)]
|
|
77
|
+
|
|
78
|
+
if isinstance(stmt, ast.If):
|
|
79
|
+
if not stmt.orelse:
|
|
80
|
+
raise MissingImplicitReturnError(
|
|
81
|
+
'If without else at tail position must have an else clause.',
|
|
82
|
+
getattr(stmt, 'lineno', None),
|
|
83
|
+
getattr(stmt, 'col_offset', None),
|
|
84
|
+
)
|
|
85
|
+
stmt.body = self.rewrite_block(stmt.body)
|
|
86
|
+
stmt.orelse = self.rewrite_block(stmt.orelse)
|
|
87
|
+
return [stmt]
|
|
88
|
+
|
|
89
|
+
if isinstance(stmt, ast.Try):
|
|
90
|
+
# Body must produce a value
|
|
91
|
+
stmt.body = self.rewrite_block(stmt.body)
|
|
92
|
+
# Each except must produce a value
|
|
93
|
+
for h in stmt.handlers:
|
|
94
|
+
h.body = self.rewrite_block(h.body)
|
|
95
|
+
# Else (if present) also produces a value (it runs on success)
|
|
96
|
+
if stmt.orelse:
|
|
97
|
+
stmt.orelse = self.rewrite_block(stmt.orelse)
|
|
98
|
+
# finally is allowed, but it shouldn't need to assign result; it runs after
|
|
99
|
+
# the above assignments. We leave it unchanged.
|
|
100
|
+
return [stmt]
|
|
101
|
+
|
|
102
|
+
if isinstance(stmt, ast.Match):
|
|
103
|
+
# All cases must set the result
|
|
104
|
+
for case in stmt.cases:
|
|
105
|
+
if not case.body:
|
|
106
|
+
raise MissingImplicitReturnError(
|
|
107
|
+
'Empty match case body cannot yield a value.',
|
|
108
|
+
getattr(stmt, 'lineno', None),
|
|
109
|
+
getattr(stmt, 'col_offset', None),
|
|
110
|
+
)
|
|
111
|
+
case.body = self.rewrite_block(case.body)
|
|
112
|
+
return [stmt]
|
|
113
|
+
|
|
114
|
+
if isinstance(stmt, ast.Pass):
|
|
115
|
+
raise MissingImplicitReturnError(
|
|
116
|
+
'Pass statement cannot yield a value.',
|
|
117
|
+
getattr(stmt, 'lineno', None),
|
|
118
|
+
getattr(stmt, 'col_offset', None),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
raise UnsupportedConstructError(
|
|
122
|
+
f'Unsupported tail construct: {type(stmt).__name__}',
|
|
123
|
+
getattr(stmt, 'lineno', None),
|
|
124
|
+
getattr(stmt, 'col_offset', None),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def rewrite_block(self, body: List[ast.stmt]) -> List[ast.stmt]:
|
|
128
|
+
if not body:
|
|
129
|
+
raise MissingImplicitReturnError('Empty block where a value is required.')
|
|
130
|
+
*init, last = body
|
|
131
|
+
new_last = self.rewrite_tail_stmt(last)
|
|
132
|
+
return [*init, *new_last]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def transform_function_ast(fn_node: ast.AST, func_name: str) -> ast.AST:
|
|
136
|
+
"""Transform function AST to enforce implicit return semantics.
|
|
137
|
+
|
|
138
|
+
Pure function that transforms a FunctionDef/AsyncFunctionDef AST node to enforce
|
|
139
|
+
implicit return semantics.
|
|
140
|
+
|
|
141
|
+
Steps:
|
|
142
|
+
1. Verify no explicit `return` at top-level
|
|
143
|
+
2. Rewrite tail of the function body to assign to a hidden result var
|
|
144
|
+
3. Append a single `return __implicit_result`
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
fn_node: The function AST node to transform
|
|
148
|
+
func_name: Name of the function (for error messages)
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The transformed AST node with implicit return semantics
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ExplicitReturnDisallowedError: If explicit return found at top level
|
|
155
|
+
MissingImplicitReturnError: If a block cannot yield a value
|
|
156
|
+
UnsupportedConstructError: If an unsupported construct is found
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
assert isinstance(fn_node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
160
|
+
body = fn_node.body
|
|
161
|
+
|
|
162
|
+
# Check for explicit return at top-level
|
|
163
|
+
checker = _NoExplicitReturnChecker()
|
|
164
|
+
# Visit only top-level statements
|
|
165
|
+
for stmt in body:
|
|
166
|
+
checker.visit(stmt)
|
|
167
|
+
if checker.found is not None:
|
|
168
|
+
line, col = checker.found
|
|
169
|
+
raise ExplicitReturnDisallowedError(
|
|
170
|
+
f"Explicit `return` is disallowed in '@guarded_expression' function '{func_name}' with implicit_return=True.",
|
|
171
|
+
line,
|
|
172
|
+
col,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
result_name = '__implicit_result'
|
|
176
|
+
rewriter = _TailRewriter(result_name)
|
|
177
|
+
new_body = rewriter.rewrite_block(body)
|
|
178
|
+
# Append the single return
|
|
179
|
+
new_body.append(ast.Return(value=ast.Name(id=result_name, ctx=ast.Load())))
|
|
180
|
+
fn_node.body = new_body
|
|
181
|
+
return fn_node
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _TopLevelTransformer(ast.NodeTransformer):
|
|
185
|
+
|
|
186
|
+
"""Transform only the target function definition.
|
|
187
|
+
|
|
188
|
+
Applies transformation only to the *decorated* function definition that we parsed.
|
|
189
|
+
We rely on inspect.getsource(func) returning just that function (common in modules).
|
|
190
|
+
Strips all decorators to prevent re-application during exec.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, target_name: str) -> None:
|
|
194
|
+
self.target_name = target_name
|
|
195
|
+
super().__init__()
|
|
196
|
+
|
|
197
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST: # type: ignore[override]
|
|
198
|
+
if node.name == self.target_name:
|
|
199
|
+
node.decorator_list = []
|
|
200
|
+
return transform_function_ast(node, node.name)
|
|
201
|
+
return node
|
|
202
|
+
|
|
203
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST: # type: ignore[override]
|
|
204
|
+
if node.name == self.target_name:
|
|
205
|
+
node.decorator_list = []
|
|
206
|
+
return transform_function_ast(node, node.name)
|
|
207
|
+
return node
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def apply_implicit_return_transform(func_source: str, func_name: str) -> Tuple[ast.Module, str]:
|
|
211
|
+
"""Pure function that applies implicit return transformation to function source code.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
func_source: The source code of the function (dedented)
|
|
215
|
+
func_name: The name of the function to transform
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Tuple of (transformed_ast, compiled_code_object)
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ExplicitReturnDisallowedError: If explicit return found
|
|
222
|
+
MissingImplicitReturnError: If a block cannot yield a value
|
|
223
|
+
UnsupportedConstructError: If an unsupported construct is found
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
tree = ast.parse(func_source)
|
|
227
|
+
transformer = _TopLevelTransformer(func_name)
|
|
228
|
+
new_tree = transformer.visit(tree)
|
|
229
|
+
ast.fix_missing_locations(new_tree)
|
|
230
|
+
return new_tree, f'<{func_name}-implicit>'
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Common guard validators for typical validation scenarios.
|
|
2
|
+
|
|
3
|
+
Provides pre-built guard functions through the CommonGuards class for
|
|
4
|
+
common validation patterns like not_none, positive, in_range, etc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Optional, Union
|
|
9
|
+
|
|
10
|
+
from ..shared.types import GuardFunction
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommonGuards:
|
|
14
|
+
|
|
15
|
+
"""Pre-defined common guard clauses.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
@guarded_expression(
|
|
19
|
+
CommonGuards.not_empty("username"),
|
|
20
|
+
log=True
|
|
21
|
+
)
|
|
22
|
+
def create_user(username):
|
|
23
|
+
return {"username": username}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _extract_param(
|
|
28
|
+
param_name: str, position: Optional[int], args: tuple, kwargs: dict, default: Any = None
|
|
29
|
+
) -> Any:
|
|
30
|
+
"""Extract parameter value from args or kwargs.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
param_name: Name of the parameter in kwargs
|
|
34
|
+
position: Position in args (None means first arg)
|
|
35
|
+
args: Positional arguments tuple
|
|
36
|
+
kwargs: Keyword arguments dict
|
|
37
|
+
default: Default value if not found
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Parameter value or default
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
if param_name in kwargs:
|
|
44
|
+
return kwargs[param_name]
|
|
45
|
+
|
|
46
|
+
# Use explicit position if provided, else default to first arg
|
|
47
|
+
pos = position if position is not None else 0
|
|
48
|
+
if 0 <= pos < len(args):
|
|
49
|
+
return args[pos]
|
|
50
|
+
|
|
51
|
+
return default
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def not_empty(param_name: str = 'parameter', position: Optional[int] = None) -> GuardFunction:
|
|
55
|
+
"""Guard ensuring collection parameter is not empty.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
param_name: Name of the parameter to check
|
|
59
|
+
position: Optional explicit position for positional args (0-based)
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def check_not_empty(*args: Any, **kwargs: Any) -> Union[bool, str]:
|
|
64
|
+
value = CommonGuards._extract_param(param_name, position, args, kwargs, default='')
|
|
65
|
+
|
|
66
|
+
# Check if value is empty (works for strings and collections)
|
|
67
|
+
if hasattr(value, '__len__'):
|
|
68
|
+
return len(value) > 0 or f'{param_name} cannot be empty'
|
|
69
|
+
|
|
70
|
+
return bool(value) or f'{param_name} cannot be empty'
|
|
71
|
+
|
|
72
|
+
return check_not_empty
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def not_none(param_name: str = 'parameter', position: int = 0) -> GuardFunction:
|
|
76
|
+
"""Guard ensuring parameter is not None.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
param_name: Name of the parameter to check
|
|
80
|
+
position: Position for positional args (default: 0)
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def check_not_none(*args: Any, **kwargs: Any) -> Union[bool, str]:
|
|
85
|
+
value = CommonGuards._extract_param(param_name, position, args, kwargs, default=None)
|
|
86
|
+
return value is not None or f'{param_name} cannot be None'
|
|
87
|
+
|
|
88
|
+
return check_not_none
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def positive(param_name: str = 'parameter', position: int = 0) -> GuardFunction:
|
|
92
|
+
"""Guard ensuring parameter is positive.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
param_name: Name of the parameter to check
|
|
96
|
+
position: Position for positional args (default: 0)
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def check_positive(*args: Any, **kwargs: Any) -> Union[bool, str]:
|
|
101
|
+
value = CommonGuards._extract_param(param_name, position, args, kwargs, default=0)
|
|
102
|
+
return value > 0 or f'{param_name} must be positive'
|
|
103
|
+
|
|
104
|
+
return check_positive
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def in_range(
|
|
108
|
+
min_val: float, max_val: float, param_name: str = 'parameter', position: int = 0
|
|
109
|
+
) -> GuardFunction:
|
|
110
|
+
"""Guard ensuring parameter is within range [min_val, max_val].
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
min_val: Minimum value (inclusive)
|
|
114
|
+
max_val: Maximum value (inclusive)
|
|
115
|
+
param_name: Name of the parameter to check
|
|
116
|
+
position: Position for positional args (default: 0)
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def check_in_range(*args: Any, **kwargs: Any) -> Union[bool, str]:
|
|
121
|
+
value = CommonGuards._extract_param(param_name, position, args, kwargs, default=min_val - 1)
|
|
122
|
+
return min_val <= value <= max_val or f'{param_name} must be between {min_val} and {max_val}'
|
|
123
|
+
|
|
124
|
+
return check_in_range
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def type_check(
|
|
128
|
+
expected_type: type, param_name: str = 'parameter', position: int = 0
|
|
129
|
+
) -> GuardFunction:
|
|
130
|
+
"""Guard ensuring parameter matches expected type.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
expected_type: Expected type for the parameter
|
|
134
|
+
param_name: Name of the parameter to check
|
|
135
|
+
position: Position for positional args (default: 0)
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def check_type(*args: Any, **kwargs: Any) -> Union[bool, str]:
|
|
140
|
+
value = CommonGuards._extract_param(param_name, position, args, kwargs, default=None)
|
|
141
|
+
return (
|
|
142
|
+
isinstance(value, expected_type) or f'{param_name} must be of type {expected_type.__name__}'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return check_type
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def matches_pattern(
|
|
149
|
+
pattern: str, param_name: str = 'parameter', position: int = 0
|
|
150
|
+
) -> GuardFunction:
|
|
151
|
+
"""Guard ensuring string parameter matches regex pattern.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
pattern: Regular expression pattern to match
|
|
155
|
+
param_name: Name of the parameter to check
|
|
156
|
+
position: Position for positional args (default: 0)
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def check_pattern(*args: Any, **kwargs: Any) -> Union[bool, str]:
|
|
161
|
+
value = str(CommonGuards._extract_param(param_name, position, args, kwargs, default=''))
|
|
162
|
+
return re.match(pattern, value) is not None or f'{param_name} must match pattern {pattern}'
|
|
163
|
+
|
|
164
|
+
return check_pattern
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Unified guarded_expression decorator combining guards and implicit returns.
|
|
2
|
+
|
|
3
|
+
Unified guarded_expression decorator that combines guard clause validation
|
|
4
|
+
with optional implicit return transformation.
|
|
5
|
+
|
|
6
|
+
This is the primary decorator for the modgud library, unifying the functionality
|
|
7
|
+
of guard_clause and implicit_return into a single, composable decorator.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
from textwrap import dedent
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
|
|
15
|
+
from ..shared.errors import GuardClauseError, UnsupportedConstructError
|
|
16
|
+
from ..shared.types import FailureBehavior, GuardFunction
|
|
17
|
+
from .ast_transform import apply_implicit_return_transform
|
|
18
|
+
from .guard_runtime import check_guards, handle_failure
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class guarded_expression:
|
|
22
|
+
|
|
23
|
+
"""Unified decorator combining guard clauses with optional implicit return.
|
|
24
|
+
|
|
25
|
+
Guards are callables that return True (pass) or a string error message (fail).
|
|
26
|
+
On failure, behavior is determined by the `on_error` parameter.
|
|
27
|
+
|
|
28
|
+
Default behavior: Raises GuardClauseError on guard failure.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
*guards: Guard functions returning True or error message string
|
|
32
|
+
implicit_return: Enable implicit return transformation (default: True)
|
|
33
|
+
on_error: Failure behavior (default: GuardClauseError) - can be:
|
|
34
|
+
- Value (str, int, None, etc.): Returned on guard failure
|
|
35
|
+
- Callable: Invoked with (error_msg, *args, **kwargs), return value used
|
|
36
|
+
- Exception class: Instantiated with error message and raised
|
|
37
|
+
log: If True, log guard failures at INFO level (default: False)
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
@guarded_expression(
|
|
41
|
+
lambda x: x > 0 or "Must be positive",
|
|
42
|
+
implicit_return=True,
|
|
43
|
+
on_error=GuardClauseError
|
|
44
|
+
)
|
|
45
|
+
def safe_divide(x):
|
|
46
|
+
result = 100 / x
|
|
47
|
+
# No explicit return needed when implicit_return=True
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*guards: GuardFunction,
|
|
54
|
+
implicit_return: bool = True,
|
|
55
|
+
on_error: FailureBehavior = GuardClauseError,
|
|
56
|
+
log: bool = False,
|
|
57
|
+
):
|
|
58
|
+
"""Initialize the guarded_expression decorator.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
*guards: Variable number of guard functions
|
|
62
|
+
implicit_return: Enable implicit return transformation
|
|
63
|
+
on_error: Behavior on guard failure
|
|
64
|
+
log: Enable logging of guard failures
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
self.guards = guards
|
|
68
|
+
self.implicit_return_enabled = implicit_return
|
|
69
|
+
self.on_error = on_error
|
|
70
|
+
self.log = log
|
|
71
|
+
|
|
72
|
+
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
73
|
+
"""Apply guard wrapping and optional implicit return transformation."""
|
|
74
|
+
return (
|
|
75
|
+
self._apply_implicit_return(func)
|
|
76
|
+
if self.implicit_return_enabled
|
|
77
|
+
else self._wrap_with_guards(func)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _apply_implicit_return(self, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
81
|
+
"""Apply implicit return transformation to the function."""
|
|
82
|
+
# Extract and parse source
|
|
83
|
+
try:
|
|
84
|
+
source = dedent(inspect.getsource(func))
|
|
85
|
+
except OSError as e:
|
|
86
|
+
raise UnsupportedConstructError(
|
|
87
|
+
'Source unavailable — guarded_expression with implicit_return=True requires importable source code.'
|
|
88
|
+
) from e
|
|
89
|
+
|
|
90
|
+
# Transform the AST
|
|
91
|
+
new_tree, filename = apply_implicit_return_transform(source, func.__name__)
|
|
92
|
+
|
|
93
|
+
# Compile the transformed code in the original global scope
|
|
94
|
+
env = func.__globals__.copy()
|
|
95
|
+
code = compile(new_tree, filename=filename, mode='exec')
|
|
96
|
+
exec(code, env)
|
|
97
|
+
|
|
98
|
+
transformed = env[func.__name__]
|
|
99
|
+
|
|
100
|
+
# Wrap with guards and return
|
|
101
|
+
return self._wrap_with_guards(transformed, preserve_metadata_from=func)
|
|
102
|
+
|
|
103
|
+
def _wrap_with_guards(
|
|
104
|
+
self, func: Callable[..., Any], preserve_metadata_from: Optional[Callable[..., Any]] = None
|
|
105
|
+
) -> Callable[..., Any]:
|
|
106
|
+
"""Wrap the function with guard checking logic."""
|
|
107
|
+
metadata_source = preserve_metadata_from if preserve_metadata_from is not None else func
|
|
108
|
+
|
|
109
|
+
@functools.wraps(metadata_source)
|
|
110
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
111
|
+
# Check guards if any are defined
|
|
112
|
+
if self.guards:
|
|
113
|
+
error_msg = check_guards(self.guards, args, kwargs)
|
|
114
|
+
if error_msg is not None:
|
|
115
|
+
# Handle failure
|
|
116
|
+
result, exception_to_raise = handle_failure(
|
|
117
|
+
error_msg, self.on_error, func.__name__, args, kwargs, self.log
|
|
118
|
+
)
|
|
119
|
+
# Raise exception if configured
|
|
120
|
+
if exception_to_raise:
|
|
121
|
+
raise exception_to_raise
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
# All guards passed - execute the function
|
|
125
|
+
return func(*args, **kwargs)
|
|
126
|
+
|
|
127
|
+
# Preserve explicit annotations for typing/IDE help
|
|
128
|
+
wrapper.__signature__ = inspect.signature(metadata_source) # type: ignore[attr-defined]
|
|
129
|
+
wrapper.__annotations__ = getattr(metadata_source, '__annotations__', {})
|
|
130
|
+
return wrapper
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Pure guard checking logic extracted from guard_clause.
|
|
2
|
+
|
|
3
|
+
Provides composable functions for evaluating guards and handling failures
|
|
4
|
+
without decorator-specific concerns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ..shared.types import FailureBehavior, GuardFunction
|
|
11
|
+
|
|
12
|
+
# Module-level logger for guard failures
|
|
13
|
+
_logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_guards(
|
|
17
|
+
guards: Tuple[GuardFunction, ...], args: Tuple[Any, ...], kwargs: dict
|
|
18
|
+
) -> Optional[str]:
|
|
19
|
+
"""Pure function that evaluates all guards sequentially.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
guards: Tuple of guard functions to evaluate
|
|
23
|
+
args: Positional arguments passed to the decorated function
|
|
24
|
+
kwargs: Keyword arguments passed to the decorated function
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
None if all guards pass, or error message string if any guard fails
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
for guard in guards:
|
|
31
|
+
guard_result = guard(*args, **kwargs)
|
|
32
|
+
# Handle guard failure
|
|
33
|
+
if guard_result is not True:
|
|
34
|
+
return guard_result if isinstance(guard_result, str) else 'Guard clause failed'
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def handle_failure(
|
|
39
|
+
error_msg: str,
|
|
40
|
+
on_error: FailureBehavior,
|
|
41
|
+
func_name: str,
|
|
42
|
+
args: Tuple[Any, ...],
|
|
43
|
+
kwargs: dict,
|
|
44
|
+
log_enabled: bool,
|
|
45
|
+
) -> Tuple[Any, Optional[BaseException]]:
|
|
46
|
+
"""Pure function that handles guard failure based on on_error configuration.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
error_msg: The error message from the failed guard
|
|
50
|
+
on_error: The failure behavior configuration
|
|
51
|
+
func_name: Name of the decorated function (for logging)
|
|
52
|
+
args: Positional arguments passed to the decorated function
|
|
53
|
+
kwargs: Keyword arguments passed to the decorated function
|
|
54
|
+
log_enabled: Whether to log the failure
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (return_value, exception_to_raise)
|
|
58
|
+
- If exception should be raised: (None, exception_instance)
|
|
59
|
+
- If value should be returned: (value, None)
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
# Log if enabled
|
|
63
|
+
if log_enabled:
|
|
64
|
+
_logger.info(f'Guard clause failed in {func_name}: {error_msg}')
|
|
65
|
+
|
|
66
|
+
# Handle failure based on on_error type
|
|
67
|
+
if isinstance(on_error, type) and issubclass(on_error, BaseException):
|
|
68
|
+
return None, on_error(error_msg)
|
|
69
|
+
|
|
70
|
+
if callable(on_error):
|
|
71
|
+
return on_error(error_msg, *args, **kwargs), None # type: ignore[call-arg]
|
|
72
|
+
|
|
73
|
+
return on_error, None
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared types and errors for the modgud library."""
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
ExplicitReturnDisallowedError,
|
|
5
|
+
GuardClauseError,
|
|
6
|
+
ImplicitReturnError,
|
|
7
|
+
MissingImplicitReturnError,
|
|
8
|
+
UnsupportedConstructError,
|
|
9
|
+
)
|
|
10
|
+
from .types import FailureBehavior, FailureTypes, GuardFunction
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'GuardFunction',
|
|
14
|
+
'FailureBehavior',
|
|
15
|
+
'FailureTypes',
|
|
16
|
+
'GuardClauseError',
|
|
17
|
+
'ImplicitReturnError',
|
|
18
|
+
'ExplicitReturnDisallowedError',
|
|
19
|
+
'MissingImplicitReturnError',
|
|
20
|
+
'UnsupportedConstructError',
|
|
21
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared error classes for the modgud library."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GuardClauseError(Exception):
|
|
7
|
+
|
|
8
|
+
"""Exception raised when a guard clause fails."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImplicitReturnError(SyntaxError):
|
|
14
|
+
|
|
15
|
+
"""Base class for implicit-return related transformation errors."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, message: str, lineno: Optional[int] = None, col_offset: Optional[int] = None
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Initialize the ImplicitReturnError with location information.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
message: Error message describing the issue
|
|
24
|
+
lineno: Line number where the error occurred
|
|
25
|
+
col_offset: Column offset where the error occurred
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
if lineno is not None:
|
|
30
|
+
self.lineno = lineno # type: ignore[attr-defined]
|
|
31
|
+
if col_offset is not None:
|
|
32
|
+
self.offset = col_offset # type: ignore[attr-defined]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExplicitReturnDisallowedError(ImplicitReturnError):
|
|
36
|
+
|
|
37
|
+
"""Raised when an explicit `return` statement is found in a decorated function."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MissingImplicitReturnError(ImplicitReturnError):
|
|
41
|
+
|
|
42
|
+
"""Raised when block cannot yield a value.
|
|
43
|
+
|
|
44
|
+
Raised when a block is required to yield a value but does not end with
|
|
45
|
+
a (convertible) final expression or a supported branching structure.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UnsupportedConstructError(ImplicitReturnError):
|
|
50
|
+
|
|
51
|
+
"""Raised when an unsupported AST construct appears at a required return boundary."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared type definitions for the modgud library."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Union
|
|
4
|
+
|
|
5
|
+
# Guard function signature: (*args, **kwargs) -> True | str
|
|
6
|
+
GuardFunction = Callable[..., Union[bool, str]]
|
|
7
|
+
|
|
8
|
+
# Failure behavior types
|
|
9
|
+
FailureTypes = Union[bool, str, int, float, None, dict, list, tuple]
|
|
10
|
+
FailureBehavior = Union[FailureTypes, Callable, type]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "modgud"
|
|
3
|
+
version = "0.2.1"
|
|
4
|
+
description = "Guard clauses are validation checks at the beginning of functions that exit early when preconditions aren't met. They prevent deeply nested conditional logic and make the \"happy path\" of your code more readable."
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "steven.miers@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
|
|
11
|
+
repository = "https://github.com/terracoil/modgud"
|
|
12
|
+
homepage = "https://pypi.org/project/modgud/"
|
|
13
|
+
documentation = "https://github.com/terracoil/modgud/tree/main/docs"
|
|
14
|
+
keywords = ["guards", "validation", "decorators", "defensive-programming", "single-return-point", "implicit-return"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
"Topic :: System :: Systems Administration",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
packages = [{include = "modgud"}]
|
|
27
|
+
include = [
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md",
|
|
30
|
+
"modgud/py.typed",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
[tool.poetry.dependencies]
|
|
35
|
+
python = "^3.13"
|
|
36
|
+
# No runtime dependencies - modgud only stdlib
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
test = [
|
|
40
|
+
"pytest (>=8.4.2,<9.0.0)",
|
|
41
|
+
"pytest-cov (>=7.0.0,<8.0.0)",
|
|
42
|
+
"pytest-timeout (>=2.3.1,<3.0.0)",
|
|
43
|
+
"pytest-xdist (>=3.8.0,<4.0.0)",
|
|
44
|
+
"ruff (>=0.14.2,<0.15.0)"
|
|
45
|
+
]
|
|
46
|
+
dev = [
|
|
47
|
+
{ include-group = "test" },
|
|
48
|
+
"freyja (>=1.0.22,<2.0.0)",
|
|
49
|
+
"mypy[reports] (>=1.18.2,<2.0.0)",
|
|
50
|
+
"pre-commit (>=4.3.0,<5.0.0)",
|
|
51
|
+
"poetry-dynamic-versioning (>=1.9.1,<2.0.0)"
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.mypy]
|
|
55
|
+
cache_dir = "tmp/.mypy_cache"
|
|
56
|
+
disallow_untyped_defs = true
|
|
57
|
+
exclude = [
|
|
58
|
+
"build/",
|
|
59
|
+
"dist/",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Output
|
|
63
|
+
linecount_report = "reports/mypy/"
|
|
64
|
+
linecoverage_report = "reports/mypy/"
|
|
65
|
+
lineprecision_report = "reports/mypy/"
|
|
66
|
+
python_version = "3.13"
|
|
67
|
+
show_error_codes = true
|
|
68
|
+
verbosity=1
|
|
69
|
+
warn_return_any = true
|
|
70
|
+
warn_unused_configs = true
|
|
71
|
+
|
|
72
|
+
[tool.pytest.ini_options]
|
|
73
|
+
cache_dir = "tmp/.pytest_cache"
|
|
74
|
+
minversion = "8.0"
|
|
75
|
+
addopts = "-ra -q --strict-markers --cov=modgud --cov-report=term-missing --cov-report=html:reports/coverage --cov-report=xml:reports/coverage/converage.xml"
|
|
76
|
+
log_file = "tmp/pytest.log" # Path to the log file
|
|
77
|
+
log_file_level = "DEBUG" # Minimum level for logging to file
|
|
78
|
+
log_file_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
|
|
79
|
+
log_file_date_format = "%Y-%m-%d %H:%M:%S"
|
|
80
|
+
testpaths = ["tests"]
|
|
81
|
+
python_files = ["test_*.py"]
|
|
82
|
+
python_classes = ["Test*"]
|
|
83
|
+
python_functions = ["test_*"]
|
|
84
|
+
|
|
85
|
+
[tool.poetry-dynamic-versioning]
|
|
86
|
+
enable = false
|
|
87
|
+
vcs = "git"
|
|
88
|
+
style = "semver"
|
|
89
|
+
|
|
90
|
+
[build-system]
|
|
91
|
+
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
|
|
92
|
+
build-backend = "poetry_dynamic_versioning.backend"
|