pyformula 0.1.0__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.
- pyformula-0.1.0/PKG-INFO +941 -0
- pyformula-0.1.0/README.md +927 -0
- pyformula-0.1.0/pyproject.toml +36 -0
- pyformula-0.1.0/pyproject.toml.orig +34 -0
- pyformula-0.1.0/src/pyformula/__init__.py +26 -0
- pyformula-0.1.0/src/pyformula/compiler/__init__.py +0 -0
- pyformula-0.1.0/src/pyformula/compiler/compiler.py +160 -0
- pyformula-0.1.0/src/pyformula/compiler/models.py +330 -0
- pyformula-0.1.0/src/pyformula/exceptions.py +29 -0
- pyformula-0.1.0/src/pyformula/formula.py +181 -0
- pyformula-0.1.0/src/pyformula/math.py +562 -0
- pyformula-0.1.0/src/pyformula/models.py +13 -0
- pyformula-0.1.0/src/pyformula/operator.py +52 -0
- pyformula-0.1.0/src/pyformula/registry.py +123 -0
- pyformula-0.1.0/src/pyformula/variable.py +60 -0
pyformula-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pyformula
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, typed Python library for composing business rules as reusable, executable numeric formulas.
|
|
5
|
+
Author: aaltatan
|
|
6
|
+
Author-email: aaltatan <a.altatan@gmail.com>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Project-URL: Homepage, https://github.com/aaltatan/pyformula
|
|
12
|
+
Project-URL: Issues, https://github.com/aaltatan/pyformula/issues
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# pyformula
|
|
16
|
+
|
|
17
|
+
A lightweight, typed Python library for composing business rules as reusable, executable numeric formulas.
|
|
18
|
+
|
|
19
|
+
`pyformula` focuses on a functional style where:
|
|
20
|
+
|
|
21
|
+
- formulas are first-class callable objects
|
|
22
|
+
- calculations compose naturally with arithmetic operators
|
|
23
|
+
- formulas are built from typed model accessors and reusable variables
|
|
24
|
+
- registry-based declaration keeps formulas organized and discoverable
|
|
25
|
+
- structured formula definitions can be compiled from dictionaries or JSON-like payloads
|
|
26
|
+
- structured formula definitions make calculations portable, inspectable, and machine-readable
|
|
27
|
+
|
|
28
|
+
It is especially useful for:
|
|
29
|
+
|
|
30
|
+
- pricing and discount engines
|
|
31
|
+
- payroll and compensation logic
|
|
32
|
+
- scoring and risk calculations
|
|
33
|
+
- declarative business rules
|
|
34
|
+
- dynamic feature or KPI computation
|
|
35
|
+
- policy systems where rules are selected by context and evaluated against domain objects
|
|
36
|
+
|
|
37
|
+
The package is designed for clarity and portability: formula definitions are not locked inside one-off Python functions, but can be described as data and evaluated consistently.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- `Formula[T]` objects behave like lazily evaluated numeric functions
|
|
44
|
+
- arithmetic composition with `+`, `-`, `*`, `/`, `%`, `**`, `//`
|
|
45
|
+
- math wrappers such as `sqrt`, `sin`, `log10`, `round`, `exp`, `gamma`, and more
|
|
46
|
+
- `variable()` decorator for turning accessors into reusable named formulas
|
|
47
|
+
- `VariablesRegistry` for naming and organizing formulas
|
|
48
|
+
- `FormulaCompiler` for compiling nested dictionary expressions into executable formulas
|
|
49
|
+
- recursive expression trees for declarative rule and calculation definition
|
|
50
|
+
- strict validation for malformed or missing expressions
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Why this package exists
|
|
55
|
+
|
|
56
|
+
Most formula libraries are either:
|
|
57
|
+
|
|
58
|
+
- low-level numeric math libraries, or
|
|
59
|
+
- expression evaluators that work with strings
|
|
60
|
+
|
|
61
|
+
pyformula tries a middle ground:
|
|
62
|
+
|
|
63
|
+
- formulas are Python objects
|
|
64
|
+
- formulas are evaluated against real domain objects
|
|
65
|
+
- formulas are composable and inspectable
|
|
66
|
+
- expressions can be defined as dictionaries and compiled at runtime
|
|
67
|
+
|
|
68
|
+
That means you can keep your calculation logic structured, reusable, and easy to debug.
|
|
69
|
+
|
|
70
|
+
This is valuable when formulas are part of a business process rather than just a single one-off calculation.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
From a PyPI:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install pyformula
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
or using uv:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv add pyformula
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The project declares Python 3.12+ in `pyproject.toml`.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Quick start
|
|
93
|
+
|
|
94
|
+
### 1) Define a model and formulas
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from dataclasses import dataclass
|
|
98
|
+
|
|
99
|
+
from pyformula import Formula
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Rectangle:
|
|
104
|
+
width: float
|
|
105
|
+
height: float
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
width = Formula[Rectangle](lambda rect: rect.width)
|
|
109
|
+
height = Formula[Rectangle](lambda rect: rect.height)
|
|
110
|
+
|
|
111
|
+
perimeter = (width + height) * 2
|
|
112
|
+
area = width * height
|
|
113
|
+
|
|
114
|
+
rect = Rectangle(10, 20)
|
|
115
|
+
print(perimeter(rect)) # 60.0
|
|
116
|
+
print(area(rect)) # 200.0
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 2) Use the `variable` decorator
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from dataclasses import dataclass
|
|
123
|
+
|
|
124
|
+
from pyformula import variable
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Employee:
|
|
129
|
+
salary: float
|
|
130
|
+
hourly_rate: float
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@variable()
|
|
134
|
+
def salary(employee: Employee) -> float:
|
|
135
|
+
return employee.salary
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@variable()
|
|
139
|
+
def hourly_rate(employee: Employee) -> float:
|
|
140
|
+
return employee.hourly_rate
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
bonus = salary * 0.25
|
|
144
|
+
weekly_cost = (salary / 40) * hourly_rate
|
|
145
|
+
|
|
146
|
+
employee = Employee(5000, 25)
|
|
147
|
+
print(bonus(employee)) # 1250.0
|
|
148
|
+
print(weekly_cost(employee))
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 3) Compile a formula tree from a dictionary
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from pyformula import FormulaCompiler
|
|
155
|
+
|
|
156
|
+
compiler = FormulaCompiler({
|
|
157
|
+
"base_salary": Formula(lambda employee: employee["salary"]),
|
|
158
|
+
"bonus": Formula(lambda employee: employee["bonus"]),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expression = {
|
|
162
|
+
"operator": "add",
|
|
163
|
+
"expressions": [
|
|
164
|
+
"base_salary",
|
|
165
|
+
{"operator": "multiply", "expressions": ["bonus", 0.15]},
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
formula = compiler.compile(expression)
|
|
170
|
+
print(formula({"salary": 1000, "bonus": 200}))
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Core concepts
|
|
176
|
+
|
|
177
|
+
### Formula
|
|
178
|
+
|
|
179
|
+
`Formula[T]` wraps a callable that takes an instance of `T` and returns a numeric result.
|
|
180
|
+
|
|
181
|
+
Allowed numeric results are `int`, `float`, and `Decimal`.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from pyformula import Formula
|
|
185
|
+
|
|
186
|
+
f = Formula[dict](lambda item: item["value"])
|
|
187
|
+
print(f({"value": 3})) # 3
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
You can build formulas with arithmetic operations:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
base = Formula[dict](lambda item: item["base"])
|
|
194
|
+
multiplier = Formula[dict](lambda item: item["multiplier"])
|
|
195
|
+
|
|
196
|
+
final_formula = (base * multiplier) + 5
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The library overloads the normal arithmetic operators, so formula composition is natural and expressive.
|
|
200
|
+
|
|
201
|
+
### variable
|
|
202
|
+
|
|
203
|
+
`variable()` is a decorator that converts a function into a named `Formula` while preserving the original function metadata.
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from pyformula import variable
|
|
207
|
+
|
|
208
|
+
@variable(name="days_in_month")
|
|
209
|
+
def days_in_month(_: object) -> float:
|
|
210
|
+
return 30
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
This is useful for turning ordinary model accessors into named formula building blocks.
|
|
214
|
+
|
|
215
|
+
### VariablesRegistry
|
|
216
|
+
|
|
217
|
+
A `VariablesRegistry` stores named formulas keyed by string names.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from pyformula.registry import VariablesRegistry
|
|
221
|
+
|
|
222
|
+
registry = VariablesRegistry()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def salary(_: object) -> float:
|
|
226
|
+
return 1200
|
|
227
|
+
|
|
228
|
+
registry.register_variable(salary)
|
|
229
|
+
print(registry["salary"](None))
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
It is useful when the formula engine needs to resolve variable names dynamically from a data model or configuration.
|
|
233
|
+
|
|
234
|
+
### FormulaCompiler
|
|
235
|
+
|
|
236
|
+
A `FormulaCompiler` compiles structured expression trees into executable formulas.
|
|
237
|
+
|
|
238
|
+
This is the on-ramp for the dictionary schema described below.
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from pyformula import FormulaCompiler
|
|
242
|
+
|
|
243
|
+
compiler = FormulaCompiler({
|
|
244
|
+
"price": Formula(lambda item: item["price"]),
|
|
245
|
+
"qty": Formula(lambda item: item["qty"]),
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
expression = {
|
|
249
|
+
"operator": "multiply",
|
|
250
|
+
"expressions": ["price", "qty"],
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
formula = compiler.compile(expression)
|
|
254
|
+
print(formula({"price": 10, "qty": 4})) # 40
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Formula dict schema
|
|
260
|
+
|
|
261
|
+
The most important part of the package is the structured expression schema used by `FormulaCompiler.compile()`.
|
|
262
|
+
|
|
263
|
+
The schema supports:
|
|
264
|
+
|
|
265
|
+
1. numeric literals
|
|
266
|
+
2. named variables
|
|
267
|
+
3. binary arithmetic expressions
|
|
268
|
+
4. unary wrapper expressions
|
|
269
|
+
5. nested expressions
|
|
270
|
+
|
|
271
|
+
### 1) Numeric literal
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
42
|
|
275
|
+
3.14
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A plain number is treated as a constant expression.
|
|
279
|
+
|
|
280
|
+
### 2) Named variable
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
"salary"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
This looks up the variable name in the compiler registry.
|
|
287
|
+
|
|
288
|
+
### 3) Binary operation expression
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
{
|
|
292
|
+
"operator": "add",
|
|
293
|
+
"expressions": ["salary", 1000],
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Supported operators are:
|
|
298
|
+
|
|
299
|
+
- `add`
|
|
300
|
+
- `subtract`
|
|
301
|
+
- `multiply`
|
|
302
|
+
- `divide`
|
|
303
|
+
- `modulo`
|
|
304
|
+
- `power`
|
|
305
|
+
- `floor_divide`
|
|
306
|
+
|
|
307
|
+
Example:
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
{
|
|
311
|
+
"operator": "divide",
|
|
312
|
+
"expressions": [
|
|
313
|
+
{
|
|
314
|
+
"operator": "add",
|
|
315
|
+
"expressions": ["salary", "bonus"],
|
|
316
|
+
},
|
|
317
|
+
2,
|
|
318
|
+
],
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
This produces:
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
(salary + bonus) / 2
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### 4) Unary wrapper expressions
|
|
329
|
+
|
|
330
|
+
The compiler supports wrappers such as:
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
{"positive": expression}
|
|
334
|
+
{"negative": expression}
|
|
335
|
+
{"absolute": expression}
|
|
336
|
+
{"round": expression, "ndigits": 2}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The math wrappers use the same pattern as the other unary wrappers: the wrapper name is the key, and the inner value is the expression to evaluate.
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
{"ceil": expression}
|
|
343
|
+
{"floor": expression}
|
|
344
|
+
{"trunc": expression}
|
|
345
|
+
{"sqrt": expression}
|
|
346
|
+
{"cbrt": expression}
|
|
347
|
+
{"exp": expression}
|
|
348
|
+
{"exp2": expression}
|
|
349
|
+
{"expm1": expression}
|
|
350
|
+
{"log10": expression}
|
|
351
|
+
{"log1p": expression}
|
|
352
|
+
{"log2": expression}
|
|
353
|
+
{"sin": expression}
|
|
354
|
+
{"sinh": expression}
|
|
355
|
+
{"asin": expression}
|
|
356
|
+
{"asinh": expression}
|
|
357
|
+
{"cos": expression}
|
|
358
|
+
{"cosh": expression}
|
|
359
|
+
{"acos": expression}
|
|
360
|
+
{"acosh": expression}
|
|
361
|
+
{"tan": expression}
|
|
362
|
+
{"tanh": expression}
|
|
363
|
+
{"atan": expression}
|
|
364
|
+
{"atanh": expression}
|
|
365
|
+
{"degrees": expression}
|
|
366
|
+
{"radians": expression}
|
|
367
|
+
{"erf": expression}
|
|
368
|
+
{"erfc": expression}
|
|
369
|
+
{"gamma": expression}
|
|
370
|
+
{"lgamma": expression}
|
|
371
|
+
{"fabs": expression}
|
|
372
|
+
{"ulp": expression}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Full wrapper reference
|
|
376
|
+
|
|
377
|
+
#### Rounding
|
|
378
|
+
|
|
379
|
+
- `ceil`: applies `math.ceil(...)`
|
|
380
|
+
- `floor`: applies `math.floor(...)`
|
|
381
|
+
- `trunc`: applies `math.trunc(...)`
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
{"ceil": {"operator": "divide", "expressions": ["salary", 4]}}
|
|
385
|
+
{"floor": "hours_worked"}
|
|
386
|
+
{"trunc": {"negative": "rate"}}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
#### Roots and powers
|
|
390
|
+
|
|
391
|
+
- `sqrt`: applies `math.sqrt(...)`
|
|
392
|
+
- `cbrt`: applies `math.cbrt(...)`
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
{"sqrt": {"operator": "add", "expressions": ["width", "height"]}}
|
|
396
|
+
{"cbrt": {"operator": "power", "expressions": ["volume", 3]}}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
#### Exponential and logarithmic functions
|
|
400
|
+
|
|
401
|
+
- `exp`: applies `math.exp(...)`
|
|
402
|
+
- `exp2`: applies `math.exp2(...)`
|
|
403
|
+
- `expm1`: applies `math.expm1(...)`
|
|
404
|
+
- `log10`: applies `math.log10(...)`
|
|
405
|
+
- `log1p`: applies `math.log1p(...)`
|
|
406
|
+
- `log2`: applies `math.log2(...)`
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
{"exp": "rate"}
|
|
410
|
+
{"log10": {"operator": "multiply", "expressions": ["base", 10]}}
|
|
411
|
+
{"log1p": {"operator": "divide", "expressions": ["bonus", "salary"]}}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
#### Trigonometry
|
|
415
|
+
|
|
416
|
+
- `sin`, `sinh`, `asin`, `asinh`
|
|
417
|
+
- `cos`, `cosh`, `acos`, `acosh`
|
|
418
|
+
- `tan`, `tanh`, `atan`, `atanh`
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
{"sin": "angle_radians"}
|
|
422
|
+
{"cos": {"degrees": "angle_degrees"}}
|
|
423
|
+
{"atan": {"operator": "divide", "expressions": ["y", "x"]}}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
#### Angular conversion
|
|
427
|
+
|
|
428
|
+
- `degrees`: converts radians to degrees with `math.degrees(...)`
|
|
429
|
+
- `radians`: converts degrees to radians with `math.radians(...)`
|
|
430
|
+
|
|
431
|
+
```python
|
|
432
|
+
{"degrees": "angle_in_radians"}
|
|
433
|
+
{"radians": "angle_in_degrees"}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
#### Special functions
|
|
437
|
+
|
|
438
|
+
- `erf`: `math.erf(...)`
|
|
439
|
+
- `erfc`: `math.erfc(...)`
|
|
440
|
+
- `gamma`: `math.gamma(...)`
|
|
441
|
+
- `lgamma`: `math.lgamma(...)`
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
{"gamma": {"operator": "add", "expressions": ["n", 1]}}
|
|
445
|
+
{"lgamma": "score"}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### Absolute value and floating-point helpers
|
|
449
|
+
|
|
450
|
+
- `fabs`: `math.fabs(...)`
|
|
451
|
+
- `ulp`: `math.ulp(...)`
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
{"fabs": {"negative": "margin"}}
|
|
455
|
+
{"ulp": "threshold"}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Round wrapper form
|
|
459
|
+
|
|
460
|
+
`round` is a special unary wrapper because it also needs `ndigits`.
|
|
461
|
+
|
|
462
|
+
```python
|
|
463
|
+
{
|
|
464
|
+
"round": {
|
|
465
|
+
"operator": "divide",
|
|
466
|
+
"expressions": [
|
|
467
|
+
{
|
|
468
|
+
"operator": "add",
|
|
469
|
+
"expressions": ["salary", "bonus"],
|
|
470
|
+
},
|
|
471
|
+
3,
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
"ndigits": 2,
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
This is equivalent to:
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
round((salary + bonus) / 3, 2)
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### Example: nested wrapper use
|
|
485
|
+
|
|
486
|
+
```python
|
|
487
|
+
expression = {
|
|
488
|
+
"sqrt": {
|
|
489
|
+
"operator": "add",
|
|
490
|
+
"expressions": [
|
|
491
|
+
{"operator": "power", "expressions": ["width", 2]},
|
|
492
|
+
{"operator": "power", "expressions": ["height", 2]},
|
|
493
|
+
],
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
This is equivalent to:
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
math.sqrt(width**2 + height**2)
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Example: full rule payload using wrappers
|
|
505
|
+
|
|
506
|
+
```python
|
|
507
|
+
expression = {
|
|
508
|
+
"operator": "multiply",
|
|
509
|
+
"expressions": [
|
|
510
|
+
{"sqrt": {"operator": "add", "expressions": ["x", "y"]}},
|
|
511
|
+
{"log10": {"operator": "add", "expressions": ["value", 1]}},
|
|
512
|
+
],
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
This is equivalent to:
|
|
517
|
+
|
|
518
|
+
```python
|
|
519
|
+
sqrt(x + y) * log10(value + 1)
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### 5) Full nested expression trees
|
|
523
|
+
|
|
524
|
+
The schema is recursive, so you can build deeply nested formulas.
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
expression = {
|
|
528
|
+
"operator": "multiply",
|
|
529
|
+
"expressions": [
|
|
530
|
+
2,
|
|
531
|
+
{
|
|
532
|
+
"operator": "add",
|
|
533
|
+
"expressions": [
|
|
534
|
+
"width",
|
|
535
|
+
"height",
|
|
536
|
+
],
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
This evaluates to:
|
|
543
|
+
|
|
544
|
+
```python
|
|
545
|
+
2 * (width + height)
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
### 6) Validation rules
|
|
549
|
+
|
|
550
|
+
The compiler is intentionally strict:
|
|
551
|
+
|
|
552
|
+
- missing formula names raise `FormulaNotFoundError`
|
|
553
|
+
- invalid expression shapes raise `InvalidExpressionError`
|
|
554
|
+
- empty expression lists are rejected
|
|
555
|
+
- `round` requires an integer `ndigits`
|
|
556
|
+
|
|
557
|
+
This is helpful when formulas come from untrusted configuration or user input.
|
|
558
|
+
|
|
559
|
+
---
|
|
560
|
+
|
|
561
|
+
## Example: payroll and compensation policies
|
|
562
|
+
|
|
563
|
+
This is a natural domain for pyformula because compensation rules are often a combination of:
|
|
564
|
+
|
|
565
|
+
- base pay
|
|
566
|
+
- overtime
|
|
567
|
+
- bonuses
|
|
568
|
+
- taxes
|
|
569
|
+
- deductions
|
|
570
|
+
- thresholds
|
|
571
|
+
- role-based adjustments
|
|
572
|
+
|
|
573
|
+
```python
|
|
574
|
+
from dataclasses import dataclass
|
|
575
|
+
|
|
576
|
+
from pyformula import FormulaCompiler, VariablesRegistry
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@dataclass
|
|
580
|
+
class Employee:
|
|
581
|
+
salary: float
|
|
582
|
+
hours_worked: float
|
|
583
|
+
bonus: float
|
|
584
|
+
tax_rate: float
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
registry = VariablesRegistry[Employee]()
|
|
588
|
+
registry.register_variable(lambda e: e.salary, name="salary")
|
|
589
|
+
registry.register_variable(lambda e: e.hours_worked, name="hours_worked")
|
|
590
|
+
registry.register_variable(lambda e: e.bonus, name="bonus")
|
|
591
|
+
registry.register_variable(lambda e: e.tax_rate, name="tax_rate")
|
|
592
|
+
|
|
593
|
+
compiler = FormulaCompiler(registry.variables)
|
|
594
|
+
|
|
595
|
+
expression = {
|
|
596
|
+
"operator": "subtract",
|
|
597
|
+
"expressions": [
|
|
598
|
+
{
|
|
599
|
+
"operator": "add",
|
|
600
|
+
"expressions": ["salary", "bonus"],
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
"operator": "multiply",
|
|
604
|
+
"expressions": [
|
|
605
|
+
{
|
|
606
|
+
"operator": "add",
|
|
607
|
+
"expressions": ["salary", "bonus"],
|
|
608
|
+
},
|
|
609
|
+
"tax_rate",
|
|
610
|
+
],
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
net_pay = compiler.compile(expression)
|
|
616
|
+
emp = Employee(3000, 40, 200, 0.2)
|
|
617
|
+
print(net_pay(emp)) # 2560.0
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
This pattern scales well when you have a catalog of formulas in a registry and select which ones to apply based on role, contract type, or employee segment.
|
|
621
|
+
|
|
622
|
+
---
|
|
623
|
+
|
|
624
|
+
## Example: using with `pyspecification`
|
|
625
|
+
|
|
626
|
+
The project includes a script in `scripts/with_pyspecification.py` that demonstrates an even more powerful pattern: use `Formula` objects together with `pyspecification` predicates to build a rule-based evaluator.
|
|
627
|
+
|
|
628
|
+
That script defines a dataclass like `Employee`, then builds predicates such as:
|
|
629
|
+
|
|
630
|
+
- `is_administrator()`
|
|
631
|
+
- `is_fulltime()`
|
|
632
|
+
- `salary > 0`
|
|
633
|
+
- `name__icontains(...)`
|
|
634
|
+
|
|
635
|
+
Then it combines those predicates and formulas in a guard-based condition engine:
|
|
636
|
+
|
|
637
|
+
```python
|
|
638
|
+
from dataclasses import dataclass
|
|
639
|
+
from pyformula import variable
|
|
640
|
+
from pyformula.formula import Formula
|
|
641
|
+
from pyspecification import Predicate, object_rule
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@dataclass
|
|
645
|
+
class Guard:
|
|
646
|
+
rule: Predicate[Employee, bool]
|
|
647
|
+
formula: Formula[Employee]
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@dataclass
|
|
651
|
+
class Condition:
|
|
652
|
+
guards: list[Guard]
|
|
653
|
+
fallback: Formula[Employee]
|
|
654
|
+
|
|
655
|
+
def evaluate(self, employee: Employee) -> float:
|
|
656
|
+
for guard in self.guards:
|
|
657
|
+
if guard.rule(employee):
|
|
658
|
+
return float(guard.formula(employee))
|
|
659
|
+
return float(self.fallback(employee))
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
Then the actual logic becomes:
|
|
663
|
+
|
|
664
|
+
```python
|
|
665
|
+
reward = Condition(
|
|
666
|
+
[
|
|
667
|
+
Guard(is_administrator() & is_fulltime(), salary * 0.25),
|
|
668
|
+
Guard(is_teacher() & is_fulltime(), salary * 0.5),
|
|
669
|
+
Guard(is_teacher() & is_parttime(), salary * 0.15),
|
|
670
|
+
],
|
|
671
|
+
salary / 0.1,
|
|
672
|
+
)
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
This is the key idea:
|
|
676
|
+
|
|
677
|
+
- `pyspecification` tells you whether a rule applies
|
|
678
|
+
- `pyformula` computes the numeric result when the rule matches
|
|
679
|
+
|
|
680
|
+
The combination is excellent for:
|
|
681
|
+
|
|
682
|
+
- compensation policy engines
|
|
683
|
+
- credit eligibility scoring
|
|
684
|
+
- insurance pricing by segment
|
|
685
|
+
- pricing adjustments by contract or customer class
|
|
686
|
+
- decision logic that needs both a boolean gate and a numeric output
|
|
687
|
+
|
|
688
|
+
This is one of the strongest real-world patterns in the package.
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
## Use cases beyond the examples
|
|
693
|
+
|
|
694
|
+
The examples in the repository show arithmetic and reward calculations, but the package can be used in many more problem domains.
|
|
695
|
+
|
|
696
|
+
### 1) Pricing and discount policy engines
|
|
697
|
+
|
|
698
|
+
Instead of writing inline if/else logic, you can make formulas explicit:
|
|
699
|
+
|
|
700
|
+
- base price
|
|
701
|
+
- loyalty discount
|
|
702
|
+
- shipping surcharge
|
|
703
|
+
- taxes
|
|
704
|
+
- volume-based discounts
|
|
705
|
+
- seasonal multipliers
|
|
706
|
+
|
|
707
|
+
```python
|
|
708
|
+
price = Formula[Order](lambda o: o.subtotal)
|
|
709
|
+
loyalty = Formula[Order](lambda o: o.loyalty_discount)
|
|
710
|
+
shipping = Formula[Order](lambda o: o.shipping)
|
|
711
|
+
|
|
712
|
+
final_total = (price - loyalty) + shipping
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### 2) Risk and scoring systems
|
|
716
|
+
|
|
717
|
+
For a fraud-risk or business-risk model, formulas can represent weighted features or policy thresholds.
|
|
718
|
+
|
|
719
|
+
- exposure amount
|
|
720
|
+
- transaction velocity
|
|
721
|
+
- customer age
|
|
722
|
+
- outstanding amount
|
|
723
|
+
- default probability
|
|
724
|
+
|
|
725
|
+
These can be composed into a score or a final risk-adjusted amount.
|
|
726
|
+
|
|
727
|
+
### 3) Operational planning
|
|
728
|
+
|
|
729
|
+
An object representing a warehouse, machine, or service can expose formulas such as:
|
|
730
|
+
|
|
731
|
+
- utilization
|
|
732
|
+
- capacity utilization
|
|
733
|
+
- run-time cost
|
|
734
|
+
- maintenance interval
|
|
735
|
+
- labor hours
|
|
736
|
+
- defect rate adjusted cost
|
|
737
|
+
|
|
738
|
+
This is especially useful when you want to calculate KPIs from domain objects without embedding the math in the object itself.
|
|
739
|
+
|
|
740
|
+
### 4) Scientific and engineering calculations
|
|
741
|
+
|
|
742
|
+
The library supports a wide math wrapper set, so it is useful for parameterized engineering formulas such as:
|
|
743
|
+
|
|
744
|
+
- energy calculations
|
|
745
|
+
- signal processing formulas
|
|
746
|
+
- vehicle dynamics
|
|
747
|
+
- thermal loads
|
|
748
|
+
- financial approximation formulas
|
|
749
|
+
- root and logarithmic calculations
|
|
750
|
+
|
|
751
|
+
### 5) UI and form-based calculators
|
|
752
|
+
|
|
753
|
+
If your application has dynamic forms or configurable calculations, pyformula can express them as data-driven formulas rather than a batch of custom event handlers.
|
|
754
|
+
|
|
755
|
+
Examples:
|
|
756
|
+
|
|
757
|
+
- loan calculator
|
|
758
|
+
- margin calculator
|
|
759
|
+
- tax estimate estimator
|
|
760
|
+
- insurance premium estimator
|
|
761
|
+
- medical dosage estimator
|
|
762
|
+
|
|
763
|
+
### 6) Data pipeline feature computation
|
|
764
|
+
|
|
765
|
+
If you receive records and need to compute feature values for each row, you can register variables, build formulas, and compile them from dictionaries. That creates a tidy separation between:
|
|
766
|
+
|
|
767
|
+
- data extraction
|
|
768
|
+
- transformation schema
|
|
769
|
+
- evaluation logic
|
|
770
|
+
|
|
771
|
+
### 7) Policy-as-data systems
|
|
772
|
+
|
|
773
|
+
This is the strongest long-term use case.
|
|
774
|
+
|
|
775
|
+
Instead of hard-coding a formula implementation in Python, you can define a formula dictionary in config or a database table, then compile it at runtime with a registry of available variables.
|
|
776
|
+
|
|
777
|
+
This gives you:
|
|
778
|
+
|
|
779
|
+
- rule versioning
|
|
780
|
+
- formula portability
|
|
781
|
+
- safer auditing
|
|
782
|
+
- easier business-user review
|
|
783
|
+
|
|
784
|
+
---
|
|
785
|
+
|
|
786
|
+
## Recommended pattern for production code
|
|
787
|
+
|
|
788
|
+
When using pyformula in real systems, the cleanest pattern is usually:
|
|
789
|
+
|
|
790
|
+
1. define a domain model
|
|
791
|
+
2. register all relevant variables in a `VariablesRegistry`
|
|
792
|
+
3. define formula expressions as either Python `Formula` objects or dictionary trees
|
|
793
|
+
4. compile the expression tree with `FormulaCompiler`
|
|
794
|
+
5. evaluate against the domain object
|
|
795
|
+
|
|
796
|
+
For example:
|
|
797
|
+
|
|
798
|
+
```python
|
|
799
|
+
from dataclasses import dataclass
|
|
800
|
+
|
|
801
|
+
from pyformula import FormulaCompiler, VariablesRegistry
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
@dataclass
|
|
805
|
+
class Invoice:
|
|
806
|
+
subtotal: float
|
|
807
|
+
discount_pct: float
|
|
808
|
+
tax_pct: float
|
|
809
|
+
shipping: float
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
registry = VariablesRegistry[Invoice]()
|
|
813
|
+
registry.register_variable(lambda i: i.subtotal, name="subtotal")
|
|
814
|
+
registry.register_variable(lambda i: i.discount_pct, name="discount_pct")
|
|
815
|
+
registry.register_variable(lambda i: i.tax_pct, name="tax_pct")
|
|
816
|
+
registry.register_variable(lambda i: i.shipping, name="shipping")
|
|
817
|
+
|
|
818
|
+
compiler = FormulaCompiler(registry.variables)
|
|
819
|
+
|
|
820
|
+
net_total = compiler.compile({
|
|
821
|
+
"operator": "add",
|
|
822
|
+
"expressions": [
|
|
823
|
+
{
|
|
824
|
+
"operator": "subtract",
|
|
825
|
+
"expressions": [
|
|
826
|
+
"subtotal",
|
|
827
|
+
{
|
|
828
|
+
"operator": "multiply",
|
|
829
|
+
"expressions": ["subtotal", "discount_pct"],
|
|
830
|
+
},
|
|
831
|
+
],
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
"operator": "multiply",
|
|
835
|
+
"expressions": [
|
|
836
|
+
{
|
|
837
|
+
"operator": "subtract",
|
|
838
|
+
"expressions": [
|
|
839
|
+
"subtotal",
|
|
840
|
+
{
|
|
841
|
+
"operator": "multiply",
|
|
842
|
+
"expressions": ["subtotal", "discount_pct"],
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
},
|
|
846
|
+
"tax_pct",
|
|
847
|
+
],
|
|
848
|
+
},
|
|
849
|
+
"shipping",
|
|
850
|
+
],
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
invoice = Invoice(100, 0.1, 0.2, 10)
|
|
854
|
+
print(net_total(invoice))
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
This avoids hard-coded logic and turns business rules into a transparent, inspectable expression tree.
|
|
858
|
+
|
|
859
|
+
---
|
|
860
|
+
|
|
861
|
+
## Error handling overview
|
|
862
|
+
|
|
863
|
+
The library raises explicit exceptions for common configuration mistakes:
|
|
864
|
+
|
|
865
|
+
- `FormulaNotFoundError`: a referenced variable name does not exist in the registry
|
|
866
|
+
- `InvalidExpressionError`: the expression shape is malformed or unsupported
|
|
867
|
+
- `FormulaAlreadyRegisteredError`: duplicate variable registration name
|
|
868
|
+
- `FormulaNotRegisteredError`: name is registered but hidden or unavailable
|
|
869
|
+
|
|
870
|
+
Examples:
|
|
871
|
+
|
|
872
|
+
```python
|
|
873
|
+
from pyformula import FormulaCompiler
|
|
874
|
+
from pyformula.exceptions import FormulaNotFoundError
|
|
875
|
+
|
|
876
|
+
compiler = FormulaCompiler({})
|
|
877
|
+
|
|
878
|
+
try:
|
|
879
|
+
compiler.compile("missing_variable")
|
|
880
|
+
except FormulaNotFoundError:
|
|
881
|
+
print("That variable is not registered")
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
---
|
|
885
|
+
|
|
886
|
+
## Library surface
|
|
887
|
+
|
|
888
|
+
The top-level package exposes:
|
|
889
|
+
|
|
890
|
+
```python
|
|
891
|
+
from pyformula import Formula, FormulaCompiler, VariablesRegistry, variable
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
And the main submodules are:
|
|
895
|
+
|
|
896
|
+
- `pyformula.formula` — core `Formula` implementation
|
|
897
|
+
- `pyformula.variable` — decorator for named formulas
|
|
898
|
+
- `pyformula.registry` — registry for named variables
|
|
899
|
+
- `pyformula.compiler` — expression-tree compilation logic
|
|
900
|
+
- `pyformula.math` — wrapper math functions for formulas
|
|
901
|
+
- `pyformula.models` — numeric and operator type aliases
|
|
902
|
+
- `pyformula.exceptions` — library-specific errors
|
|
903
|
+
|
|
904
|
+
---
|
|
905
|
+
|
|
906
|
+
## Summary
|
|
907
|
+
|
|
908
|
+
pyformula is best thought of as a small expression system for domain objects.
|
|
909
|
+
|
|
910
|
+
It gives you three core capabilities:
|
|
911
|
+
|
|
912
|
+
- build formulas from object accessors
|
|
913
|
+
- compose formulas with arithmetic and math wrappers
|
|
914
|
+
- compile nested expression dictionaries into runtime formulas
|
|
915
|
+
|
|
916
|
+
That makes it particularly powerful when formulas are part of business logic, policy decisions, or configurable calculations rather than just ad hoc arithmetic.
|
|
917
|
+
|
|
918
|
+
If you want a calculation model that is:
|
|
919
|
+
|
|
920
|
+
- readable
|
|
921
|
+
- declarative
|
|
922
|
+
- composable
|
|
923
|
+
- inspectable
|
|
924
|
+
- and easy to version or parameterize
|
|
925
|
+
|
|
926
|
+
then pyformula is a good fit.
|
|
927
|
+
|
|
928
|
+
---
|
|
929
|
+
|
|
930
|
+
## Full example from the repository
|
|
931
|
+
|
|
932
|
+
The repository’s `scripts/with_pyspecification.py` is an excellent demonstration of the library’s real-world pattern: boolean rule predicates choose which numeric formula to run.
|
|
933
|
+
|
|
934
|
+
That file is the best example of building a policy engine where rule selection and numeric calculation are separated cleanly.
|
|
935
|
+
|
|
936
|
+
In other words:
|
|
937
|
+
|
|
938
|
+
- `pyspecification` decides whether the rule applies
|
|
939
|
+
- `pyformula` computes the value
|
|
940
|
+
|
|
941
|
+
This pairing is especially useful when the final output is numeric but the decision logic itself is rule-driven.
|