Skip to content

Commit

Permalink
docs: Add more documentation on how to work with rules (#85)
Browse files Browse the repository at this point in the history
Improves and polishes documentation for running rules locally. Includes examples on how to runs rules on newly created transactions, as well as including the rules and exceptions on the documentation.

Closes #68
  • Loading branch information
bvanelli authored Oct 12, 2024
1 parent 3a69ca6 commit e7503be
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 53 deletions.
29 changes: 28 additions & 1 deletion actual/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import requests


def get_exception_from_response(response: requests.Response):
def get_exception_from_response(response: requests.Response) -> Exception:
text = response.content.decode()
if text == "internal-error" or response.status_code == 500:
return ActualError(text)
Expand All @@ -21,37 +21,64 @@ def get_exception_from_response(response: requests.Response):


class ActualError(Exception):
"""General error with Actual. The error message should provide more information."""

pass


class ActualInvalidOperationError(ActualError):
"""Invalid operation requested. Happens usually when a request has been done, but it's missing a required
parameters."""

pass


class AuthorizationError(ActualError):
"""When the login fails due to invalid credentials, or a request has been done with the wrong credentials
(i.e. invalid token)"""

pass


class UnknownFileId(ActualError):
"""When the file id that has been set does not exist on the server."""

pass


class InvalidZipFile(ActualError):
"""
The validation fails when loading a zip file, either because it's an invalid zip file or the file is corrupted.
"""

pass


class InvalidFile(ActualError):

pass


class ActualDecryptionError(ActualError):
"""
The decryption for the file failed. This can happen for a multitude or reasons, like the password is wrong, the file
is corrupted, or when the password is not provided but the file is encrypted.
"""

pass


class ActualSplitTransactionError(ActualError):
"""The split transaction is invalid, most likely because the sum of splits is not equal the full amount of the
transaction."""

pass


class ActualBankSyncError(ActualError):
"""The bank sync had an error, due to the service being unavailable or due to authentication issues with the
third-party service. This likely indicates a problem with the configuration of the bank sync, not an issue with
this library."""

def __init__(self, error_type: str, status: str = None, reason: str = None):
self.error_type, self.status, self.reason = error_type, status, reason
115 changes: 67 additions & 48 deletions actual/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,25 +234,27 @@ def condition_evaluation(

class Condition(pydantic.BaseModel):
"""
A condition does a single comparison check for a transaction. The 'op' indicates the action type, usually being
set to IS or CONTAINS, and the operation applied to a 'field' with certain 'value'. If the transaction value matches
the condition, the `run` method returns `True`, otherwise it returns `False`.
A condition does a single comparison check for a transaction. The `op` indicates the action type, usually being
set to `IS` or `CONTAINS`, and the comparison is applied to a `field` with certain `value`. If the transaction
value matches the condition, the `run` method returns `True`, otherwise it returns `False`. The individual condition
cannot change the value of the transaction, as only the [Action][actual.rules.Action] can.
**Important**: Actual shows the amount on frontend as decimal but handles it internally as cents. Make sure that, if
you provide the 'amount' rule manually, you either provide number of cents or a float that get automatically
converted to cents.
you provide the `amount` rule manually, you either provide number of cents, as an integers, or a float that get
automatically converted to cents. As an example, `50` will be interpreted as 50 cents, but `50.0` will be
interpreted as 50 of the currency (or 5000 cents).
The 'field' can be one of the following ('type' will be set automatically):
- imported_description: 'type' must be 'string' and 'value' any string
- acct: 'type' must be 'id' and 'value' a valid uuid
- category: 'type' must be 'id' and 'value' a valid uuid
- date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11'
- description: 'type' must be 'id' and 'value' a valid uuid (means payee_id)
- notes: 'type' must be 'string' and 'value' any string
- amount: 'type' must be 'number' and format in cents
- amount_inflow: 'type' must be 'number' and format in cents, will set "options":{"inflow":true}
- amount_outflow: 'type' must be 'number' and format in cents, will set "options":{"outflow":true}
- `imported_description`: `type` must be `string` and `value` any string
- `acct`: `type` must be `id` and `value` a valid uuid
- `category`: `type` must be `id` and `value` a valid uuid
- `date`: `type` must be `date` and `value` a string in the date format `'2024-04-11'`
- `description`: `type` must be `id` and `value` a valid uuid (means payee_id)
- `notes`: `type` must be 'string' and `value` any string
- `amount`: `type` must be `number` and format in cents
- `amount_inflow`: `type` must be `number` and format in cents, will set `"options":{"inflow":true}`
- `amount_outflow`: `type` must be `number` and format in cents, will set `"options":{"outflow":true}`
"""

field: typing.Literal[
Expand Down Expand Up @@ -333,24 +335,26 @@ def run(self, transaction: Transactions) -> bool:

class Action(pydantic.BaseModel):
"""
An Action does a single column change for a transaction. The 'op' indicates the action type, usually being to SET
a 'field' with certain 'value'.
For the 'op' LINKED_SCHEDULE, the operation will link the transaction to a certain schedule id that generated it.
For the 'op' SET_SPLIT_AMOUNT, the transaction will be split into multiple different splits depending on the rules
defined by the user, being it on 'fixed-amount', 'fixed-percent' or 'remainder'. The options will then be on the
format {"method": "remainder", "splitIndex": 1}
The 'field' can be one of the following ('type' will be set automatically):
- category: 'type' must be 'id' and 'value' a valid uuid
- description: 'type' must be 'id' 'value' a valid uuid (additional "options":{"splitIndex":0})
- notes: 'type' must be 'string' and 'value' any string
- cleared: 'type' must be 'boolean' and value is a literal True/False (additional "options":{"splitIndex":0})
- acct: 'type' must be 'id' and 'value' an uuid
- date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11'
- amount: 'type' must be 'number' and format in cents
An Action does a single column change for a transaction. The `op` indicates the
[ActionType][actual.rules.ActionType], usually being to SET a `field` with certain `value`.
For the `op` `LINKED_SCHEDULE`, the operation will link the transaction to a certain schedule id that generated it.
This is often done automatically when you create schedules, as they are also rules internally.
For the `op` `SET_SPLIT_AMOUNT`, the transaction will be split into multiple different splits depending on the
method defined by the user, being it on `fixed-amount`, `fixed-percent` or `remainder`. The method will be stored
under `options` with the format `{"method": "remainder", "splitIndex": 1}`, indicating both the method of splitting
but also which index that split will take.
The `field` can be one of the following (`type` will be set automatically based on the database):
- `category`: `type` must be `id` and `value` a valid uuid
- `description`: `type` must be `id` `value` a valid uuid (additional `"options":{"splitIndex":0}`)
- `notes`: `type` must be `string` and `value` any string
- `cleared`: `type` must be `boolean` and `value` is a literal True/False (additional `"options":{"splitIndex":0}`)
- `acct`: `type` must be `id` and `value` an uuid
- `date`: `type` must be `date` and `value` a string in the date format `"2024-04-11"`
- `amount`: `type` must be `number` and format in cents
"""

field: typing.Optional[typing.Literal["category", "description", "notes", "cleared", "acct", "date", "amount"]] = (
Expand Down Expand Up @@ -416,6 +420,8 @@ def check_operation_type(self):
return self

def run(self, transaction: Transactions) -> None:
"""Runs the action on the transaction, regardless of the condition. For the condition based rule, see
[Rule.run][actual.rules.Rule.run]."""
if self.op == ActionType.SET:
attr = get_attribute_by_table_name(Transactions.__tablename__, str(self.field))
value = get_value(self.value, self.type)
Expand Down Expand Up @@ -467,7 +473,8 @@ class Rule(pydantic.BaseModel):

@pydantic.model_validator(mode="before")
def correct_operation(cls, value):
"""If the user provides the same 'all' or 'any' that the frontend provides, we fix it silently."""
"""If the user provides the same `all` or `any` that the frontend provides, we fix it silently to `and` and
`or` respectively."""
if value.get("operation") == "all":
value["operation"] = "and"
elif value.get("operation") == "any":
Expand Down Expand Up @@ -553,27 +560,34 @@ def run(self, transaction: Transactions) -> bool:

class RuleSet(pydantic.BaseModel):
"""
A RuleSet is a collection of Conditions and Actions that will evaluate for one or more transactions.
A RuleSet is a collection of [Conditions][actual.rules.Condition] and [Actions][actual.rules.Action] that will
evaluate for one or more transactions.
The conditions are list of rules that will compare fields from the transaction. If all conditions from a RuleEntry
are met (or any, if the RuleEntry has an 'or' operation), then the actions will be applied.
The [Conditions][actual.rules.Condition] are list of logical comparisons that will compare fields from the
transaction to stored values. If all conditions from a Transaction are met (or any, if the Rule has an `or`
operation), then the actions will be applied.
The actions are a list of changes that will be applied to one or more transaction.
The actions are a list of changes that will be applied to the transaction.
Full example ruleset: "If all of these conditions match 'notes' contains 'foo' then set 'notes' to 'bar'"
**Full example ruleset**: "If all of these conditions match 'notes' contains 'foo' then set 'notes' to 'bar'"
To create that rule set, you can do:
>>> RuleSet(rules=[
>>> Rule(
>>> operation="and",
>>> conditions=[Condition(field="notes", op=ConditionType.CONTAINS, value="foo")],
>>> actions=[Action(field="notes", value="bar")],
>>> )
>>> ])
```python
RuleSet(rules=[
Rule(
# all of these conditions match
operation="and",
# 'notes' contains 'foo'
conditions=[Condition(field="notes", op=ConditionType.CONTAINS, value="foo")],
# set 'notes' to 'bar'
actions=[Action(field="notes", value="bar")],
)
])
```
"""

rules: typing.List[Rule]
rules: typing.List[Rule] = pydantic.Field(..., description="List of rules to be evaluated on run.")

def __str__(self):
return "\n".join([str(r) for r in self.rules])
Expand All @@ -598,8 +612,13 @@ def run(
transaction: typing.Union[Transactions, typing.Sequence[Transactions]],
stage: typing.Literal["all", "pre", "post", None] = "all",
):
"""Runs the rules for each and every transaction on the list. If stage is 'all' (default), all rules are run in
the order 'pre' -> None -> 'post'. You can provide a value to run only a certain stage of rules."""
"""
Runs the rules for each and every transaction on the list. If stage is 'all' (default), all rules are run in
the order `pre` -> `None` -> `post`. You can provide a value to run only a certain stage of rules.
If necessary, you can also individually select the rules you want to run by initializing a new ruleset from
the original one, or select individual rules from, by using the list of rules `RuleSet.rules`.
"""
if stage == "all":
self._run(transaction, "pre")
self._run(transaction, None)
Expand Down
5 changes: 5 additions & 0 deletions docs/API-reference/exceptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Exceptions

::: actual.exceptions
options:
members: true
23 changes: 23 additions & 0 deletions docs/API-reference/rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Rules

::: actual.rules.RuleSet
options:
members: true
::: actual.rules.Rule
options:
members: true
::: actual.rules.Condition
options:
members: true
::: actual.rules.Action
options:
members: true
::: actual.rules.ValueType
options:
members: true
::: actual.rules.ActionType
options:
members: true
::: actual.rules.ConditionType
options:
members: true
41 changes: 37 additions & 4 deletions docs/experimental-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,61 @@ with Actual(base_url="http://localhost:5006", password="mypass") as actual:

## Running rules

You can also automatically run rules using the library:
Rules can be rune individually via the library. You can filter which rules are going to be run, but also check
beforehand which rules actually are going to run, similar to the preview function from Actual.

The most simple case is to run all rules for all transactions at once. This is equivalent as "Apply Actions" for all
rules on frontend:

```python
from actual import Actual
from actual.queries import get_ruleset

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
# print all rules and their human-readable descriptions
print(get_ruleset(actual.session))
# run all rules
actual.run_rules()
# sync changes back to the server
actual.commit()
```

You can also manipulate the rules individually:
You can also manipulate the rules individually, and validate each rule that runs for each transaction, allowing you
to also debug rules. This can be useful when more than one rule is modifying the same transaction, but the order of
operations is not correct:

```python
from actual import Actual
from actual.queries import get_ruleset, get_transactions

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
rs = get_ruleset(actual.session)
ruleset = get_ruleset(actual.session)
transactions = get_transactions(actual.session)
for rule in rs:
for rule in ruleset:
for t in transactions:
if rule.evaluate(t):
print(f"Rule {rule} matches for {t}")
# if you are happy with the result from the rule, apply it
rule.run(t)
# if you want to sync the changes back to the server, uncomment the following line
# actual.commit()
```

If you are importing transactions, the rules are not running automatically. For that reason, you might need to run them
individually. Use the [RuleSet.run][actual.rules.RuleSet.run] for that purpose, to run the rule after creating the
transaction:

```python
from actual import Actual
from actual.queries import get_ruleset, reconcile_transaction
from datetime import date

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
ruleset = get_ruleset(actual.session)
# we create one transaction
t = reconcile_transaction(actual.session, date.today(), "Bank", "", notes="Coffee", amount=-4.50)
# run the rules on the newly created transaction
ruleset.run(t)
# send the changes back to the server
actual.commit()
```

0 comments on commit e7503be

Please sign in to comment.