djc-core 1.1.1__cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.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.
- djc_core/__init__.py +16 -0
- djc_core/__init__.pyi +2 -0
- djc_core/djc_core.cpython-314t-aarch64-linux-gnu.so +0 -0
- djc_core/djc_html_transformer.pyi +36 -0
- djc_core/djc_safe_eval/__init__.py +8 -0
- djc_core/djc_safe_eval/__init__.pyi +79 -0
- djc_core/djc_safe_eval/error.py +155 -0
- djc_core/djc_safe_eval/eval.py +428 -0
- djc_core/djc_safe_eval/sandbox.py +227 -0
- djc_core/py.typed +0 -0
- djc_core-1.1.1.dist-info/METADATA +240 -0
- djc_core-1.1.1.dist-info/RECORD +14 -0
- djc_core-1.1.1.dist-info/WHEEL +5 -0
- djc_core-1.1.1.dist-info/licenses/LICENSE +21 -0
djc_core/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# This file is what maturin auto-generates. But it seems maturin omits it when we have a __init__.pyi file.
|
|
2
|
+
# So we have to manually include it here.
|
|
3
|
+
# Following block of code is what maturin would've generated
|
|
4
|
+
|
|
5
|
+
from .djc_core import *
|
|
6
|
+
|
|
7
|
+
__doc__ = djc_core.__doc__
|
|
8
|
+
if hasattr(djc_core, "__all__"):
|
|
9
|
+
__all__ = djc_core.__all__
|
|
10
|
+
|
|
11
|
+
# OVERRIDES START HERE
|
|
12
|
+
# Add here any additional public API that we defined purely in Python
|
|
13
|
+
from djc_core.djc_safe_eval import safe_eval, unsafe, SecurityError
|
|
14
|
+
|
|
15
|
+
if hasattr(djc_core, "__all__"):
|
|
16
|
+
__all__ += ["safe_eval", "unsafe", "SecurityError"]
|
djc_core/__init__.pyi
ADDED
|
Binary file
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import List, Dict, Optional
|
|
2
|
+
|
|
3
|
+
def set_html_attributes(
|
|
4
|
+
html: str,
|
|
5
|
+
root_attributes: List[str],
|
|
6
|
+
all_attributes: List[str],
|
|
7
|
+
check_end_names: Optional[bool] = None,
|
|
8
|
+
watch_on_attribute: Optional[str] = None,
|
|
9
|
+
) -> tuple[str, Dict[str, List[str]]]:
|
|
10
|
+
"""
|
|
11
|
+
Transform HTML by adding attributes to root and all elements.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
html (str): The HTML string to transform. Can be a fragment or full document.
|
|
15
|
+
root_attributes (List[str]): List of attribute names to add to root elements only.
|
|
16
|
+
all_attributes (List[str]): List of attribute names to add to all elements.
|
|
17
|
+
check_end_names (Optional[bool]): Whether to validate matching of end tags. Defaults to None.
|
|
18
|
+
watch_on_attribute (Optional[str]): If set, captures which attributes were added to elements with this attribute.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A tuple containing:
|
|
22
|
+
- The transformed HTML string
|
|
23
|
+
- A dictionary mapping captured attribute values to lists of attributes that were added
|
|
24
|
+
to those elements. Only returned if watch_on_attribute is set, otherwise empty dict.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> html = '<div><p>Hello</p></div>'
|
|
28
|
+
>>> set_html_attributes(html, ['data-root-id'], ['data-v-123'])
|
|
29
|
+
'<div data-root-id="" data-v-123=""><p data-v-123="">Hello</p></div>'
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If the HTML is malformed or cannot be parsed.
|
|
33
|
+
"""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
__all__ = ["set_html_attributes"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# ruff: noqa
|
|
2
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
3
|
+
|
|
4
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
5
|
+
|
|
6
|
+
class SecurityError(Exception):
|
|
7
|
+
"""An error raised when a security violation occurs."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def safe_eval(
|
|
11
|
+
source: str,
|
|
12
|
+
*,
|
|
13
|
+
validate_variable: Optional[Callable[[str], bool]] = None,
|
|
14
|
+
validate_attribute: Optional[Callable[[Any, str], bool]] = None,
|
|
15
|
+
validate_subscript: Optional[Callable[[Any, Any], bool]] = None,
|
|
16
|
+
validate_callable: Optional[Callable[[Callable], bool]] = None,
|
|
17
|
+
validate_assign: Optional[Callable[[str, Any], bool]] = None,
|
|
18
|
+
) -> Callable[[Dict[str, Any]], Any]:
|
|
19
|
+
"""
|
|
20
|
+
Compile a Python expression string into a safe evaluation function.
|
|
21
|
+
|
|
22
|
+
This function takes a Python expression string and transforms it into safe code
|
|
23
|
+
by wrapping potentially unsafe operations (like variable access, function calls,
|
|
24
|
+
attribute access, etc.) with sandboxed function calls.
|
|
25
|
+
|
|
26
|
+
This is the re-implementation of Jinja's sandboxed evaluation logic.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
source: The Python expression string to transform.
|
|
30
|
+
validate_variable: Optional extra validation for variable lookups.
|
|
31
|
+
validate_attribute: Optional extra validation for attribute access.
|
|
32
|
+
validate_subscript: Optional extra validation for subscript access.
|
|
33
|
+
validate_callable: Optional extra validation for function calls.
|
|
34
|
+
validate_assign: Optional extra validation for variable assignments.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A compiled function that takes a context dictionary and evaluates the expression.
|
|
38
|
+
The function signature is: `func(context: Dict[str, Any]) -> Any`
|
|
39
|
+
|
|
40
|
+
The returned function may raise SecurityError if the expression is unsafe.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
SyntaxError: If the input is not valid Python syntax or contains forbidden constructs.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> compiled = safe_eval("my_var + 1")
|
|
47
|
+
>>> result = compiled({"my_var": 5})
|
|
48
|
+
>>> print(result)
|
|
49
|
+
6
|
|
50
|
+
|
|
51
|
+
>>> compiled = safe_eval("lambda x: x + my_var")
|
|
52
|
+
>>> func = compiled({"my_var": 10})
|
|
53
|
+
>>> print(func(5))
|
|
54
|
+
15
|
|
55
|
+
|
|
56
|
+
>>> compiled = safe_eval("unsafe_var + 1", validate_variable=lambda name: name != "unsafe_var")
|
|
57
|
+
>>> result = compiled({"unsafe_var": 5})
|
|
58
|
+
SecurityError: variable 'unsafe_var' is unsafe
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unsafe(f: F) -> F:
|
|
63
|
+
"""
|
|
64
|
+
Marks a function or method as unsafe.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
```python
|
|
68
|
+
@unsafe
|
|
69
|
+
def delete(self):
|
|
70
|
+
pass
|
|
71
|
+
```
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"safe_eval",
|
|
77
|
+
"SecurityError",
|
|
78
|
+
"unsafe",
|
|
79
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Any, Callable, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T", bound=Callable)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _format_error_with_context(
|
|
8
|
+
error: Exception,
|
|
9
|
+
source: str,
|
|
10
|
+
start_index: int,
|
|
11
|
+
end_index: int,
|
|
12
|
+
func_name: str,
|
|
13
|
+
add_prefix: bool = True,
|
|
14
|
+
) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Format an error with underlined source code context.
|
|
17
|
+
|
|
18
|
+
Modifies the exception's message to include:
|
|
19
|
+
- Up to 2 preceding lines
|
|
20
|
+
- The lines containing the error (start_index to end_index)
|
|
21
|
+
- Up to 2 following lines
|
|
22
|
+
- Underlined code with ^^^ characters
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
```
|
|
26
|
+
(a := 1 + my_var)
|
|
27
|
+
^^^^^^
|
|
28
|
+
NameError: name 'my_var' is not defined
|
|
29
|
+
```
|
|
30
|
+
"""
|
|
31
|
+
# Convert source to lines with line numbers
|
|
32
|
+
lines = source.split("\n")
|
|
33
|
+
|
|
34
|
+
# Find which lines contain the error
|
|
35
|
+
line_starts = [0] # Cumulative character count at start of each line
|
|
36
|
+
for line in lines[:-1]:
|
|
37
|
+
line_starts.append(line_starts[-1] + len(line) + 1) # +1 for newline
|
|
38
|
+
|
|
39
|
+
# Find start and end line numbers (0-indexed)
|
|
40
|
+
start_line = 0
|
|
41
|
+
for i, line_start in enumerate(line_starts):
|
|
42
|
+
if line_start > start_index:
|
|
43
|
+
start_line = max(0, i - 1)
|
|
44
|
+
break
|
|
45
|
+
else:
|
|
46
|
+
start_line = len(lines) - 1
|
|
47
|
+
|
|
48
|
+
end_line = start_line
|
|
49
|
+
for i, line_start in enumerate(line_starts):
|
|
50
|
+
if line_start > end_index:
|
|
51
|
+
end_line = max(0, i - 1)
|
|
52
|
+
break
|
|
53
|
+
else:
|
|
54
|
+
end_line = len(lines) - 1
|
|
55
|
+
|
|
56
|
+
# Calculate column positions within each line
|
|
57
|
+
def get_column(line_num: int, char_index: int) -> int:
|
|
58
|
+
"""Get column number (0-indexed) for a character index in a specific line."""
|
|
59
|
+
if line_num >= len(line_starts):
|
|
60
|
+
return 0
|
|
61
|
+
line_start = line_starts[line_num]
|
|
62
|
+
return max(0, char_index - line_start)
|
|
63
|
+
|
|
64
|
+
start_col = get_column(start_line, start_index)
|
|
65
|
+
end_col = get_column(end_line, end_index)
|
|
66
|
+
|
|
67
|
+
# Collect lines to show (up to 2 before and 2 after)
|
|
68
|
+
show_start = max(0, start_line - 2)
|
|
69
|
+
show_end = min(len(lines), end_line + 3) # +3 because end is inclusive
|
|
70
|
+
|
|
71
|
+
# Build the formatted error message
|
|
72
|
+
error_lines = []
|
|
73
|
+
if add_prefix:
|
|
74
|
+
error_lines.append(f"Error in {func_name}: {type(error).__name__}: {error}")
|
|
75
|
+
else:
|
|
76
|
+
# Just use the original error message
|
|
77
|
+
error_lines.append(str(error))
|
|
78
|
+
error_lines.append("")
|
|
79
|
+
|
|
80
|
+
# Add source lines with line numbers
|
|
81
|
+
for line_num in range(show_start, show_end):
|
|
82
|
+
line_content = lines[line_num]
|
|
83
|
+
line_display_num = line_num + 1 # 1-indexed for display
|
|
84
|
+
|
|
85
|
+
# Calculate underline range for this line
|
|
86
|
+
underline_start = 0
|
|
87
|
+
underline_end = len(line_content)
|
|
88
|
+
|
|
89
|
+
if line_num == start_line == end_line:
|
|
90
|
+
# Error spans single line
|
|
91
|
+
underline_start = start_col
|
|
92
|
+
underline_end = min(len(line_content), end_col)
|
|
93
|
+
elif line_num == start_line:
|
|
94
|
+
# Error starts on this line
|
|
95
|
+
underline_start = start_col
|
|
96
|
+
underline_end = len(line_content)
|
|
97
|
+
elif line_num == end_line:
|
|
98
|
+
# Error ends on this line
|
|
99
|
+
underline_start = 0
|
|
100
|
+
underline_end = min(len(line_content), end_col)
|
|
101
|
+
elif start_line < line_num < end_line:
|
|
102
|
+
# Error spans this entire line
|
|
103
|
+
underline_start = 0
|
|
104
|
+
underline_end = len(line_content)
|
|
105
|
+
else:
|
|
106
|
+
# No error on this line, don't underline
|
|
107
|
+
underline_start = -1
|
|
108
|
+
underline_end = -1
|
|
109
|
+
|
|
110
|
+
# Add line with number
|
|
111
|
+
line_prefix = f" {line_display_num:4d} | "
|
|
112
|
+
error_lines.append(line_prefix + line_content)
|
|
113
|
+
|
|
114
|
+
# Add underline if error is on this line
|
|
115
|
+
if underline_start >= 0:
|
|
116
|
+
# Create underline: prefix spaces + spaces to column + ^ characters
|
|
117
|
+
prefix_len = len(line_prefix) # " 4 | " = 9 characters
|
|
118
|
+
underline = " " * (prefix_len + underline_start) + "^" * max(
|
|
119
|
+
1, underline_end - underline_start
|
|
120
|
+
)
|
|
121
|
+
error_lines.append(underline)
|
|
122
|
+
|
|
123
|
+
# Update exception message
|
|
124
|
+
error.args = ("\n".join(error_lines),)
|
|
125
|
+
# Mark that this error has been processed by error_context
|
|
126
|
+
error._safe_eval_error_processed = True # type: ignore[attr-defined]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def error_context(func_name: str) -> Callable[[T], T]:
|
|
130
|
+
"""
|
|
131
|
+
Decorator that wraps functions to add error context reporting.
|
|
132
|
+
|
|
133
|
+
Extracts token tuple (start_index, end_index) from the third positional argument,
|
|
134
|
+
and on error, formats the error with underlined source code.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def decorator(func: T) -> T:
|
|
138
|
+
@functools.wraps(func)
|
|
139
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
140
|
+
# Functions that use this decorator have signature
|
|
141
|
+
# (context: Mapping[str, Any], source: str, token: tuple[int, int], *args: Any, **kwargs: Any) -> Any
|
|
142
|
+
source = args[1]
|
|
143
|
+
start_index, end_index = args[2]
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
# On success return result normally
|
|
147
|
+
return func(*args, **kwargs)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
# On error, modify the error message to include the source code context
|
|
150
|
+
_format_error_with_context(e, source, start_index, end_index, func_name)
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
return wrapper
|
|
154
|
+
|
|
155
|
+
return decorator
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from djc_core.djc_core import safe_eval as safe_eval_rust
|
|
5
|
+
from djc_core.djc_safe_eval.error import error_context, _format_error_with_context
|
|
6
|
+
from djc_core.djc_safe_eval.sandbox import (
|
|
7
|
+
is_safe_attribute,
|
|
8
|
+
is_safe_callable,
|
|
9
|
+
is_safe_variable,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SecurityError(Exception):
|
|
14
|
+
"""An error raised when a security violation occurs."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def safe_eval(
|
|
20
|
+
source: str,
|
|
21
|
+
*,
|
|
22
|
+
validate_variable: Optional[Callable[[str], bool]] = None,
|
|
23
|
+
validate_attribute: Optional[Callable[[Any, str], bool]] = None,
|
|
24
|
+
validate_subscript: Optional[Callable[[Any, Any], bool]] = None,
|
|
25
|
+
validate_callable: Optional[Callable[[Callable], bool]] = None,
|
|
26
|
+
validate_assign: Optional[Callable[[str, Any], bool]] = None,
|
|
27
|
+
) -> Callable[[Dict[str, Any]], Any]:
|
|
28
|
+
"""
|
|
29
|
+
Compile a Python expression string into a safe evaluation function.
|
|
30
|
+
|
|
31
|
+
This function takes a Python expression string and transforms it into safe code
|
|
32
|
+
by wrapping potentially unsafe operations (like variable access, function calls,
|
|
33
|
+
attribute access, etc.) with sandboxed function calls.
|
|
34
|
+
|
|
35
|
+
This is the re-implementation of Jinja's sandboxed evaluation logic.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
source: The Python expression string to transform.
|
|
39
|
+
validate_variable: Optional extra validation for variable lookups.
|
|
40
|
+
validate_attribute: Optional extra validation for attribute access.
|
|
41
|
+
validate_subscript: Optional extra validation for subscript access.
|
|
42
|
+
validate_callable: Optional extra validation for function calls.
|
|
43
|
+
validate_assign: Optional extra validation for variable assignments.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A compiled function that takes a context dictionary and evaluates the expression.
|
|
47
|
+
The function signature is: `func(context: Dict[str, Any]) -> Any`
|
|
48
|
+
|
|
49
|
+
The returned function may raise SecurityError if the expression is unsafe.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
SyntaxError: If the input is not valid Python syntax or contains forbidden constructs.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> compiled = safe_eval("my_var + 1")
|
|
56
|
+
>>> result = compiled({"my_var": 5})
|
|
57
|
+
>>> print(result)
|
|
58
|
+
6
|
|
59
|
+
|
|
60
|
+
>>> compiled = safe_eval("lambda x: x + my_var")
|
|
61
|
+
>>> func = compiled({"my_var": 10})
|
|
62
|
+
>>> print(func(5))
|
|
63
|
+
15
|
|
64
|
+
|
|
65
|
+
>>> compiled = safe_eval("unsafe_var + 1", validate_variable=lambda name: name != "unsafe_var")
|
|
66
|
+
>>> result = compiled({"unsafe_var": 5})
|
|
67
|
+
SecurityError: variable 'unsafe_var' is unsafe
|
|
68
|
+
"""
|
|
69
|
+
# If user specified extra validation functions, wrap the original functions with them
|
|
70
|
+
if validate_variable is not None:
|
|
71
|
+
|
|
72
|
+
@error_context("variable")
|
|
73
|
+
def variable_fn(
|
|
74
|
+
__context: Mapping[str, Any],
|
|
75
|
+
__source: str,
|
|
76
|
+
__token: Tuple[int, int],
|
|
77
|
+
var_name: str,
|
|
78
|
+
) -> Any:
|
|
79
|
+
if not validate_variable(var_name):
|
|
80
|
+
raise SecurityError(f"variable '{var_name}' is unsafe")
|
|
81
|
+
return variable(__context, __source, __token, var_name)
|
|
82
|
+
else:
|
|
83
|
+
variable_fn = variable
|
|
84
|
+
|
|
85
|
+
if validate_attribute is not None:
|
|
86
|
+
|
|
87
|
+
@error_context("attribute")
|
|
88
|
+
def attribute_fn(
|
|
89
|
+
__context: Mapping[str, Any],
|
|
90
|
+
__source: str,
|
|
91
|
+
__token: Tuple[int, int],
|
|
92
|
+
obj: Any,
|
|
93
|
+
attr_name: str,
|
|
94
|
+
) -> Any:
|
|
95
|
+
if not validate_attribute(obj, attr_name):
|
|
96
|
+
raise SecurityError(
|
|
97
|
+
f"attribute '{attr_name}' on object '{type(obj)}' is unsafe"
|
|
98
|
+
)
|
|
99
|
+
return attribute(__context, __source, __token, obj, attr_name)
|
|
100
|
+
else:
|
|
101
|
+
attribute_fn = attribute
|
|
102
|
+
|
|
103
|
+
if validate_subscript is not None:
|
|
104
|
+
|
|
105
|
+
@error_context("subscript")
|
|
106
|
+
def subscript_fn(
|
|
107
|
+
__context: Mapping[str, Any],
|
|
108
|
+
__source: str,
|
|
109
|
+
__token: Tuple[int, int],
|
|
110
|
+
obj: Any,
|
|
111
|
+
key: Any,
|
|
112
|
+
) -> Any:
|
|
113
|
+
if not validate_subscript(obj, key):
|
|
114
|
+
raise SecurityError(f"key '{key}' on object '{type(obj)}' is unsafe")
|
|
115
|
+
return subscript(__context, __source, __token, obj, key)
|
|
116
|
+
else:
|
|
117
|
+
subscript_fn = subscript
|
|
118
|
+
|
|
119
|
+
if validate_callable is not None:
|
|
120
|
+
|
|
121
|
+
@error_context("call")
|
|
122
|
+
def call_fn(
|
|
123
|
+
__context: Mapping[str, Any],
|
|
124
|
+
__source: str,
|
|
125
|
+
__token: Tuple[int, int],
|
|
126
|
+
func: Callable,
|
|
127
|
+
*args: Any,
|
|
128
|
+
**kwargs: Any,
|
|
129
|
+
) -> Any:
|
|
130
|
+
if not validate_callable(func):
|
|
131
|
+
raise SecurityError(f"function '{func!r}' is unsafe")
|
|
132
|
+
return call(__context, __source, __token, func, *args, **kwargs)
|
|
133
|
+
else:
|
|
134
|
+
call_fn = call
|
|
135
|
+
|
|
136
|
+
if validate_assign is not None:
|
|
137
|
+
|
|
138
|
+
@error_context("assign")
|
|
139
|
+
def assign_fn(
|
|
140
|
+
__context: Mapping[str, Any],
|
|
141
|
+
__source: str,
|
|
142
|
+
__token: Tuple[int, int],
|
|
143
|
+
var_name: str,
|
|
144
|
+
value: Any,
|
|
145
|
+
) -> Any:
|
|
146
|
+
if not validate_assign(var_name, value):
|
|
147
|
+
raise SecurityError(f"assignment to variable '{var_name}' is unsafe")
|
|
148
|
+
return assign(__context, __source, __token, var_name, value)
|
|
149
|
+
else:
|
|
150
|
+
assign_fn = assign
|
|
151
|
+
|
|
152
|
+
# Create evaluation namespace with wrapped functions
|
|
153
|
+
# These are captured in the closure of the lambda function we'll create
|
|
154
|
+
eval_namespace = {
|
|
155
|
+
"variable": variable_fn,
|
|
156
|
+
"attribute": attribute_fn,
|
|
157
|
+
"subscript": subscript_fn,
|
|
158
|
+
"call": call_fn,
|
|
159
|
+
"assign": assign_fn,
|
|
160
|
+
"slice": slice,
|
|
161
|
+
"interpolation": interpolation,
|
|
162
|
+
"template": template,
|
|
163
|
+
"format": format,
|
|
164
|
+
# Pass through the source string. This way we won't have to re-define
|
|
165
|
+
# the functions on each evaluation.
|
|
166
|
+
"source": source,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Get transformed code from Rust
|
|
170
|
+
transformed_code = safe_eval_rust(source)
|
|
171
|
+
|
|
172
|
+
# Wrap the transformed code in a lambda that captures the helper functions
|
|
173
|
+
# This avoids the overhead of calling eval() and creating a dict on each evaluation
|
|
174
|
+
# NOTE: The lambda is assigned to a variable because we use `exec()` instead of `eval()`
|
|
175
|
+
# to support triple-quoted multi-line strings (which `eval()` doesn't handle).
|
|
176
|
+
# And while `eval()` returns its result directly, with `exec()` we need to assign it to a variable.
|
|
177
|
+
# We wrap with parentheses and newlines to allow multi-line expressions and trailing comments:
|
|
178
|
+
# the newlines ensure that trailing comments don't swallow the closing parenthesis.
|
|
179
|
+
lambda_code = f"_eval_expr = lambda context: (\n{transformed_code}\n)"
|
|
180
|
+
eval_locals = {}
|
|
181
|
+
try:
|
|
182
|
+
# Execute the function definition
|
|
183
|
+
exec(lambda_code, eval_namespace, eval_locals)
|
|
184
|
+
# Get the function from the namespace
|
|
185
|
+
eval_func = eval_locals["_eval_expr"]
|
|
186
|
+
except Exception as e:
|
|
187
|
+
# If the error hasn't been processed by error_context decorator,
|
|
188
|
+
# include the whole expression in the error message (without the "Error in..." prefix)
|
|
189
|
+
if not getattr(e, "_safe_eval_error_processed", False):
|
|
190
|
+
_format_error_with_context(
|
|
191
|
+
e, source, 0, len(source), "expression", add_prefix=False
|
|
192
|
+
)
|
|
193
|
+
# Mark it as processed to avoid double-formatting if re-raised
|
|
194
|
+
e._safe_eval_error_processed = True # type: ignore[attr-defined]
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
# Return a function that calls the compiled function directly
|
|
198
|
+
# This is much faster than calling exec() on each evaluation
|
|
199
|
+
def evaluate(context: Dict[str, Any]) -> Any:
|
|
200
|
+
"""
|
|
201
|
+
Evaluate the compiled expression with the given context.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
context: Dictionary of variables to use in evaluation.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The result of evaluating the expression.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
return eval_func(context)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
# If the error hasn't been processed by error_context decorator,
|
|
213
|
+
# include the whole expression in the error message (without the "Error in..." prefix)
|
|
214
|
+
if not getattr(e, "_safe_eval_error_processed", False):
|
|
215
|
+
_format_error_with_context(
|
|
216
|
+
e, source, 0, len(source), "expression", add_prefix=False
|
|
217
|
+
)
|
|
218
|
+
# Mark it as processed to avoid double-formatting if re-raised
|
|
219
|
+
e._safe_eval_error_processed = True # type: ignore[attr-defined]
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
return evaluate
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# Following are the operations that we intercept. These functions are called by the transformed code.
|
|
226
|
+
#
|
|
227
|
+
# E.g.
|
|
228
|
+
# ```python
|
|
229
|
+
# my_var
|
|
230
|
+
# obj := {"attr": 2}
|
|
231
|
+
# ```
|
|
232
|
+
#
|
|
233
|
+
# is transformed into:
|
|
234
|
+
# ```python
|
|
235
|
+
# variable((0, 4), source, context, "my_var")
|
|
236
|
+
# assign((0, 18), source, context, "obj", {"attr": 2})
|
|
237
|
+
# ```
|
|
238
|
+
#
|
|
239
|
+
# Each interceptor function receives the same 3 first positional arguments:
|
|
240
|
+
# - __context: Mapping[str, Any] - The evaluation context
|
|
241
|
+
# - __source: str - The source code
|
|
242
|
+
# - __token: Tuple[int, int] - The token tuple (start_index, end_index)
|
|
243
|
+
#
|
|
244
|
+
# The __source and __token arguments are used by `@error_context` decorator to add the position
|
|
245
|
+
# where the error occurred to the error message.
|
|
246
|
+
# E.g.
|
|
247
|
+
# ```
|
|
248
|
+
# obj := eval("unsafe code")
|
|
249
|
+
# ^^^^^^^^^^^^^^^^^^^
|
|
250
|
+
# SecurityError: <built-in function eval> is unsafe
|
|
251
|
+
# ```
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@error_context("variable")
|
|
255
|
+
def variable(
|
|
256
|
+
__context: Mapping[str, Any], __source: str, __token: Tuple[int, int], var_name: str
|
|
257
|
+
) -> Any:
|
|
258
|
+
"""Look up a variable in the evaluation context, e.g. `my_var`"""
|
|
259
|
+
if not is_safe_variable(var_name):
|
|
260
|
+
raise SecurityError(f"variable '{var_name}' is unsafe")
|
|
261
|
+
return __context[var_name]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@error_context("attribute")
|
|
265
|
+
def attribute(
|
|
266
|
+
__context: Mapping[str, Any],
|
|
267
|
+
__source: str,
|
|
268
|
+
__token: Tuple[int, int],
|
|
269
|
+
obj: Any,
|
|
270
|
+
attr_name: str,
|
|
271
|
+
) -> Any:
|
|
272
|
+
"""Access an attribute of an object, e.g. `obj.attr`"""
|
|
273
|
+
if not is_safe_attribute(obj, attr_name):
|
|
274
|
+
raise SecurityError(
|
|
275
|
+
f"attribute '{attr_name}' on object '{type(obj)}' is unsafe"
|
|
276
|
+
)
|
|
277
|
+
return getattr(obj, attr_name)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@error_context("subscript")
|
|
281
|
+
def subscript(
|
|
282
|
+
__context: Mapping[str, Any],
|
|
283
|
+
__source: str,
|
|
284
|
+
__token: Tuple[int, int],
|
|
285
|
+
obj: Any,
|
|
286
|
+
key: Any,
|
|
287
|
+
) -> Any:
|
|
288
|
+
"""Access a key of an object, e.g. `obj[key]`"""
|
|
289
|
+
# NOTE: Right now subscript uses the same logic as attribute
|
|
290
|
+
if not is_safe_attribute(obj, key):
|
|
291
|
+
raise SecurityError(f"key '{key}' on object '{type(obj)}' is unsafe")
|
|
292
|
+
return obj[key]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# NOTE: Our internal args are prefixed with `__` to avoid keyword argument conflicts with the original input.
|
|
296
|
+
@error_context("call")
|
|
297
|
+
def call(
|
|
298
|
+
__context: Mapping[str, Any],
|
|
299
|
+
__source: str,
|
|
300
|
+
__token: Tuple[int, int],
|
|
301
|
+
func: Callable,
|
|
302
|
+
*args: Any,
|
|
303
|
+
**kwargs: Any,
|
|
304
|
+
) -> Any:
|
|
305
|
+
"""Call a function, e.g. `func(arg1, arg2, ...)`"""
|
|
306
|
+
is_safe, replacement_message = is_safe_callable(func)
|
|
307
|
+
if not is_safe:
|
|
308
|
+
error_msg = f"function '{func!r}' is unsafe"
|
|
309
|
+
if replacement_message:
|
|
310
|
+
error_msg += f". {replacement_message}"
|
|
311
|
+
raise SecurityError(error_msg)
|
|
312
|
+
return func(*args, **kwargs)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@error_context("assign")
|
|
316
|
+
def assign(
|
|
317
|
+
__context: Mapping[str, Any],
|
|
318
|
+
__source: str,
|
|
319
|
+
__token: Tuple[int, int],
|
|
320
|
+
var_name: str,
|
|
321
|
+
value: Any,
|
|
322
|
+
) -> Any:
|
|
323
|
+
"""Assign a value to a variable in the evaluation context, e.g. `(x := 5)`"""
|
|
324
|
+
if not is_safe_variable(var_name):
|
|
325
|
+
raise SecurityError(f"variable '{var_name}' is unsafe")
|
|
326
|
+
__context[var_name] = value
|
|
327
|
+
return value
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# NOTE: We don't need to validate the slice arguments as they are always safe.
|
|
331
|
+
# Slice had to be redefined from bracket syntax `obj[lower:upper:step]` to function call syntax
|
|
332
|
+
# `slice(lower, upper, step)` because we convert brackets to function calls `subscript(obj, key)`.
|
|
333
|
+
# But since we had to intercept it, this function ensures we show the correct position in the error message.
|
|
334
|
+
@error_context("slice")
|
|
335
|
+
def slice(
|
|
336
|
+
__context: Mapping[str, Any],
|
|
337
|
+
__source: str,
|
|
338
|
+
__token: Tuple[int, int],
|
|
339
|
+
lower: Any = None,
|
|
340
|
+
upper: Any = None,
|
|
341
|
+
step: Any = None,
|
|
342
|
+
) -> builtins.slice:
|
|
343
|
+
"""Create a slice object, e.g. `obj[lower:upper:step]`"""
|
|
344
|
+
return builtins.slice(lower, upper, step)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# For compatiblity with Python 3.14+:
|
|
348
|
+
# - on 3.14+, t-strings are created as normal
|
|
349
|
+
# - on >=3.13, using t-strings raises an error
|
|
350
|
+
# See: https://docs.python.org/3.14/library/string.templatelib.html#template-strings
|
|
351
|
+
@error_context("interpolation")
|
|
352
|
+
def interpolation(
|
|
353
|
+
__context: Mapping[str, Any],
|
|
354
|
+
__source: str,
|
|
355
|
+
__token: Tuple[int, int],
|
|
356
|
+
value: Any,
|
|
357
|
+
expression: str,
|
|
358
|
+
conversion: Optional[str],
|
|
359
|
+
format_spec: str,
|
|
360
|
+
) -> Any:
|
|
361
|
+
"""Process t-string interpolation."""
|
|
362
|
+
try:
|
|
363
|
+
from string.templatelib import Interpolation # type: ignore[import-untyped]
|
|
364
|
+
except ImportError:
|
|
365
|
+
raise NotImplementedError("t-string interpolation is not supported")
|
|
366
|
+
return Interpolation(value, expression, conversion, format_spec)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@error_context("template")
|
|
370
|
+
def template(
|
|
371
|
+
__context: Mapping[str, Any], __source: str, __token: Tuple[int, int], *parts: Any
|
|
372
|
+
) -> Any:
|
|
373
|
+
"""Construct a template from parts."""
|
|
374
|
+
try:
|
|
375
|
+
from string.templatelib import Template # type: ignore[import-untyped]
|
|
376
|
+
except ImportError:
|
|
377
|
+
raise NotImplementedError("t-string template construction is not supported")
|
|
378
|
+
return Template(*parts)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@error_context("format")
|
|
382
|
+
def format(
|
|
383
|
+
__context: Mapping[str, Any],
|
|
384
|
+
__source: str,
|
|
385
|
+
__token: Tuple[int, int],
|
|
386
|
+
template_string: str,
|
|
387
|
+
*args: Any,
|
|
388
|
+
) -> str:
|
|
389
|
+
"""
|
|
390
|
+
Format a template string with arguments.
|
|
391
|
+
|
|
392
|
+
Each argument is a tuple (value, conversion_flag, format_spec) where:
|
|
393
|
+
- value: The expression value to format
|
|
394
|
+
- conversion_flag: "r", "s", "a", or None
|
|
395
|
+
- format_spec: A string for static specs, or a tuple (template, *args) for dynamic specs
|
|
396
|
+
|
|
397
|
+
This wraps the built-in str.format() method so that errors inside f-strings get nice
|
|
398
|
+
error reporting with underlining via the `@error_context` decorator.
|
|
399
|
+
"""
|
|
400
|
+
processed_args = []
|
|
401
|
+
for value, conversion_flag, format_spec in args:
|
|
402
|
+
# Apply conversion flag if present
|
|
403
|
+
if conversion_flag == "r":
|
|
404
|
+
value = repr(value)
|
|
405
|
+
elif conversion_flag == "s":
|
|
406
|
+
value = str(value)
|
|
407
|
+
elif conversion_flag == "a":
|
|
408
|
+
value = ascii(value)
|
|
409
|
+
# If None, keep value as-is
|
|
410
|
+
|
|
411
|
+
# Apply format spec if present (non-empty string or tuple)
|
|
412
|
+
if format_spec:
|
|
413
|
+
if isinstance(format_spec, tuple):
|
|
414
|
+
# Dynamic format spec: (template, *args)
|
|
415
|
+
spec_template, *spec_args = format_spec
|
|
416
|
+
# Format the spec template with its args
|
|
417
|
+
format_spec_str = spec_template.format(*spec_args)
|
|
418
|
+
else:
|
|
419
|
+
# Static format spec: already a string
|
|
420
|
+
format_spec_str = format_spec
|
|
421
|
+
|
|
422
|
+
# Only apply format spec if it's non-empty
|
|
423
|
+
if format_spec_str:
|
|
424
|
+
value = builtins.format(value, format_spec_str)
|
|
425
|
+
|
|
426
|
+
processed_args.append(value)
|
|
427
|
+
|
|
428
|
+
return template_string.format(*processed_args)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A sandbox layer that ensures unsafe operations cannot be performed.
|
|
3
|
+
Useful when the Python expression itself comes from an untrusted source.
|
|
4
|
+
|
|
5
|
+
Based on the Jinja v3.1.6 sandbox implementation.
|
|
6
|
+
See https://github.com/pallets/jinja/blob/5ef70112a1ff19c05324ff889dd30405b1002044/src/jinja2/sandbox.py
|
|
7
|
+
|
|
8
|
+
We do NOT support:
|
|
9
|
+
- Builtins. If you need to use `len()`, `str()`, `int()`, `list()`, `dict()`, etc.,
|
|
10
|
+
you'll have to pass them as variables.
|
|
11
|
+
- "safe" range. Jinja puts limit on the number of items a range may produce.
|
|
12
|
+
We don't expose `range()` function to the sandboxed code at all.
|
|
13
|
+
- "Immutable" sandbox (e.g. raising when mutating a list).
|
|
14
|
+
- Async functions, coroutines, etc.
|
|
15
|
+
- `str.format` and `str.format_map` are not allowed as they can be used to access unsafe variables.
|
|
16
|
+
Use f-strings instead.
|
|
17
|
+
|
|
18
|
+
We add these safety features not present in Jinja:
|
|
19
|
+
- Prevent users from calling unsafe builtins like `eval` even if they were passed as variables.
|
|
20
|
+
|
|
21
|
+
To mark custom functions as unsafe, use the `@unsafe` decorator.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
@unsafe
|
|
26
|
+
def delete(self):
|
|
27
|
+
pass
|
|
28
|
+
```
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import builtins
|
|
32
|
+
import types
|
|
33
|
+
from typing import Any, Callable, Dict, Optional, Set, Tuple, TypeVar
|
|
34
|
+
|
|
35
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
36
|
+
|
|
37
|
+
#: Unsafe function attributes.
|
|
38
|
+
UNSAFE_FUNCTION_ATTRIBUTES: Set[str] = set()
|
|
39
|
+
|
|
40
|
+
#: Unsafe method attributes. Function attributes are unsafe for methods too.
|
|
41
|
+
UNSAFE_METHOD_ATTRIBUTES: Set[str] = set()
|
|
42
|
+
|
|
43
|
+
#: unsafe generator attributes.
|
|
44
|
+
UNSAFE_GENERATOR_ATTRIBUTES = {"gi_frame", "gi_code"}
|
|
45
|
+
|
|
46
|
+
#: unsafe attributes on coroutines
|
|
47
|
+
UNSAFE_COROUTINE_ATTRIBUTES = {"cr_frame", "cr_code"}
|
|
48
|
+
|
|
49
|
+
#: unsafe attributes on async generators
|
|
50
|
+
UNSAFE_ASYNC_GENERATOR_ATTRIBUTES = {"ag_code", "ag_frame"}
|
|
51
|
+
|
|
52
|
+
# Builtin functions that users have no business calling in sandboxed code.
|
|
53
|
+
# We check for these as it may happen that these functons were passed as variables,
|
|
54
|
+
# and the user may try to call them.
|
|
55
|
+
# Dictionary mapping unsafe functions to replacement messages.
|
|
56
|
+
_UNSAFE_BUILTIN_FUNCTION_NAMES = {
|
|
57
|
+
"__build_class__": None,
|
|
58
|
+
"__import__": None,
|
|
59
|
+
"__loader__": None,
|
|
60
|
+
"aiter": None,
|
|
61
|
+
"anext": None,
|
|
62
|
+
"breakpoint": None,
|
|
63
|
+
"classmethod": None,
|
|
64
|
+
"compile": None,
|
|
65
|
+
"delattr": None,
|
|
66
|
+
"eval": None,
|
|
67
|
+
"exec": None,
|
|
68
|
+
"exit": None,
|
|
69
|
+
"getattr": None,
|
|
70
|
+
"globals": None,
|
|
71
|
+
"help": None,
|
|
72
|
+
"input": None,
|
|
73
|
+
"locals": None,
|
|
74
|
+
"memoryview": None,
|
|
75
|
+
"open": None,
|
|
76
|
+
"property": None,
|
|
77
|
+
"quit": None,
|
|
78
|
+
"setattr": None,
|
|
79
|
+
"staticmethod": None,
|
|
80
|
+
"super": None,
|
|
81
|
+
"vars": None,
|
|
82
|
+
}
|
|
83
|
+
UNSAFE_BUILTIN_FUNCTIONS: Dict[Any, Optional[str]] = {
|
|
84
|
+
getattr(builtins, attr): replacement
|
|
85
|
+
for attr, replacement in _UNSAFE_BUILTIN_FUNCTION_NAMES.items()
|
|
86
|
+
if hasattr(builtins, attr)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# These are not allowed as they can be used to access unsafe variables,
|
|
90
|
+
# e.g. `"a{0.b.__builtins__[__import__]}b".format({"b": 42})`
|
|
91
|
+
# Use f-strings instead.
|
|
92
|
+
UNSAFE_FUNCTIONS: Dict[Any, Optional[str]] = {
|
|
93
|
+
str.format: "Use f-strings instead.",
|
|
94
|
+
str.format_map: "Use f-strings instead.",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def unsafe(f: F) -> F:
|
|
99
|
+
"""
|
|
100
|
+
Marks a function or method as unsafe.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
```python
|
|
104
|
+
@unsafe
|
|
105
|
+
def delete(self):
|
|
106
|
+
pass
|
|
107
|
+
```
|
|
108
|
+
"""
|
|
109
|
+
f.unsafe_callable = True # type: ignore
|
|
110
|
+
return f
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_internal_attribute(obj: Any, attr: str) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Test if the attribute is an internal Python attribute.
|
|
116
|
+
|
|
117
|
+
>>> _is_internal_attribute(str, "mro")
|
|
118
|
+
True
|
|
119
|
+
>>> _is_internal_attribute(str, "upper")
|
|
120
|
+
False
|
|
121
|
+
"""
|
|
122
|
+
if isinstance(obj, types.FunctionType):
|
|
123
|
+
if attr in UNSAFE_FUNCTION_ATTRIBUTES:
|
|
124
|
+
return True
|
|
125
|
+
elif isinstance(obj, types.MethodType):
|
|
126
|
+
if attr in UNSAFE_FUNCTION_ATTRIBUTES or attr in UNSAFE_METHOD_ATTRIBUTES:
|
|
127
|
+
return True
|
|
128
|
+
elif isinstance(obj, type):
|
|
129
|
+
if attr == "mro":
|
|
130
|
+
return True
|
|
131
|
+
elif isinstance(obj, (types.CodeType, types.TracebackType, types.FrameType)):
|
|
132
|
+
return True
|
|
133
|
+
elif isinstance(obj, types.GeneratorType):
|
|
134
|
+
if attr in UNSAFE_GENERATOR_ATTRIBUTES:
|
|
135
|
+
return True
|
|
136
|
+
elif hasattr(types, "CoroutineType") and isinstance(obj, types.CoroutineType):
|
|
137
|
+
if attr in UNSAFE_COROUTINE_ATTRIBUTES:
|
|
138
|
+
return True
|
|
139
|
+
elif hasattr(types, "AsyncGeneratorType") and isinstance(
|
|
140
|
+
obj, types.AsyncGeneratorType
|
|
141
|
+
):
|
|
142
|
+
if attr in UNSAFE_ASYNC_GENERATOR_ATTRIBUTES:
|
|
143
|
+
return True
|
|
144
|
+
return attr.startswith("__")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_safe_attribute(obj: Any, attr: str) -> bool:
|
|
148
|
+
"""
|
|
149
|
+
Check if the attribute of an object is safe to access.
|
|
150
|
+
|
|
151
|
+
Unsafe attributes are:
|
|
152
|
+
- Starting with an underscore `_`
|
|
153
|
+
- Internal attributes as set by `_is_internal_attribute`.
|
|
154
|
+
"""
|
|
155
|
+
# Non-string subscripts should be fine, as they should be found only
|
|
156
|
+
# as list slices.
|
|
157
|
+
if not isinstance(attr, str):
|
|
158
|
+
return True
|
|
159
|
+
return not (attr.startswith("_") or _is_internal_attribute(obj, attr))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_safe_callable(obj: Any) -> Tuple[bool, Optional[str]]:
|
|
163
|
+
"""
|
|
164
|
+
Check if an object is safely callable.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Tuple of (is_safe, replacement_message)
|
|
168
|
+
- is_safe: True if safe to call, False otherwise
|
|
169
|
+
- replacement_message: Optional string suggesting a replacement, None if not applicable
|
|
170
|
+
|
|
171
|
+
Unsafe callables are:
|
|
172
|
+
- Decorated with `@unsafe`
|
|
173
|
+
- Marked with `obj.alters_data = True` (Django convention)
|
|
174
|
+
- Unsafe builtins (e.g. `eval`)
|
|
175
|
+
- `str.format` or `str.format_map` (use f-strings instead)
|
|
176
|
+
"""
|
|
177
|
+
# Check for bound methods (e.g., "string".format())
|
|
178
|
+
# Handle both regular methods (types.MethodType) and built-in methods (builtin_function_or_method)
|
|
179
|
+
underlying_func = None
|
|
180
|
+
if isinstance(obj, types.MethodType):
|
|
181
|
+
# Regular Python method - has __func__ attribute
|
|
182
|
+
underlying_func = obj.__func__
|
|
183
|
+
elif (
|
|
184
|
+
hasattr(obj, "__self__")
|
|
185
|
+
and hasattr(obj, "__name__")
|
|
186
|
+
and not hasattr(obj, "__func__")
|
|
187
|
+
):
|
|
188
|
+
# Built-in method descriptor (e.g., str.format, str.format_map)
|
|
189
|
+
# These are bound methods that don't have __func__, but we can get the original descriptor
|
|
190
|
+
try:
|
|
191
|
+
underlying_func = getattr(type(obj.__self__), obj.__name__)
|
|
192
|
+
except (AttributeError, TypeError):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
if underlying_func is not None:
|
|
196
|
+
# Check marks on inner function (decorated with @unsafe or alters_data)
|
|
197
|
+
if getattr(underlying_func, "unsafe_callable", False) or getattr(
|
|
198
|
+
underlying_func, "alters_data", False
|
|
199
|
+
):
|
|
200
|
+
return (False, None)
|
|
201
|
+
# Check if the underlying function is in our unsafe dictionaries
|
|
202
|
+
if underlying_func in UNSAFE_FUNCTIONS:
|
|
203
|
+
return (False, UNSAFE_FUNCTIONS[underlying_func])
|
|
204
|
+
if underlying_func in UNSAFE_BUILTIN_FUNCTIONS:
|
|
205
|
+
return (False, UNSAFE_BUILTIN_FUNCTIONS[underlying_func])
|
|
206
|
+
|
|
207
|
+
# Check marks on the outer function (decorated with @unsafe or alters_data)
|
|
208
|
+
if getattr(obj, "unsafe_callable", False) or getattr(obj, "alters_data", False):
|
|
209
|
+
return (False, None)
|
|
210
|
+
|
|
211
|
+
# Check identity for unbound functions
|
|
212
|
+
if obj in UNSAFE_FUNCTIONS:
|
|
213
|
+
return (False, UNSAFE_FUNCTIONS[obj])
|
|
214
|
+
if obj in UNSAFE_BUILTIN_FUNCTIONS:
|
|
215
|
+
return (False, UNSAFE_BUILTIN_FUNCTIONS[obj])
|
|
216
|
+
|
|
217
|
+
return (True, None)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def is_safe_variable(var_name: str) -> bool:
|
|
221
|
+
"""
|
|
222
|
+
Check if a variable is safe to access.
|
|
223
|
+
|
|
224
|
+
Unsafe variables are:
|
|
225
|
+
- Starting with an underscore `_`
|
|
226
|
+
"""
|
|
227
|
+
return not var_name.startswith("_")
|
djc_core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: djc_core
|
|
3
|
+
Version: 1.1.1
|
|
4
|
+
Classifier: Programming Language :: Python
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Rust
|
|
14
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
15
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Summary: HTML parser used by django-components written in Rust.
|
|
18
|
+
Keywords: django,components,html
|
|
19
|
+
Author-email: Juro Oravec <juraj.oravec.josefson@gmail.com>
|
|
20
|
+
License: MIT
|
|
21
|
+
Requires-Python: >=3.8, <4.0
|
|
22
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
23
|
+
Project-URL: Changelog, https://github.com/django-components/djc-core/blob/main/CHANGELOG.md
|
|
24
|
+
Project-URL: Donate, https://github.com/sponsors/EmilStenstrom
|
|
25
|
+
Project-URL: Homepage, https://github.com/django-components/djc-core/
|
|
26
|
+
Project-URL: Issues, https://github.com/django-components/djc-core/issues
|
|
27
|
+
|
|
28
|
+
# djc-core
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/djc-core/) [](https://pypi.org/project/djc-core/) [](https://github.com/django-components/djc-core/blob/master/LICENSE/) [](https://pypistats.org/packages/djc-core) [](https://github.com/django-components/djc-core/actions/workflows/tests.yml)
|
|
31
|
+
|
|
32
|
+
Rust-based parsers and toolings used by [django-components](https://github.com/django-components/django-components). Exposed as a Python package with [maturin](https://www.maturin.rs/).
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install djc-core
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Packages
|
|
41
|
+
|
|
42
|
+
### Safe eval
|
|
43
|
+
|
|
44
|
+
Re-implementation of Jinja2's sandboxed evaluation logic, built in Rust using the Ruff Python parser.
|
|
45
|
+
|
|
46
|
+
**Usage**
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from djc_core import safe_eval
|
|
50
|
+
|
|
51
|
+
# Compile an expression
|
|
52
|
+
compiled = safe_eval("my_var + 1")
|
|
53
|
+
|
|
54
|
+
# Evaluate with a context
|
|
55
|
+
result = compiled({"my_var": 5})
|
|
56
|
+
print(result) # 6
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Key Features**
|
|
60
|
+
|
|
61
|
+
- **Security**: Blocks unsafe operations like `eval()`, `exec()`, accessing private attributes (`_private`), and dangerous builtins
|
|
62
|
+
- **Variable tracking**: Reports which variables are used and which are assigned via walrus operator (`:=`)
|
|
63
|
+
- **Error reporting**: Provides detailed error messages with underlined source code indicating where errors occurred
|
|
64
|
+
- **Performance**: Implemented in Rust for fast parsing and transformation
|
|
65
|
+
|
|
66
|
+
**Supported Syntax**
|
|
67
|
+
|
|
68
|
+
Almost all Python expression features are supported:
|
|
69
|
+
|
|
70
|
+
- Literals, data structures, operators
|
|
71
|
+
- Comprehensions, lambdas, conditionals
|
|
72
|
+
- F-strings and t-strings
|
|
73
|
+
- Function calls, attribute/subscript access
|
|
74
|
+
- Walrus operator for assignments
|
|
75
|
+
|
|
76
|
+
**Security**
|
|
77
|
+
|
|
78
|
+
By default, `safe_eval` blocks:
|
|
79
|
+
|
|
80
|
+
- Unsafe builtins (`eval`, `exec`, `open`, etc.)
|
|
81
|
+
- Private attributes (starting with `_`)
|
|
82
|
+
- Dunder attributes (`__class__`, `__dict__`, etc.)
|
|
83
|
+
- Functions decorated with `@unsafe`
|
|
84
|
+
- Django methods marked with `alters_data = True`
|
|
85
|
+
|
|
86
|
+
For more details, examples, and advanced usage, see [`crates/djc-safe-eval/README.md`](crates/djc-safe-eval/README.md).
|
|
87
|
+
|
|
88
|
+
> **WARNING!** Just like Jinja2 and Django's templating, none of these are 100% bulletproof solutions!
|
|
89
|
+
>
|
|
90
|
+
> Because they work by blocking known unsafe scenarios. There can always be a new unknown scenario.
|
|
91
|
+
>
|
|
92
|
+
> If you expose a dangerous function to the template/expression, this can be potentially exploited.
|
|
93
|
+
>
|
|
94
|
+
> Safer approach would be to allow to call only those functions that have been explicitly tagged as safe.
|
|
95
|
+
>
|
|
96
|
+
> If you really need to render templates submitted from your users you should instead define the UI blocks yourself, and let your users pick and choose through JSON or similar:
|
|
97
|
+
>
|
|
98
|
+
> ```json
|
|
99
|
+
> {
|
|
100
|
+
> "template": "my_template",
|
|
101
|
+
> "user_id": 123,
|
|
102
|
+
> "blocks": [
|
|
103
|
+
> {"type": "header", "title": "Hello!"},
|
|
104
|
+
> {"type": "paragraph", "text": "This is my blog"},
|
|
105
|
+
> {"type": "table", "data": [[1, 2, 3], [3, 4, 5]]},
|
|
106
|
+
> ]
|
|
107
|
+
> }
|
|
108
|
+
> ```
|
|
109
|
+
|
|
110
|
+
### HTML transfomer
|
|
111
|
+
|
|
112
|
+
Transform HTML in a single pass. This is a simple implementation.
|
|
113
|
+
|
|
114
|
+
This implementation was found to be 40-50x faster than our Python implementation, taking ~90ms to parse 5 MB of HTML.
|
|
115
|
+
|
|
116
|
+
**Usage**
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from djc_core import set_html_attributes
|
|
120
|
+
|
|
121
|
+
html = '<div><p>Hello</p></div>'
|
|
122
|
+
result, _ = set_html_attributes(
|
|
123
|
+
html,
|
|
124
|
+
# Add attributes to the root elements
|
|
125
|
+
root_attributes=['data-root-id'],
|
|
126
|
+
# Add attributes to all elements
|
|
127
|
+
all_attributes=['data-v-123'],
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
To save ourselves from re-parsing the HTML, `set_html_attributes` returns not just the transformed HTML, but also a dictionary as the second item.
|
|
132
|
+
|
|
133
|
+
This dictionary contains a record of which HTML attributes were written to which elemenents.
|
|
134
|
+
|
|
135
|
+
To populate this dictionary, you need set `watch_on_attribute` to an attribute name.
|
|
136
|
+
|
|
137
|
+
Then, during the HTML transformation, we check each element for this attribute. And if the element HAS this attribute, we:
|
|
138
|
+
|
|
139
|
+
1. Get the value of said attribute
|
|
140
|
+
2. Record the attributes that were added to the element, using the value of the watched attribute as the key.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from djc_core import set_html_attributes
|
|
144
|
+
|
|
145
|
+
html = """
|
|
146
|
+
<div data-watch-id="123">
|
|
147
|
+
<p data-watch-id="456">
|
|
148
|
+
Hello
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
result, captured = set_html_attributes(
|
|
154
|
+
html,
|
|
155
|
+
# Add attributes to the root elements
|
|
156
|
+
root_attributes=['data-root-id'],
|
|
157
|
+
# Add attributes to all elements
|
|
158
|
+
all_attributes=['data-djc-tag'],
|
|
159
|
+
# Watch for this attribute on elements
|
|
160
|
+
watch_on_attribute='data-watch-id',
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
print(captured)
|
|
164
|
+
# {
|
|
165
|
+
# '123': ['data-root-id', 'data-djc-tag'],
|
|
166
|
+
# '456': ['data-djc-tag'],
|
|
167
|
+
# }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Architecture
|
|
171
|
+
|
|
172
|
+
This project uses a multi-crate Rust workspace structure to maintain clean separation of concerns:
|
|
173
|
+
|
|
174
|
+
### Crate structure
|
|
175
|
+
|
|
176
|
+
- **`djc-html-transformer`**: Pure Rust library for HTML transformation
|
|
177
|
+
- **`djc-template-parser`**: Pure Rust library for Django template parsing
|
|
178
|
+
- **`djc-core`**: Python bindings that combines all other libraries
|
|
179
|
+
|
|
180
|
+
### Design philosophy
|
|
181
|
+
|
|
182
|
+
To make sense of the code, the Python API and Rust logic are defined separately:
|
|
183
|
+
|
|
184
|
+
1. Each crate (AKA Rust package) has `lib.rs` (which is like Python's `__init__.py`). These files do not define the main logic, but only the public API of the crate. So the API that's to be used by other crates.
|
|
185
|
+
2. The `djc-core` crate imports other crates
|
|
186
|
+
3. And it is only this `djc-core` where we define the Python API using PyO3.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
1. Setup python env
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
python -m venv .venv
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
2. Install dependencies
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
pip install -r requirements-dev.txt
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The dev requirements also include `maturin` which is used packaging a Rust project
|
|
203
|
+
as Python package.
|
|
204
|
+
|
|
205
|
+
3. Install Rust
|
|
206
|
+
|
|
207
|
+
See https://www.rust-lang.org/tools/install
|
|
208
|
+
|
|
209
|
+
4. Run Rust tests
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
cargo test
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
5. Build the Python package
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
maturin develop
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
To build the production-optimized package, use `maturin develop --release`.
|
|
222
|
+
|
|
223
|
+
6. Run Python tests
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
pytest
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
> NOTE: When running Python tests, you need to run `maturin develop` first.
|
|
230
|
+
|
|
231
|
+
## Deployment
|
|
232
|
+
|
|
233
|
+
Deployment is done automatically via GitHub Actions.
|
|
234
|
+
|
|
235
|
+
To publish a new version of the package, you need to:
|
|
236
|
+
|
|
237
|
+
1. Bump the version in `pyproject.toml` and `Cargo.toml`
|
|
238
|
+
2. Open a PR and merge it to `main`.
|
|
239
|
+
3. Create a new tag on the `main` branch with the new version number (e.g. `1.0.0`), or create a new release in the GitHub UI.
|
|
240
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
djc_core/__init__.py,sha256=Ln1Cbt4owEeLpp3dEaqW3aittUD4NXRKubgRqRKgy5o,580
|
|
2
|
+
djc_core/__init__.pyi,sha256=Md_d2R3hcxDpRnUMp7j2aaiGJcb_MLlPGWnYgvAwfGs,81
|
|
3
|
+
djc_core/djc_core.cpython-314t-aarch64-linux-gnu.so,sha256=1aMA246JUNbIgHVGsPohTKBW2r2ZpkeZ32ZNi3_lcZs,22350952
|
|
4
|
+
djc_core/djc_html_transformer.pyi,sha256=HY7-gMgzNOqUptGtNPl1yIEZ5zSPXpxCim0hN846kks,1465
|
|
5
|
+
djc_core/djc_safe_eval/__init__.py,sha256=sd8eDySbeKk4Bu7d_cwMg6aQQcyOtD2IpAbJAZoOCiQ,137
|
|
6
|
+
djc_core/djc_safe_eval/__init__.pyi,sha256=-iJZ4LrvzAZf1HiaPqXAR7sr9fHyKl2Zqn5mXuLpq6g,2555
|
|
7
|
+
djc_core/djc_safe_eval/error.py,sha256=1iQRvbC6p9WNW33igCeP7qSEuCCPACRIrdH6CiC6Mmc,5306
|
|
8
|
+
djc_core/djc_safe_eval/eval.py,sha256=6-ViPj8ILkhtabG6T_b-zBjmOABbbRgsPnKI-pl7Feo,15232
|
|
9
|
+
djc_core/djc_safe_eval/sandbox.py,sha256=wFVPVgjK0Vcv1GgbuqAYo89bETl_ClibOWpDklI5K6g,7482
|
|
10
|
+
djc_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
djc_core-1.1.1.dist-info/METADATA,sha256=dpZWI3e_TkLbw52YCSLWxQX_PUr2DBs-fapiPZGjyVY,7873
|
|
12
|
+
djc_core-1.1.1.dist-info/WHEEL,sha256=K42pU0cRVtxA8N2aGHpXHx_0G7KEA_mBkTgnb38YQKw,151
|
|
13
|
+
djc_core-1.1.1.dist-info/licenses/LICENSE,sha256=flYqsTDuObp3YA1SgILjCKp6YxKDC6FcMVBpJNicRq0,1074
|
|
14
|
+
djc_core-1.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 django-components
|
|
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.
|