Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: Add first draft of mkdocs documentation #79

Merged
merged 8 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,5 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
site/
docker/actual-data/
19 changes: 19 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Read the Docs configuration file for MkDocs projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details

# Required
version: 2

# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.12"

mkdocs:
configuration: mkdocs.yml

# Optionally declare the Python requirements required to build your docs
python:
install:
- requirements: docs/requirements.txt
197 changes: 42 additions & 155 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,15 @@ from actual import Actual
from actual.queries import get_transactions

with Actual(
base_url="http://localhost:5006", # Url of the Actual Server
password="<your_password>", # Password for authentication
encryption_password=None, # Optional: Password for the file encryption. Will not use it if set to None.
file="<file_id_or_name>", # Set the file to work with. Can be either the file id or file name, if name is unique
data_dir="<path_to_data_directory>", # Optional: Directory to store downloaded files. Will use a temporary if not provided
cert="<path_to_cert_file>" # Optional: Path to the certificate file to use for the connection, can also be set as False to disable SSL verification
base_url="http://localhost:5006", # Url of the Actual Server
password="<your_password>", # Password for authentication
encryption_password=None, # Optional: Password for the file encryption. Will not use it if set to None.
# Set the file to work with. Can be either the file id or file name, if name is unique
file="<file_id_or_name>",
# Optional: Directory to store downloaded files. Will use a temporary if not provided
data_dir="<path_to_data_directory>",
# Optional: Path to the certificate file to use for the connection, can also be set as False to disable SSL verification
cert="<path_to_cert_file>"
) as actual:
transactions = get_transactions(actual.session)
for t in transactions:
Expand All @@ -56,7 +59,7 @@ The `file` will be matched to either one of the following:
- The ID of the budget, a UUID that is only available if you inspect the result of the method `list_user_files`
- The Sync ID of the budget, a UUID available on the frontend on the "Advanced options"
- If none of those options work for you, you can search for the file manually with `list_user_files` and provide the
object directly:
object directly:

```python
from actual import Actual
Expand All @@ -66,150 +69,7 @@ with Actual("http://localhost:5006", password="mypass") as actual:
actual.download_budget()
```

## Adding new transactions

After you created your first budget (or when updating an existing budget), you can add new transactions by adding them
using the `actual.session.add()` method. You cannot use the SQLAlchemy session directly because that adds the entries to your
local database, but will not sync the results back to the server (that is only possible when re-uploading the file).

The method will make sure the local database is updated, but will also send a SYNC request with the added data so that
it will be immediately available on the frontend:

```python
import decimal
import datetime
from actual import Actual
from actual.queries import create_transaction, create_account

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
act = create_account(actual.session, "My account")
t = create_transaction(
actual.session,
datetime.date.today(),
act,
"My payee",
notes="My first transaction",
amount=decimal.Decimal(-10.5),
)
actual.commit() # use the actual.commit() instead of session.commit()!
```

Will produce:

