avada-eval 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.
- avada_eval/__init__.py +72 -0
- avada_eval/cli.py +102 -0
- avada_eval/core/__init__.py +3 -0
- avada_eval/core/settings.py +39 -0
- avada_eval/decimal_ops.py +152 -0
- avada_eval/evaluator.py +1544 -0
- avada_eval/exceptions.py +88 -0
- avada_eval/llm/__init__.py +21 -0
- avada_eval/llm/finance.py +62 -0
- avada_eval/llm/preprocess.py +174 -0
- avada_eval/llm/tool.py +126 -0
- avada_eval/py.typed +0 -0
- avada_eval-0.1.0.dist-info/METADATA +132 -0
- avada_eval-0.1.0.dist-info/RECORD +17 -0
- avada_eval-0.1.0.dist-info/WHEEL +4 -0
- avada_eval-0.1.0.dist-info/entry_points.txt +2 -0
- avada_eval-0.1.0.dist-info/licenses/LICENSE +22 -0
avada_eval/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""avada_eval: a safe expression evaluator with exact ``decimal.Decimal`` arithmetic.
|
|
2
|
+
|
|
3
|
+
Drop-in for simpleeval: ``from avada_eval import simple_eval, SimpleEval``.
|
|
4
|
+
The module-level limits (``MAX_POWER``, ``DISALLOW_METHODS`` ...) live in
|
|
5
|
+
``avada_eval.evaluator``; patch them there.
|
|
6
|
+
|
|
7
|
+
This file only installs the beartype claw hook and re-exports (no function
|
|
8
|
+
definitions: the hook cannot instrument the module that installs it).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# leaf; deliberately imported before the hook (itself unchecked)
|
|
12
|
+
from avada_eval.core.settings import settings
|
|
13
|
+
|
|
14
|
+
if settings.beartype_on:
|
|
15
|
+
from beartype.claw import beartype_this_package
|
|
16
|
+
|
|
17
|
+
beartype_this_package()
|
|
18
|
+
|
|
19
|
+
# ↓ all other package-level imports/exports stay below the hook
|
|
20
|
+
from avada_eval.decimal_ops import format_number
|
|
21
|
+
from avada_eval.evaluator import (
|
|
22
|
+
BASIC_ALLOWED_ATTRS,
|
|
23
|
+
DEFAULT_CONTEXT,
|
|
24
|
+
DEFAULT_FUNCTIONS,
|
|
25
|
+
DEFAULT_NAMES,
|
|
26
|
+
DEFAULT_OPERATORS,
|
|
27
|
+
DecimalLiteral,
|
|
28
|
+
EvalWithCompoundTypes,
|
|
29
|
+
ModuleWrapper,
|
|
30
|
+
SimpleEval,
|
|
31
|
+
simple_eval,
|
|
32
|
+
)
|
|
33
|
+
from avada_eval.exceptions import (
|
|
34
|
+
AssignmentAttempted,
|
|
35
|
+
AttributeDoesNotExist,
|
|
36
|
+
DivisionByZero,
|
|
37
|
+
FeatureNotAvailable,
|
|
38
|
+
FloatNotAllowed,
|
|
39
|
+
FunctionNotDefined,
|
|
40
|
+
InvalidExpression,
|
|
41
|
+
IterableTooLong,
|
|
42
|
+
MultipleExpressions,
|
|
43
|
+
NameNotDefined,
|
|
44
|
+
NumberTooHigh,
|
|
45
|
+
OperatorNotDefined,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"BASIC_ALLOWED_ATTRS",
|
|
50
|
+
"DEFAULT_CONTEXT",
|
|
51
|
+
"DEFAULT_FUNCTIONS",
|
|
52
|
+
"DEFAULT_NAMES",
|
|
53
|
+
"DEFAULT_OPERATORS",
|
|
54
|
+
"AssignmentAttempted",
|
|
55
|
+
"AttributeDoesNotExist",
|
|
56
|
+
"DecimalLiteral",
|
|
57
|
+
"DivisionByZero",
|
|
58
|
+
"EvalWithCompoundTypes",
|
|
59
|
+
"FeatureNotAvailable",
|
|
60
|
+
"FloatNotAllowed",
|
|
61
|
+
"FunctionNotDefined",
|
|
62
|
+
"InvalidExpression",
|
|
63
|
+
"IterableTooLong",
|
|
64
|
+
"ModuleWrapper",
|
|
65
|
+
"MultipleExpressions",
|
|
66
|
+
"NameNotDefined",
|
|
67
|
+
"NumberTooHigh",
|
|
68
|
+
"OperatorNotDefined",
|
|
69
|
+
"SimpleEval",
|
|
70
|
+
"format_number",
|
|
71
|
+
"simple_eval",
|
|
72
|
+
]
|
avada_eval/cli.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Command line entry: ``uv run scripts/avada.py "0.1 + 0.2"`` or ``uv run avada ...``.
|
|
2
|
+
|
|
3
|
+
The result on stdout *is* this command's output contract, so it is written
|
|
4
|
+
with ``sys.stdout.write`` (library code elsewhere never writes to stdout).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import decimal
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from avada_eval.evaluator import DEFAULT_CONTEXT
|
|
14
|
+
from avada_eval.llm.tool import evaluate_for_llm
|
|
15
|
+
|
|
16
|
+
_ROUNDING_MODES = [
|
|
17
|
+
decimal.ROUND_HALF_EVEN,
|
|
18
|
+
decimal.ROUND_HALF_UP,
|
|
19
|
+
decimal.ROUND_HALF_DOWN,
|
|
20
|
+
decimal.ROUND_UP,
|
|
21
|
+
decimal.ROUND_DOWN,
|
|
22
|
+
decimal.ROUND_CEILING,
|
|
23
|
+
decimal.ROUND_FLOOR,
|
|
24
|
+
decimal.ROUND_05UP,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_variable(item: str) -> tuple[str, object]:
|
|
29
|
+
name, sep, raw = item.partition("=")
|
|
30
|
+
if not sep or not name.strip().isidentifier():
|
|
31
|
+
raise argparse.ArgumentTypeError(f"expected NAME=VALUE, got {item!r}")
|
|
32
|
+
text = raw.strip()
|
|
33
|
+
value: object
|
|
34
|
+
try:
|
|
35
|
+
value = int(text)
|
|
36
|
+
except ValueError:
|
|
37
|
+
try:
|
|
38
|
+
value = decimal.Decimal(text)
|
|
39
|
+
except decimal.InvalidOperation:
|
|
40
|
+
value = raw # plain string
|
|
41
|
+
return name.strip(), value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
parser = argparse.ArgumentParser(
|
|
46
|
+
prog="avada",
|
|
47
|
+
description="Evaluate an expression with exact decimal arithmetic.",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument("expression")
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"-v",
|
|
52
|
+
"--var",
|
|
53
|
+
action="append",
|
|
54
|
+
default=[],
|
|
55
|
+
type=_parse_variable,
|
|
56
|
+
metavar="NAME=VALUE",
|
|
57
|
+
help="define a variable (int, exact decimal, or string); repeatable",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument("--prec", type=int, help="significant digits (default 28)")
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--rounding", choices=_ROUNDING_MODES, help="default ROUND_HALF_EVEN"
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--no-thousands", action="store_true", help="disable 1,234 parsing"
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--no-percent", action="store_true", help="disable 12.5%% parsing"
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--json", action="store_true", help="print the structured result"
|
|
71
|
+
)
|
|
72
|
+
return parser
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run(argv: Sequence[str] | None = None) -> tuple[int, str]:
|
|
76
|
+
"""Parse ``argv``, evaluate, and return ``(exit_code, text_to_print)``."""
|
|
77
|
+
args = _build_parser().parse_args(argv)
|
|
78
|
+
context = DEFAULT_CONTEXT.copy()
|
|
79
|
+
if args.prec is not None:
|
|
80
|
+
context.prec = args.prec
|
|
81
|
+
if args.rounding is not None:
|
|
82
|
+
context.rounding = args.rounding
|
|
83
|
+
result = evaluate_for_llm(
|
|
84
|
+
args.expression,
|
|
85
|
+
dict(args.var),
|
|
86
|
+
context=context,
|
|
87
|
+
thousands=not args.no_thousands,
|
|
88
|
+
percent=not args.no_percent,
|
|
89
|
+
)
|
|
90
|
+
if args.json:
|
|
91
|
+
text = json.dumps(result.to_dict(), ensure_ascii=False)
|
|
92
|
+
elif result.ok:
|
|
93
|
+
text = result.value or ""
|
|
94
|
+
else:
|
|
95
|
+
text = f"{result.error_type}: {result.error}"
|
|
96
|
+
return (0 if result.ok else 1), text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
100
|
+
code, text = run(argv)
|
|
101
|
+
sys.stdout.write(text + "\n")
|
|
102
|
+
return code
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Package-wide settings: the single source of runtime switches.
|
|
2
|
+
|
|
3
|
+
beartype leaf: this module must not import any first-party module
|
|
4
|
+
(stdlib only), because the package ``__init__`` imports it *before*
|
|
5
|
+
installing the beartype claw hook.
|
|
6
|
+
|
|
7
|
+
This is a library that gets imported from arbitrary working directories,
|
|
8
|
+
so -- unlike an application -- it does not locate a project root and reads
|
|
9
|
+
no config files; the only knob is the ``AVADA_BEARTYPE_ON`` environment
|
|
10
|
+
variable (default on; set it to ``false`` to disable runtime type checks).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
|
|
17
|
+
_FALSE_VALUES = frozenset({"0", "false", "no", "off"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Settings:
|
|
22
|
+
beartype_on: bool = True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _env_flag(name: str, *, default: bool) -> bool:
|
|
26
|
+
raw = os.getenv(name)
|
|
27
|
+
if raw is None or not raw.strip():
|
|
28
|
+
return default
|
|
29
|
+
return raw.strip().lower() not in _FALSE_VALUES
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@lru_cache(maxsize=1)
|
|
33
|
+
def get_settings() -> Settings:
|
|
34
|
+
"""Runtime accessor; tests may ``get_settings.cache_clear()`` after setenv."""
|
|
35
|
+
return Settings(beartype_on=_env_flag("AVADA_BEARTYPE_ON", default=True))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
settings = get_settings()
|
|
39
|
+
"""Import-time singleton, used only by the beartype hook in ``__init__``."""
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Pure ``decimal.Decimal`` helpers used by the evaluator and its default functions.
|
|
2
|
+
|
|
3
|
+
Everything here runs inside the evaluator's ``localcontext`` (precision and
|
|
4
|
+
rounding come from ``decimal.getcontext()``), and nothing ever calls
|
|
5
|
+
``Decimal(float)``: binary floats are converted through ``str`` (their
|
|
6
|
+
shortest round-trip repr), literals come from the source text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import decimal
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
FloatPolicy = Literal["convert", "error"]
|
|
15
|
+
|
|
16
|
+
# Plain (non-scientific) notation is used by format_number while the
|
|
17
|
+
# decimal exponent stays inside this window; outside it, str() is kept.
|
|
18
|
+
_PLAIN_MIN_ADJUSTED = -30
|
|
19
|
+
_PLAIN_MAX_ADJUSTED = 60
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def float_to_decimal(value: float) -> Decimal:
|
|
23
|
+
"""``Decimal(str(x))``: 0.1 -> Decimal('0.1'), never 0.1000000000000000055..."""
|
|
24
|
+
return Decimal(str(value))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def literal_to_decimal(source: str, value: float) -> Decimal:
|
|
28
|
+
"""Exact Decimal for a float literal, from its *source text*.
|
|
29
|
+
|
|
30
|
+
``value`` is what ``ast`` parsed; it is only used to verify that the
|
|
31
|
+
source segment really is that literal (loud failure instead of a
|
|
32
|
+
silently wrong number).
|
|
33
|
+
"""
|
|
34
|
+
result = Decimal(source)
|
|
35
|
+
if float(source) != value:
|
|
36
|
+
raise ValueError(f"source segment {source!r} does not match literal {value!r}")
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_decimal(value: object) -> Decimal:
|
|
41
|
+
"""Convert int / bool / Decimal / float / numeric string to Decimal exactly."""
|
|
42
|
+
if isinstance(value, Decimal):
|
|
43
|
+
return value
|
|
44
|
+
if isinstance(value, int):
|
|
45
|
+
return Decimal(int(value))
|
|
46
|
+
if isinstance(value, float):
|
|
47
|
+
return float_to_decimal(value)
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
text = value.strip()
|
|
50
|
+
try:
|
|
51
|
+
return Decimal(text)
|
|
52
|
+
except decimal.InvalidOperation:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"could not convert string to decimal: {value!r}"
|
|
55
|
+
) from None
|
|
56
|
+
raise TypeError(f"cannot convert {type(value).__name__} to decimal")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def coerce_number(value: object) -> object:
|
|
60
|
+
"""Floats become Decimal (via str); everything else is returned unchanged."""
|
|
61
|
+
if isinstance(value, float):
|
|
62
|
+
return float_to_decimal(value)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def floor_divmod(a: Decimal, b: Decimal) -> tuple[Decimal, Decimal]:
|
|
67
|
+
"""``divmod`` with Python's *floor* semantics (Decimal's own truncates).
|
|
68
|
+
|
|
69
|
+
``Decimal(-7) // 2`` is ``-3`` in the decimal module, but ``-7 // 2`` is
|
|
70
|
+
``-4`` for int and float; we keep the int/float behaviour so switching
|
|
71
|
+
a number between int and Decimal never changes ``//`` or ``%``.
|
|
72
|
+
"""
|
|
73
|
+
q, r = divmod(a, b)
|
|
74
|
+
if r and (r < 0) != (b < 0):
|
|
75
|
+
q -= 1
|
|
76
|
+
r += b
|
|
77
|
+
return q, r
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def decimal_round(number: object, ndigits: int | None = None) -> Decimal:
|
|
81
|
+
"""``round()`` returning a Decimal, using the *context's* rounding mode.
|
|
82
|
+
|
|
83
|
+
``round(2.675, 2)`` -> ``Decimal('2.68')`` under ROUND_HALF_UP and
|
|
84
|
+
``Decimal('2.68')`` under ROUND_HALF_EVEN too (the literal is exact, so
|
|
85
|
+
there is no binary-float surprise); ``round(0.125, 2)`` differs between
|
|
86
|
+
the two modes (0.13 vs 0.12).
|
|
87
|
+
"""
|
|
88
|
+
if isinstance(number, bool) or not isinstance(number, int | Decimal | float):
|
|
89
|
+
raise TypeError(f"round() expects a number, got {type(number).__name__}")
|
|
90
|
+
if ndigits is not None and (
|
|
91
|
+
isinstance(ndigits, bool) or not isinstance(ndigits, int)
|
|
92
|
+
):
|
|
93
|
+
raise TypeError("round() ndigits must be an integer")
|
|
94
|
+
value = to_decimal(number)
|
|
95
|
+
exponent = Decimal(1).scaleb(-(ndigits or 0))
|
|
96
|
+
return value.quantize(exponent)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
Number = int | Decimal
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _as_number(value: object) -> Number:
|
|
103
|
+
value = coerce_number(value)
|
|
104
|
+
if isinstance(value, int | Decimal):
|
|
105
|
+
return value
|
|
106
|
+
raise TypeError(f"expected a number, got {type(value).__name__}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _numbers(args: tuple[object, ...]) -> list[Number]:
|
|
110
|
+
items: Iterable[object]
|
|
111
|
+
if (
|
|
112
|
+
len(args) == 1
|
|
113
|
+
and isinstance(args[0], Iterable)
|
|
114
|
+
and not isinstance(args[0], str)
|
|
115
|
+
):
|
|
116
|
+
items = args[0]
|
|
117
|
+
else:
|
|
118
|
+
items = args
|
|
119
|
+
return [_as_number(x) for x in items]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def decimal_min(*args: object) -> Number:
|
|
123
|
+
"""``min`` over numbers (one iterable or several args); floats coerced first."""
|
|
124
|
+
values = _numbers(args)
|
|
125
|
+
if not values:
|
|
126
|
+
raise ValueError("min() arg is an empty sequence")
|
|
127
|
+
return min(values)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def decimal_max(*args: object) -> Number:
|
|
131
|
+
"""``max`` over numbers (one iterable or several args); floats coerced first."""
|
|
132
|
+
values = _numbers(args)
|
|
133
|
+
if not values:
|
|
134
|
+
raise ValueError("max() arg is an empty sequence")
|
|
135
|
+
return max(values)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def decimal_sum(iterable: Iterable[object], start: object = 0) -> Number:
|
|
139
|
+
"""``sum`` over numbers with floats coerced to Decimal; additions follow the context."""
|
|
140
|
+
total = _as_number(start)
|
|
141
|
+
for item in iterable:
|
|
142
|
+
total += _as_number(item)
|
|
143
|
+
return total
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def format_number(value: object) -> str:
|
|
147
|
+
"""Human/LLM friendly text: plain notation for Decimals of sane magnitude."""
|
|
148
|
+
if isinstance(value, Decimal) and value.is_finite():
|
|
149
|
+
adjusted = value.adjusted()
|
|
150
|
+
if _PLAIN_MIN_ADJUSTED <= adjusted <= _PLAIN_MAX_ADJUSTED:
|
|
151
|
+
return format(value, "f")
|
|
152
|
+
return str(value)
|