Skip to content
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
12 changes: 7 additions & 5 deletions actual/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import pathlib
import sqlite3
import ssl
import uuid
import warnings
import zipfile
Expand Down Expand Up @@ -64,7 +65,7 @@ def __init__(
file: str | None = None,
encryption_password: str | None = None,
data_dir: str | pathlib.Path | None = None,
cert: str | bool | None = None,
cert: bool | ssl.SSLContext | str = True,
bootstrap: bool = False,
sa_kwargs: dict | None = None,
extra_headers: dict[str, str] | None = None,
Expand All @@ -85,7 +86,7 @@ def __init__(
# Password for authentication
password="<your_password>",
# Set the file to work with.
# Can be either the file id or file name, if the name is unique
# Can be either the file id or filename (if the name is unique)
file="<file_id_or_name>",
# Optional: Password for the file encryption.
# Will not use it if set to None.
Expand All @@ -94,7 +95,7 @@ def __init__(
# 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 be set to False to disable SSL verification
# Can be set to `False` to disable SSL verification
cert="<path_to_cert_file>"
) as actual:
transactions = get_transactions(actual.session)
Expand All @@ -110,8 +111,8 @@ def __init__(
will be created instead. If database files are already present on the path, the library will
try to reuse them by re-computing the sync request. **Providing a path should speed up
the download process considerably on the next call**.
:param cert: If a custom certificate should be used (i.e., self-signed certificate), its path can be provided
as a string. Set to `False` for no certificate check.
:param cert: If a custom certificate should be used (e.g., self-signed certificate), its path can be provided
as a string or as custom [ssl.SSLContext][ssl.SSLContext]. 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
Expand Down Expand Up @@ -147,6 +148,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
self._session.close()
if self.engine:
self.engine.dispose()
self._requests_session.close()
self._in_context = False

@property
Expand Down
109 changes: 53 additions & 56 deletions actual/api/__init__.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions actual/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import requests
import httpx


def get_exception_from_response(response: requests.Response) -> Exception:
def get_exception_from_response(response: httpx.Response) -> Exception:
text = response.content.decode()
if text == "internal-error" or response.status_code == 500:
return ActualError(text)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ authors = [
]
requires-python = ">=3.10.0"
dependencies = [
"requests>=2",
"httpx>=0.27",
"sqlmodel>=0.0.18",
"pydantic>=2,<3",
"sqlalchemy>=2",
Expand Down
61 changes: 51 additions & 10 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import ssl
import zipfile
from unittest.mock import patch

import pytest
from requests import Session
from httpx import Client

from actual import Actual, reflect_model
from actual.api import ListUserFilesDTO
Expand Down Expand Up @@ -41,27 +42,33 @@ def test_rename_delete_budget_without_file(login_mocks):
actual.rename_budget("foo")


@patch.object(Session, "post", return_value=RequestsMock({"status": "error", "reason": "proxy-not-trusted"}))
@patch.object(Client, "post", return_value=RequestsMock({"status": "error", "reason": "proxy-not-trusted"}))
def test_api_login_unknown_error(_post, login_mocks):
actual = Actual(token="foo")
actual.api_url = "localhost"
actual.cert = False
with pytest.raises(AuthorizationError, match="Something went wrong on login"):
actual.login("foo")


@patch.object(Session, "post", return_value=RequestsMock({}, status_code=403))
@patch.object(Client, "post", return_value=RequestsMock({}, status_code=403))
def test_api_login_http_error(_post, login_mocks):
actual = Actual(token="foo")
actual.api_url = "localhost"
actual.cert = False
with pytest.raises(AuthorizationError, match="HTTP error '403'"):
actual.login("foo")


def test_no_certificate(login_mocks):
actual = Actual(token="foo", cert=False)
assert actual._requests_session.verify is False
def test_no_certificate(login_mocks, mocker):
mock_client = mocker.patch("actual.api.httpx.Client")
Actual(token="foo", cert=False)
mock_client.assert_called_once()
assert mock_client.call_args.kwargs["verify"] is False


def test_certificate_string(login_mocks, mocker):
mock_client = mocker.patch("actual.api.httpx.Client")
mocker.patch("actual.api.ssl.SSLContext.load_verify_locations")
Actual(token="foo", cert="my-cert-string")
mock_client.assert_called_once()
assert isinstance(mock_client.call_args.kwargs["verify"], ssl.SSLContext)


def test_set_file_exceptions(login_mocks, mocker):
Expand Down Expand Up @@ -97,3 +104,37 @@ def test_api_extra_headers(login_mocks):
actual = Actual(token="foo", extra_headers={"foo": "bar"})
assert actual._requests_session.headers["foo"] == "bar"
assert actual._requests_session.headers["X-ACTUAL-TOKEN"] == "foo"


@patch.object(
Client,
"post",
return_value=RequestsMock(
{
"status": "ok",
"data": {
"openId": {
"doc": "OpenID authentication settings.",
"discoveryURL": "",
"issuer": {
"doc": "OpenID issuer",
"name": "Friendly name for the issuer",
"authorization_endpoint": "https://example.com/login/oauth/authorize",
"token_endpoint": "https://example.com/login/oauth/access_token",
"userinfo_endpoint": "https://api.example.com/user",
},
"client_id": "my-client-id",
"client_secret": "my-client-secret",
"server_hostname": "http://localhost:5006",
"authMethod": "oauth2",
}
},
}
),
)
def test_open_id_config(_post, login_mocks):
actual = Actual(token="foo")
config = actual.open_id_config("mypass")
assert config.status == StatusCode.OK
assert config.data["openId"].client_id == "my-client-id"
assert config.data["openId"].auth_method == "oauth2"
14 changes: 7 additions & 7 deletions tests/test_bank_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import decimal

import pytest
from requests import Session
from httpx import Client

from actual import Actual, ActualBankSyncError
from actual.api.bank_sync import TransactionItem
Expand Down Expand Up @@ -91,8 +91,8 @@ def generate_bank_sync_data(mocker, starting_balance: int | None = None):
response_full["startingBalance"] = starting_balance
response_empty = copy.deepcopy(response)
response_empty["transactions"]["all"] = []
mocker.patch.object(Session, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
main_mock = mocker.patch.object(Session, "post")
mocker.patch.object(Client, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
main_mock = mocker.patch.object(Client, "post")
main_mock.side_effect = [
RequestsMock({"status": "ok", "data": {"configured": True}}),
RequestsMock({"status": "ok", "data": response_full}),
Expand Down Expand Up @@ -187,8 +187,8 @@ def test_bank_sync_with_starting_balance(session, bank_sync_data_no_match):


def test_bank_sync_unconfigured(mocker, session):
mocker.patch.object(Session, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
main_mock = mocker.patch.object(Session, "post")
mocker.patch.object(Client, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
main_mock = mocker.patch.object(Client, "post")
main_mock.return_value = RequestsMock({"status": "ok", "data": {"configured": False}})

with Actual(token="foo") as actual:
Expand All @@ -198,8 +198,8 @@ def test_bank_sync_unconfigured(mocker, session):


def test_bank_sync_exception(session, mocker):
mocker.patch.object(Session, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
main_mock = mocker.patch.object(Session, "post")
mocker.patch.object(Client, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
main_mock = mocker.patch.object(Client, "post")
main_mock.side_effect = [
RequestsMock({"status": "ok", "data": {"configured": True}}),
RequestsMock({"status": "ok", "data": fail_response}),
Expand Down
6 changes: 3 additions & 3 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,11 @@ def test_reset_password(actual_server):
actual.reset_password("mynewpass")
response = actual.list_user_files()
assert len(response.data) == 1
with Actual(f"http://localhost:{port}", password="mynewpass"):
assert len(actual.list_user_files().data) == 1
with Actual(f"http://localhost:{port}", password="mynewpass") as actual2:
assert len(actual2.list_user_files().data) == 1
with pytest.raises(AuthorizationError):
# login with old password should fail
actual.login("mypass")
actual2.login("mypass")


def test_models(actual_server):
Expand Down
9 changes: 5 additions & 4 deletions tests/test_openid.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import threading
import time

import httpx
import pytest
from requests import Session, get
from httpx import Client
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_for_logs

Expand Down Expand Up @@ -44,7 +45,7 @@ def test_openid_endpoints(actual_server, mocker):
assert len(permissions) == 1
assert all(user.owner is False for user in permissions)
# Delete user does not work due to some internal exception (when not set), so we mock the response for now
mocker.patch.object(Session, "delete").return_value = RequestsMock(
mocker.patch.object(Client, "request").return_value = RequestsMock(
{"status": "ok", "data": {"someDeletionsFailed": False}}
)
actual.delete_open_id_user(user.id)
Expand All @@ -55,7 +56,7 @@ def _threading_call(url: str):
# This thread will do the interaction of the user logging in via browser
# We just wait a second then call the endpoint passing the token from the open id callback to the API
time.sleep(1)
get(url, params={"token": "mytoken"})
httpx.get(url, params={"token": "mytoken"})

def _login_fn(_url: str, json: dict):
assert "returnUrl" in json
Expand All @@ -66,7 +67,7 @@ def _login_fn(_url: str, json: dict):
mocker.patch.object(Actual, "validate")
mocker.patch.object(Actual, "is_open_id_owner_created", return_value=True)
mocker.patch.object(Actual, "needs_bootstrap", return_value=True)
mocker.patch.object(Session, "post").side_effect = _login_fn
mocker.patch.object(Client, "post").side_effect = _login_fn

# If the handshake is successful, the token would be set
with Actual("http://localhost:123") as actual:
Expand Down
Loading