Repository: bvanelli/actualpy

Issue:
Generate first alpha version and release to pip
- Add instructions on how to install the package
- Decide on how to do the versioning

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/bvanelli/actualpy/pull/35?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) Report
Attention: Patch coverage is `96.29630%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 96.63%. Comparing base [(`7eecd67`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/7eecd67b47f7a344820f4bd23f8603dd61d8a971?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) to head [(`ff0c50c`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/ff0c50cd4b14030efd56642f2572021596a4ef43?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).

| [Files](https://app.codecov.io/gh/bvanelli/actualpy/pull/35?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) | Patch % | Lines |
|---|---|---|
| [actual/\_\_init\_\_.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/35?src=pr&el=tree&filepath=actual%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL19faW5pdF9fLnB5) | 95.65% | [1 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/35?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #35      +/-   ##
==========================================
+ Coverage   96.59%   96.63%   +0.04%     
==========================================
  Files          13       13              
  Lines        1704     1725      +21     
==========================================
+ Hits         1646     1667      +21     
  Misses         58       58              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bvanelli/actualpy/pull/35?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).


Patch:
diff --git a/README.md b/README.md
index ef120bc..3096b70 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,13 @@ Python API implementation for Actual server.
 
 # Installation
 
-Install it via Pip using the repository url:
+Install it via Pip:
+
+```bash
+pip install actualpy
+```
+
+If you want to have the latest git version, you can also install using the repository url:
 
 ```bash
 pip install git+https://github.com/bvanelli/actualpy.git