![added-transaction](https://github.com/bvanelli/actualpy/blob/main/docs/static/added-transaction.png?raw=true)

## Updating existing transactions

You may also update transactions using the SQLModel directly, you just need to make sure to commit the results at the
end:

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


with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
for transaction in get_transactions(actual.session):
# change the transactions notes
if transaction.notes is not None and "my pattern" in transaction.notes:
transaction.notes = transaction.notes + " my suffix!"
# commit your changes!
actual.commit()

```

> [!IMPORTANT]
> You can also modify the relationships, for example the `transaction.payee.name`, but you to be aware that
> this payee might be used for more than one transaction. Whenever the relationship is anything but 1:1, you have to
> track the changes already done to prevent modifying a field twice.

## Generating backups

You can use actualpy to generate regular backups of your server files. Here is a script that will backup your server
file on the current folder:

```python
from actual import Actual
from datetime import datetime

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
current_date = datetime.now().strftime("%Y%m%d-%H%M")
actual.export_data(f"actual_backup_{current_date}.zip")
```

# Experimental features

> [!CAUTION]
> Experimental features do not have all the testing necessary to ensure correctness in comparison to the
> files generated by the Javascript API. This means that this operations could in theory corrupt the data. Make sure
> you have backups of your data before trying any of those operations.

## Bootstraping a new server and uploading a first file

The following script would generate a new empty budget on the Actual server, even if the server was not bootstrapped
with an initial password.

```python
from actual import Actual

with Actual(base_url="http://localhost:5006", password="mypass", bootstrap=True) as actual:
actual.create_budget("My budget")
actual.upload_budget()
```

You will then have a freshly created new budget to use:

![created-budget](https://github.com/bvanelli/actualpy/blob/main/docs/static/new-budget.png?raw=true)

If the `encryption_password` is set, the budget will additionally also be encrypted on the upload step to the server.

## Updating transactions using Bank Sync

If you have either [goCardless](https://actualbudget.org/docs/advanced/bank-sync/#gocardless-setup) or
[simplefin](https://actualbudget.org/docs/experimental/simplefin-sync/) integration configured, it is possible to
update the transactions using just the Python API alone. This is because the actual queries to the third-party service
are handled on the server, so the client does not have to do any custom API queries.

To sync your account, simply call the `run_bank_sync` method:

```python
from actual import Actual

with Actual(base_url="http://localhost:5006", password="mypass") as actual:
synchronized_transactions = actual.run_bank_sync()
for transaction in synchronized_transactions:
print(f"Added of modified {transaction}")
# sync changes back to the server
actual.commit()
```

## Running rules

You can also automatically run rules using the library:

```python
from actual import Actual

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
actual.run_rules()
# sync changes back to the server
actual.commit()
```

You can also manipulate the rules individually:

```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)
transactions = get_transactions(actual.session)
for rule in rs:
for t in transactions:
if rule.evaluate(t):
print(f"Rule {rule} matches for {t}")
```
Checkout [the full documentation](https://actualpy.readthedocs.com) for more examples.

# Understanding how Actual handles changes

Expand All @@ -225,29 +85,56 @@ change, done locally, a SYNC request is sent to the server with a list of the fo
- `row`: the row identifier for the entry that was added/update. This would be the primary key of the row (a uuid value)
- `column`: the column that had the value changed
- `value`: the new value. Since it's a string, the values are either prefixed by `S:` to denote a string, `N:` to denote
a numeric value and `0:` to denote a null value.
a numeric value and `0:` to denote a null value.

All individual column changes are computed on an insert, serialized with protobuf and sent to the server to be stored.
Null values and server defaults are not required to be present in the SYNC message, unless a column is changed to null.
If the file is encrypted, the protobuf content will also be encrypted, so that the server does not know what was changed.
If the file is encrypted, the protobuf content will also be encrypted, so that the server does not know what was
changed.

New clients can use this individual changes to then sync their local copies and add the changes executed on other users.
Whenever a SYNC request is done, the response will also contain changes that might have been done in other browsers, so
that the user the retrieve the information and update its local copy.

But this also means that new users need to download a long list of changes, possibly making the initialization slow.
Thankfully, user is also allowed to reset the sync. When doing a reset of the file via frontend, the browser is then
resetting the file completely and clearing the list of changes. This would make sure all changes are actually stored in the
resetting the file completely and clearing the list of changes. This would make sure all changes are actually stored in
the
database. This is done on the frontend under *Settings > Reset sync*, and causes the current file to be reset (removed
from the server) and re-uploaded again, with all changes already in place.

This means that, when using this library to operate changes on the database, you have to make sure that either:

- do a sync request is made using the `actual.commit()` method. This only handles pending operations that haven't yet
been committed, generates a change list with them and posts them on the sync endpoint.
been committed, generates a change list with them and posts them on the sync endpoint.
- do a full re-upload of the database is done.

# Contributing

The goal is to have more features implemented and tested on the Actual API. If you have ideas, comments, bug fixes or
requests feel free to open an issue or submit a pull request.

To install requirements, install both requirements files:

```bash
# optionally setup a venv (recommended)
python3 -m venv venv && source venv/bin/activate
# install requirements
pip install -r requirements.txt
pip install -r requirements-dev.txt
```

We use [`pre-commit`](https://pre-commit.com/) to ensure consistent formatting across different developers. To develop
locally, make sure you install all development requirements, then install `pre-commit` hooks. This would make sure the
formatting runs on every commit.

```
pre-commit install
```

To run tests, make sure you have docker installed ([how to install docker](https://docs.docker.com/engine/install/)).
Run the tests on your machine:

```bash
pytest
```
32 changes: 24 additions & 8 deletions actual/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,16 @@ def __init__(
file: str = None,
encryption_password: str = None,
data_dir: Union[str, pathlib.Path] = None,
cert: str | bool = False,
cert: str | bool = None,
bootstrap: bool = False,
sa_kwargs: dict = None,
):
"""
Implements the Python API for the Actual Server in order to be able to read and modify information on Actual
books using Python.

Parts of the implementation are available at the following file:
https://github.com/actualbudget/actual/blob/2178da0414958064337b2c53efc95ff1d3abf98a/packages/loot-core/src/server/cloud-storage.ts
Parts of the implementation are [available at the following file.](
https://github.com/actualbudget/actual/blob/2178da0414958064337b2c53efc95ff1d3abf98a/packages/loot-core/src/server/cloud-storage.ts)

:param base_url: url of the running Actual server
:param token: the token for authentication, if this is available (optional)
Expand All @@ -73,10 +73,12 @@ def __init__(
:param encryption_password: password used to configure encryption, if existing
:param data_dir: where to store the downloaded files from the server. If not specified, a temporary folder will
be created instead.
:param cert: if a custom certificate should be used (i.e. self-signed certificate), it's path can be provided
as a string. Set to `False` for no certificate check.
:param bootstrap: if the server is not bootstrapped, bootstrap it with the password.
:param sa_kwargs: additional kwargs passed to the SQLAlchemy session maker. Examples are `autoflush` (enabled
by default), `autocommit` (disabled by default). For a list of all parameters, check the SQLAlchemy
documentation: https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.Session.__init__
by default), `autocommit` (disabled by default). For a list of all parameters, check the [SQLAlchemy
documentation.](https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.Session.__init__)
"""
super().__init__(base_url, token, password, bootstrap, cert)
self._file: RemoteFileListDTO | None = None
Expand Down Expand Up @@ -196,11 +198,13 @@ def create_budget(self, budget_name: str):
self.load_clock()

def rename_budget(self, budget_name: str):
"""Renames the budget with the given name."""
if not self._file:
raise UnknownFileId("No current file loaded.")
self.update_user_file_name(self._file.file_id, budget_name)

def delete_budget(self):
"""Deletes the currently loaded file from the server."""
if not self._file:
raise UnknownFileId("No current file loaded.")
self.delete_user_file(self._file.file_id)
Expand Down Expand Up @@ -249,7 +253,9 @@ def encrypt(self, encryption_password: str):
self.set_file(self._file.file_id)

def upload_budget(self):
"""Uploads the current file to the Actual server."""
"""Uploads the current file to the Actual server. If attempting to upload your first budget, make sure you use
[actual.Actual.create_budget][] first.
"""
if not self._data_dir:
raise UnknownFileId("No current file loaded.")
if not self._file:
Expand All @@ -270,6 +276,9 @@ def upload_budget(self):
self.encrypt(self._encryption_password)

def reupload_budget(self):
"""Similar to the reset sync option from the frontend, resets the user file on the backend and reuploads the
current copy instead. **This operation can be destructive**, so make sure you generate a copy before
attempting to reupload your budget."""
self.reset_user_file(self._file.file_id)
self.upload_budget()

Expand Down Expand Up @@ -349,6 +358,8 @@ def download_budget(self, encryption_password: str = None):
self._session = strong_reference_session(Session(self.engine, **self._sa_kwargs))

def import_zip(self, file_bytes: str | PathLike[str] | IO[bytes]):
"""Imports a zip file as the current database, as well as generating the local reflected session. Enables you
to inspect backups by loading them directly, instead of unzipping the contents."""
try:
zip_file = zipfile.ZipFile(file_bytes)
except zipfile.BadZipfile as e:
Expand All @@ -363,6 +374,9 @@ def import_zip(self, file_bytes: str | PathLike[str] | IO[bytes]):
self.load_clock()

def sync(self):
"""Does a sync request and applies all changes that are stored on the server on the local copy of the database.
Since all changes are retrieved, this function cannot be used for partial changes (since the budget is online).
"""
# after downloading the budget, some pending transactions still need to be retrieved using sync
request = SyncRequest(
{
Expand All @@ -379,8 +393,9 @@ def sync(self):
self._client = HULC_Client.from_timestamp(changes.messages[-1].timestamp)

def load_clock(self) -> MessagesClock:
"""See implementation at:
https://github.com/actualbudget/actual/blob/5bcfc71be67c6e7b7c8b444e4c4f60da9ea9fdaa/packages/loot-core/src/server/db/index.ts#L81-L98
"""Loads the HULC Clock from the database. This clock tells the server from when the messages should be
retrieved. See the [original implementation.](
https://github.com/actualbudget/actual/blob/5bcfc71be67c6e7b7c8b444e4c4f60da9ea9fdaa/packages/loot-core/src/server/db/index.ts#L81-L98)
"""
with Session(self.engine) as session:
clock = session.exec(select(MessagesClock)).one_or_none()
Expand Down Expand Up @@ -420,6 +435,7 @@ def commit(self):
self.sync_sync(req)

def run_rules(self):
"""Runs all the stored rules on the database on all transactions, without any filters."""
ruleset = get_ruleset(self.session)
transactions = get_transactions(self.session, is_parent=True)
ruleset.run(transactions)
Expand Down
Loading
Loading