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.
@@ -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.