@@ -72,7 +78,21 @@ with Actual(base_url="http://localhost:5006", password="mypass", file="My budget
 
 Will produce:
 
-![added-transaction](./docs/static/added-transaction.png)
+![added-transaction](https://github.com/bvanelli/actualpy/blob/main/docs/static/added-transaction.png?raw=true)
+
+## 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
 
@@ -95,7 +115,7 @@ with Actual(base_url="http://localhost:5006", password="mypass", bootstrap=True)
 
 You will then have a freshly created new budget to use:
 
-![created-budget](./docs/static/new-budget.png)
+![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.
 
@@ -152,9 +172,8 @@ and can be encrypted with a local key, so that not even the server can read your
 
 The Actual Server is a way of only hosting files and changes. Since re-uploading the full database on every single
 change is too heavy, Actual only stores one state of the database and everything added by the user via frontend
-or via the APIs are individual changes above the base stored database on the server, stored via separate endpoint.
-This means that on every change, done locally, a SYNC request is sent to the server with a list of the following string
-parameters:
+or via the APIs are individual changes on top of the "base database" stored on the server. This means that on every
+change, done locally, a SYNC request is sent to the server with a list of the following string parameters:
 
 - `dataset`: the name of the table where the change happened.
 - `row`: the row identifier for the entry that was added/update. This would be the primary key of the row (a uuid value)
@@ -163,13 +182,18 @@ parameters:
 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.
+
 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. This would make sure all changes are actually stored in the
+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
 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. In this case, the sync list of changes is
-reset because the server already has the latest version of the database.
+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:
 
diff --git a/actual/__init__.py b/actual/__init__.py
index 614c688..f01efa2 100644
--- a/actual/__init__.py
+++ b/actual/__init__.py
@@ -179,16 +179,26 @@ class Actual(ActualServer):
             raise UnknownFileId("No current file loaded.")
         self.update_user_file_name(self._file.file_id, budget_name)
 
+    def delete_budget(self):
+        if not self._file:
+            raise UnknownFileId("No current file loaded.")
+        self.delete_user_file(self._file.file_id)
+        # reset group id, as file cannot be synced anymore
+        self._file.group_id = None
+
     def export_data(self, output_file: str | PathLike[str] | IO[bytes] = None) -> bytes:
         """Export your data as a zip file containing db.sqlite and metadata.json files. It can be imported into another
         Actual instance by closing an open file (if any), then clicking the “Import file” button, then choosing
-        “Actual.” Even though encryption is enabled, the exported zip file will not have any encryption."""
-        if not output_file:
-            output_file = io.BytesIO()
-        with zipfile.ZipFile(output_file, "a", zipfile.ZIP_DEFLATED, False) as z:
+        “Actual.” Even when encryption is enabled, the exported zip file will not have any encryption."""
+        temp_file = io.BytesIO()
+        with zipfile.ZipFile(temp_file, "a", zipfile.ZIP_DEFLATED, False) as z:
             z.write(self._data_dir / "db.sqlite", "db.sqlite")
             z.write(self._data_dir / "metadata.json", "metadata.json")
-        return output_file.getvalue()
+        content = temp_file.getvalue()
+        if output_file:
+            with open(output_file, "wb") as f:
+                f.write(content)
+        return content
 
     def encrypt(self, encryption_password: str):
         """Encrypts the local database using a new key, and re-uploads to the server.
@@ -221,6 +231,11 @@ class Actual(ActualServer):
         """Uploads the current file to the Actual server."""
         if not self._data_dir:
             raise UnknownFileId("No current file loaded.")
+        if not self._file:
+            file_id = str(uuid.uuid4())
+            metadata = self.get_metadata()
+            budget_name = metadata.get("budgetName", "My Finances")
+            self._file = RemoteFileListDTO(name=budget_name, fileId=file_id, groupId=None, deleted=0, encryptKeyId=None)
         binary_data = io.BytesIO()
         with zipfile.ZipFile(binary_data, "a", zipfile.ZIP_DEFLATED, False) as z:
             z.write(self._data_dir / "db.sqlite", "db.sqlite")
@@ -267,12 +282,17 @@ class Actual(ActualServer):
                 s.flush()
             s.commit()
 
+    def get_metadata(self) -> dict:
+        """Gets the content of metadata.json."""
+        metadata_file = self._data_dir / "metadata.json"
+        return json.loads(metadata_file.read_text())
+
     def update_metadata(self, patch: dict):
         """Updates the metadata.json from the Actual file with the patch fields. The patch is a dictionary that will
         then be merged on the metadata and written again to a file."""
         metadata_file = self._data_dir / "metadata.json"
         if metadata_file.is_file():
-            config = json.loads(metadata_file.read_text()) | patch
+            config = self.get_metadata() | patch
         else:
             config = patch
         metadata_file.write_text(json.dumps(config, separators=(",", ":")))
@@ -371,7 +391,8 @@ class Actual(ActualServer):
         # commit to local database to clear the current flush cache
         self._session.commit()
         # sync all changes to the server
-        self.sync_sync(req)
+        if self._file.group_id:  # only files with a group id can be synced
+            self.sync_sync(req)
 
     def run_rules(self):
         ruleset = get_ruleset(self.session)
diff --git a/actual/api/__init__.py b/actual/api/__init__.py
index b98a8cb..6fb9034 100644
--- a/actual/api/__init__.py
+++ b/actual/api/__init__.py
@@ -178,6 +178,15 @@ class ActualServer:
         response.raise_for_status()
         return StatusDTO.model_validate(response.json())
 
+    def delete_user_file(self, file_id: str):
+        """Deletes the user file that is loaded from the remote server."""
+        response = requests.post(
+            f"{self.api_url}/{Endpoints.DELETE_USER_FILE}",
+            json={"fileId": file_id, "token": self._token},
+            headers=self.headers(),
+        )
+        return StatusDTO.model_validate(response.json())
+
     def user_get_key(self, file_id: str) -> UserGetKeyDTO:
         """Gets the key information associated with a user file, including the algorithm, key, salt and iv."""
         response = requests.post(
diff --git a/actual/api/models.py b/actual/api/models.py
index 1871e09..cc856d4 100644
--- a/actual/api/models.py
+++ b/actual/api/models.py
@@ -21,6 +21,7 @@ class Endpoints(enum.Enum):
     DOWNLOAD_USER_FILE = "sync/download-user-file"
     UPLOAD_USER_FILE = "sync/upload-user-file"
     RESET_USER_FILE = "sync/reset-user-file"
+    DELETE_USER_FILE = "sync/delete-user-file"
     # encryption related
     USER_GET_KEY = "sync/user-get-key"
     USER_CREATE_KEY = "sync/user-create-key"


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 6fdff21..77fc452 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,7 +1,7 @@
 import pytest
 
 from actual import Actual
-from actual.exceptions import ActualError
+from actual.exceptions import ActualError, UnknownFileId
 from actual.protobuf_models import Message
 
 
@@ -16,3 +16,12 @@ def test_api_apply(mocker):
     m.dataset = "accounts"
     with pytest.raises(ActualError, match="column 'bar' at table 'accounts' not found"):
         actual.apply_changes([m])
+
+
+def test_rename_delete_budget_without_file():
+    actual = Actual.__new__(Actual)
+    actual._file = None
+    with pytest.raises(UnknownFileId, match="No current file loaded"):
+        actual.delete_budget()
+    with pytest.raises(UnknownFileId, match="No current file loaded"):
+        actual.rename_budget("foo")
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 7286bac..c10bb5d 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -21,9 +21,10 @@ from actual.queries import (
 )
 
 
-@pytest.fixture
-def actual_server():
-    with DockerContainer("actualbudget/actual-server:24.5.0").with_exposed_ports(5006) as container:
+@pytest.fixture(params=["24.3.0", "24.4.0", "24.5.0", "24.6.0", "24.7.0"])
+def actual_server(request):
+    # we test integration with the 5 latest versions of actual server
+    with DockerContainer(f"actualbudget/actual-server:{request.param}").with_exposed_ports(5006) as container:
         wait_for_logs(container, "Listening on :::5006...")
         yield container
 
@@ -98,3 +99,26 @@ def test_update_file_name(actual_server):
     with Actual(f"http://localhost:{port}", password="mypass") as actual:
         with pytest.raises(ActualError):
             actual.rename_budget("Failing name")
+
+
+def test_reimport_file_from_zip(actual_server, tmp_path):
+    port = actual_server.get_exposed_port(5006)
+    backup_file = f"{tmp_path}/backup.zip"
+    # create one file
+    with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True) as actual:
+        # add some entries to the budget
+        actual.create_budget("My Budget")
+        get_or_create_account(actual.session, "Bank")
+        actual.commit()
+        actual.upload_budget()
+    # re-download file and save as a backup
+    with Actual(f"http://localhost:{port}", password="mypass", file="My Budget") as actual:
+        actual.export_data(backup_file)
+        actual.delete_budget()
+    # re-upload the file
+    with Actual(f"http://localhost:{port}", password="mypass") as actual:
+        actual.import_zip(backup_file)
+        actual.upload_budget()
+    # check if the account can be retrieved
+    with Actual(f"http://localhost:{port}", password="mypass", file="My Budget") as actual:
+        assert len(get_accounts(actual.session)) == 1