diff --git a/.gitignore b/.gitignore index a6399e3..6f54fe2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..015eb5d --- /dev/null +++ b/.readthedocs.yaml @@ -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 diff --git a/README.md b/README.md index 9b04e08..e8efa9f 100644 --- a/README.md +++ b/README.md @@ -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="", # Password for authentication - encryption_password=None, # Optional: Password for the file encryption. Will not use it if set to None. - file="", # Set the file to work with. Can be either the file id or file name, if name is unique - data_dir="", # Optional: Directory to store downloaded files. Will use a temporary if not provided - cert="" # 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="", # 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="", + # Optional: Directory to store downloaded files. Will use a temporary if not provided + data_dir="", + # Optional: Path to the certificate file to use for the connection, can also be set as False to disable SSL verification + cert="" ) as actual: transactions = get_transactions(actual.session) for t in transactions: @@ -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 @@ -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 @@ -225,11 +85,12 @@ 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 @@ -237,17 +98,43 @@ 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 +``` diff --git a/actual/__init__.py b/actual/__init__.py index f301c27..c9fa1dc 100644 --- a/actual/__init__.py +++ b/actual/__init__.py @@ -55,7 +55,7 @@ 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, ): @@ -63,8 +63,8 @@ def __init__( 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) @@ -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 @@ -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) @@ -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: @@ -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() @@ -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: @@ -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( { @@ -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() @@ -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) diff --git a/actual/api/__init__.py b/actual/api/__init__.py index 25b10c2..9f03605 100644 --- a/actual/api/__init__.py +++ b/actual/api/__init__.py @@ -37,8 +37,20 @@ def __init__( token: str = None, password: str = None, bootstrap: bool = False, - cert: str | bool = False, + cert: str | bool = None, ): + """ + Implements the low-level API for interacting with the Actual server by just implementing the API calls and + response models. + + :param base_url: url of the running Actual server + :param token: the token for authentication, if this is available (optional) + :param password: the password for authentication. It will be used on the .login() method to retrieve the token. + be created instead. + :param bootstrap: if the server is not bootstrapped, bootstrap it with the password. + :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. + """ self.api_url = base_url self._token = token self.cert = cert @@ -58,8 +70,8 @@ def login(self, password: str, method: Literal["password", "header"] = "password authenticate the user. :param password: password of the Actual server. - :param method: the method used to authenticate with the server. Check - https://actualbudget.org/docs/advanced/http-header-auth/ for information. + :param method: the method used to authenticate with the server. Check the [official auth header documentation]( + https://actualbudget.org/docs/advanced/http-header-auth/) for information. """ if not password: raise AuthorizationError("Trying to login but not password was provided.") diff --git a/actual/crypto.py b/actual/crypto.py index 65d1d01..bb0b332 100644 --- a/actual/crypto.py +++ b/actual/crypto.py @@ -71,25 +71,18 @@ def make_test_message(key_id: str, key: bytes) -> dict: def is_uuid(text: str, version: int = 4): """ - Check if uuid_to_test is a valid UUID. + Check if uuid_to_test is a valid UUID. Taken from [this thread](https://stackoverflow.com/a/54254115/12681470) - Taken from https://stackoverflow.com/a/54254115/12681470 + Examples: - Parameters - ---------- - uuid_to_test : str - version : {1, 2, 3, 4} - - Returns - ------- - `True` if uuid_to_test is a valid UUID, otherwise `False`. - - Examples - -------- >>> is_uuid('c9bf9e57-1685-4c89-bafb-ff5af830be8a') True >>> is_uuid('c9bf9e58') False + + :param text: UUID string to test + :param version: expected version for the UUID + :return: `True` if `text` is a valid UUID, otherwise `False`. """ try: uuid.UUID(str(text), version=version) diff --git a/actual/database.py b/actual/database.py index adbef24..5a315cc 100644 --- a/actual/database.py +++ b/actual/database.py @@ -2,9 +2,13 @@ This file was partially generated using sqlacodegen using the downloaded version of the db.sqlite file export in order to update this file, you can generate the code with: -> sqlacodegen --generator sqlmodels sqlite:///db.sqlite +```bash +sqlacodegen --generator sqlmodels sqlite:///db.sqlite +``` -and patch the necessary models by merging the results. +and patch the necessary models by merging the results. The [actual.database.BaseModel][] defines all models that can +be updated from the user, and must contain a unique `id`. Those models can then be converted automatically into a +protobuf change message using [actual.database.BaseModel.convert][]. """ import datetime @@ -35,18 +39,20 @@ """ This variable contains the internal model mappings for all databases. It solves a couple of issues, namely having the -mapping from __tablename__ to the actual SQLAlchemy class, and later mapping the SQL column into the Pydantic field, +mapping from `__tablename__` to the actual SQLAlchemy class, and later mapping the SQL column into the Pydantic field, which could be different and follows the Python naming convention. An example is the field `Transactions.is_parent`, that converts into the SQL equivalent `transactions.isParent`. In this case, we would have the following entries: - __TABLE_COLUMNS_MAP__ = { - "transactions": { - "entity": , - "columns": { - "isParent": "is_parent" - } +``` +__TABLE_COLUMNS_MAP__ = { + "transactions": { + "entity": , + "columns": { + "isParent": "is_parent" } } +} +``` """ __TABLE_COLUMNS_MAP__ = dict() @@ -61,7 +67,7 @@ def reflect_model(eng: engine.Engine) -> MetaData: def get_class_from_reflected_table_name(metadata: MetaData, table_name: str) -> Union[Table, None]: """ Returns, based on the defined tables on the reflected model the corresponding SQLAlchemy table. - If not found, returns None. + If not found, returns `None`. """ return metadata.tables.get(table_name, None) @@ -70,7 +76,7 @@ def get_attribute_from_reflected_table_name( metadata: MetaData, table_name: str, column_name: str ) -> Union[Column, None]: """ - Returns, based, on the defined reflected model the corresponding and the SAColumn. If not found, returns None. + Returns, based, on the defined reflected model the corresponding and the SAColumn. If not found, returns `None`. """ table = get_class_from_reflected_table_name(metadata, table_name) return table.columns.get(column_name, None) @@ -78,16 +84,17 @@ def get_attribute_from_reflected_table_name( def get_class_by_table_name(table_name: str) -> Union[SQLModel, None]: """ - Returns, based on the defined tables __tablename__ the corresponding SQLModel object. If not found, returns None. + Returns, based on the defined tables `__tablename__` the corresponding SQLModel object. If not found, returns + `None`. """ return __TABLE_COLUMNS_MAP__.get(table_name, {}).get("entity", None) def get_attribute_by_table_name(table_name: str, column_name: str, reverse: bool = False) -> Union[str, None]: """ - Returns, based, on the defined tables __tablename__ and the SAColumn name, the correct pydantic attribute. Search + Returns, based, on the defined tables `__tablename__` and the SAColumn name, the correct pydantic attribute. Search can be reversed by setting the `reverse` flag to `True`. - If not found, returns None. + If not found, returns `None`. :param table_name: SQL table name. :param column_name: SQL column name. @@ -135,9 +142,8 @@ class BaseModel(SQLModel): id: str = Field(sa_column=Column("id", Text, primary_key=True)) def convert(self, is_new: bool = True) -> List[Message]: - """Convert the object into distinct entries for sync method. Based on the original implementation: - - https://github.com/actualbudget/actual/blob/98c17bd5e0f13e27a09a7f6ac176510530572be7/packages/loot-core/src/server/aql/schema-helpers.ts#L146 + """Convert the object into distinct entries for sync method. Based on the [original implementation]( + https://github.com/actualbudget/actual/blob/98c17bd5e0f13e27a09a7f6ac176510530572be7/packages/loot-core/src/server/aql/schema-helpers.ts#L146) """ row = getattr(self, "id", None) # also helps lazy loading the instance if row is None: diff --git a/actual/protobuf_models.py b/actual/protobuf_models.py index 7464de9..1186368 100644 --- a/actual/protobuf_models.py +++ b/actual/protobuf_models.py @@ -11,13 +11,11 @@ from actual.exceptions import ActualDecryptionError """ -Protobuf message definitions taken from: +Protobuf message definitions taken from the [sync.proto file]( +https://github.com/actualbudget/actual/blob/029e2f09bf6caf386523bbfa944ab845271a3932/packages/crdt/src/proto/sync.proto). -https://github.com/actualbudget/actual/blob/029e2f09bf6caf386523bbfa944ab845271a3932/packages/crdt/src/proto/sync.proto - -They should represent how the server take requests from the client. The server side implementation is available here: - -https://github.com/actualbudget/actual-server/blob/master/src/app-sync.js#L32 +They should represent how the server take requests from the client. The server side implementation is available [here]( +https://github.com/actualbudget/actual-server/blob/master/src/app-sync.js#L32). """ @@ -34,11 +32,13 @@ def from_timestamp(cls, ts: str) -> HULC_Client: def timestamp(self, now: datetime.datetime = None) -> str: """Actual uses Hybrid Unique Logical Clock (HULC) timestamp generator. - Timestamps serialize into a 46-character collatable string - * example: 2015-04-24T22:23:42.123Z-1000-0123456789ABCDEF - * example: 2015-04-24T22:23:42.123Z-1000-A219E7A71CC18912 + Timestamps serialize into a 46-character collatable string. Examples: + + - `2015-04-24T22:23:42.123Z-1000-0123456789ABCDEF` + - `2015-04-24T22:23:42.123Z-1000-A219E7A71CC18912` - See https://github.com/actualbudget/actual/blob/a9362cc6f9b974140a760ad05816cac51c849769/packages/crdt/src/crdt/timestamp.ts + See [original source code]( + https://github.com/actualbudget/actual/blob/a9362cc6f9b974140a760ad05816cac51c849769/packages/crdt/src/crdt/timestamp.ts) for reference. """ if not now: @@ -48,9 +48,8 @@ def timestamp(self, now: datetime.datetime = None) -> str: return f"{now.isoformat(timespec='milliseconds')}Z-{count}-{self.client_id}" def get_client_id(self): - """Creates a client id for the HULC request. Copied implementation from: - - https://github.com/actualbudget/actual/blob/a9362cc6f9b974140a760ad05816cac51c849769/packages/crdt/src/crdt/timestamp.ts#L80 + """Creates a client id for the HULC request. Implementation copied [from the source code]( + https://github.com/actualbudget/actual/blob/a9362cc6f9b974140a760ad05816cac51c849769/packages/crdt/src/crdt/timestamp.ts#L80) """ return ( self.client_id if getattr(self, "client_id", None) is not None else str(uuid.uuid4()).replace("-", "")[-16:] @@ -70,9 +69,8 @@ class Message(proto.Message): value = proto.Field(proto.STRING, number=4) def get_value(self) -> str | int | float | None: - """Serialization types from Actual. Source: - - https://github.com/actualbudget/actual/blob/998efb9447da6f8ce97956cbe83d6e8a3c18cf53/packages/loot-core/src/server/sync/index.ts#L154-L160 + """Serialization types from Actual. [Original source code]( + https://github.com/actualbudget/actual/blob/998efb9447da6f8ce97956cbe83d6e8a3c18cf53/packages/loot-core/src/server/sync/index.ts#L154-L160) """ datatype, _, value = self.value.partition(":") if datatype == "S": diff --git a/docs/API-reference/actual.md b/docs/API-reference/actual.md new file mode 100644 index 0000000..4d3ce9b --- /dev/null +++ b/docs/API-reference/actual.md @@ -0,0 +1,5 @@ +# Actual + +::: actual.Actual + options: + inherited_members: false diff --git a/docs/API-reference/endpoints.md b/docs/API-reference/endpoints.md new file mode 100644 index 0000000..0ec42be --- /dev/null +++ b/docs/API-reference/endpoints.md @@ -0,0 +1,6 @@ +# Endpoints + +::: actual.api +::: actual.api.models +::: actual.api.bank_sync +::: actual.protobuf_models diff --git a/docs/API-reference/models.md b/docs/API-reference/models.md new file mode 100644 index 0000000..af17886 --- /dev/null +++ b/docs/API-reference/models.md @@ -0,0 +1,5 @@ +# Database models + +::: actual.database + options: + inherited_members: false diff --git a/docs/API-reference/queries.md b/docs/API-reference/queries.md new file mode 100644 index 0000000..facc4bd --- /dev/null +++ b/docs/API-reference/queries.md @@ -0,0 +1,3 @@ +# Queries + +::: actual.queries diff --git a/docs/experimental-features.md b/docs/experimental-features.md new file mode 100644 index 0000000..38fb49c --- /dev/null +++ b/docs/experimental-features.md @@ -0,0 +1,74 @@ +# Experimental features + +!!! danger + 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](./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}") +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..615ae6b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,72 @@ +# Quickstart + +## 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](./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() + +``` + +!!! warning + 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") +``` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..b8a1467 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +mkdocs-material +mkdocs +mkdocstrings-python +griffe-fieldz +black diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d0ed4f2 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,62 @@ +site_name: actualpy Documentation +strict: true +site_description: A Python re-implementation of the NodeJS API for Actual Budget +repo_name: bvanelli/actualpy +repo_url: https://github.com/bvanelli/actualpy +edit_uri: edit/main/docs/ + +theme: + name: material + palette: + # Palette toggle for light mode + - scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + # Palette toggle for dark mode + - scheme: slate + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - content.code.copy + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + +plugins: +- search +- mkdocstrings: + handlers: + python: + import: + - https://docs.python.org/3/objects.inv + options: + docstring_style: sphinx + docstring_options: + ignore_init_summary: true + docstring_section_style: list + filters: ["!^_"] + heading_level: 1 + inherited_members: true + merge_init_into_class: true + parameter_headings: true + separate_signature: true + members_order: source + show_root_heading: true + show_root_full_path: false + show_signature_annotations: true + show_symbol_type_heading: true + show_symbol_type_toc: true + signature_crossrefs: true + summary: true + extensions: + - griffe_fieldz: { include_inherited: true } diff --git a/requirements-dev.txt b/requirements-dev.txt index f7fe509..047523e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,4 @@ pytest-mock pytest pytest-cov testcontainers +pre-commit diff --git a/setup.py b/setup.py index 5814d9d..7cabd57 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ version=__version__, packages=find_packages(), description="Implementation of the Actual API to interact with Actual over Python.", - long_description=open("README.md").read(), + long_description=open("README.md").read().replace("> [!WARNING]", "⚠️**Warning**: "), long_description_content_type="text/markdown", author="Brunno Vanelli", author_email="brunnovanelli@gmail.com",