Skip to content

Commit 59ddcae

Browse files
committed
refactor: First migration attempt from requests to httpx.
1 parent cb6eb96 commit 59ddcae

8 files changed

Lines changed: 36 additions & 31 deletions

File tree

actual/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
147147
self._session.close()
148148
if self.engine:
149149
self.engine.dispose()
150+
self._requests_session.close()
150151
self._in_context = False
151152

152153
@property

actual/api/__init__.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
from typing import Literal
66

7-
import requests
7+
import httpx
88

99
from actual.api.models import (
1010
BankSyncAccountResponseDTO,
@@ -65,11 +65,12 @@ def __init__(
6565
"""
6666
self.api_url: str = base_url
6767
self._token: str | None = token
68-
self._requests_session: requests.Session = requests.Session()
69-
if extra_headers:
70-
self._requests_session.headers = extra_headers
71-
if cert is not None:
72-
self._requests_session.verify = cert
68+
_verify = cert if cert is not None else True
69+
self._requests_session: httpx.Client = httpx.Client(
70+
headers=extra_headers,
71+
verify=_verify,
72+
)
73+
self._requests_session.verify = _verify
7374
if token is None and password is None and not self.is_open_id_owner_created():
7475
raise ValueError("Either provide a valid token or a password.")
7576
# already try to log-in if password was provided
@@ -231,7 +232,7 @@ def upload_user_file(
231232
base_headers["X-ACTUAL-ENCRYPT-META"] = json.dumps(encryption_meta)
232233
request = self._requests_session.post(
233234
f"{self.api_url}/{Endpoints.UPLOAD_USER_FILE}",
234-
data=binary_data,
235+
content=binary_data,
235236
headers=self.headers(extra_headers=base_headers),
236237
)
237238
request.raise_for_status()
@@ -315,7 +316,7 @@ def sync_sync(self, request: SyncRequest) -> SyncResponse:
315316
response = self._requests_session.post(
316317
f"{self.api_url}/{Endpoints.SYNC}",
317318
headers=self.headers(request.fileId, extra_headers={"Content-Type": "application/actual-sync"}),
318-
data=SyncRequest.serialize(request),
319+
content=SyncRequest.serialize(request),
319320
)
320321
response.raise_for_status()
321322
parsed_response = SyncResponse.deserialize(response.content)
@@ -339,7 +340,9 @@ def is_open_id_owner_created(self) -> bool:
339340
def open_id_config(self, password: str) -> OpenIDConfigResponseDTO:
340341
"""Gets the OpenID configuration for the server. You will need to provide the main password to access this
341342
config."""
342-
response = self._requests_session.post(f"{self.api_url}/{Endpoints.OPEN_ID_CONFIG}", {"password": password})
343+
response = self._requests_session.post(
344+
f"{self.api_url}/{Endpoints.OPEN_ID_CONFIG}", json={"password": password}
345+
)
343346
response.raise_for_status()
344347
return OpenIDConfigResponseDTO.model_validate(response.json())
345348

@@ -366,7 +369,7 @@ def create_open_id_user(
366369
"owner": owner,
367370
"role": role,
368371
}
369-
response = self._requests_session.post(f"{self.api_url}/{Endpoints.OPEN_ID_USERS}", payload)
372+
response = self._requests_session.post(f"{self.api_url}/{Endpoints.OPEN_ID_USERS}", json=payload)
370373
response.raise_for_status()
371374
model_response = OpenIDUserDTO.model_validate(payload)
372375
# fill entity since the endpoint does not return a DTO
@@ -401,7 +404,7 @@ def update_open_id_user(
401404
elif user.role is None:
402405
user.role = "BASIC" # seems like a bug from actual
403406
response = self._requests_session.patch(
404-
f"{self.api_url}/{Endpoints.OPEN_ID_USERS}", user.model_dump(by_alias=True)
407+
f"{self.api_url}/{Endpoints.OPEN_ID_USERS}", json=user.model_dump(by_alias=True)
405408
)
406409
response.raise_for_status()
407410
return user

actual/exceptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import requests
1+
import httpx
22

33

4-
def get_exception_from_response(response: requests.Response) -> Exception:
4+
def get_exception_from_response(response: httpx.Response) -> Exception:
55
text = response.content.decode()
66
if text == "internal-error" or response.status_code == 500:
77
return ActualError(text)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ authors = [
1111
]
1212
requires-python = ">=3.10.0"
1313
dependencies = [
14-
"requests>=2",
14+
"httpx>=0.27",
1515
"sqlmodel>=0.0.18",
1616
"pydantic>=2,<3",
1717
"sqlalchemy>=2",

tests/test_api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from unittest.mock import patch
33

44
import pytest
5-
from requests import Session
5+
from httpx import Client
66

77
from actual import Actual, reflect_model
88
from actual.api import ListUserFilesDTO
@@ -41,7 +41,7 @@ def test_rename_delete_budget_without_file(login_mocks):
4141
actual.rename_budget("foo")
4242

4343

44-
@patch.object(Session, "post", return_value=RequestsMock({"status": "error", "reason": "proxy-not-trusted"}))
44+
@patch.object(Client, "post", return_value=RequestsMock({"status": "error", "reason": "proxy-not-trusted"}))
4545
def test_api_login_unknown_error(_post, login_mocks):
4646
actual = Actual(token="foo")
4747
actual.api_url = "localhost"
@@ -50,7 +50,7 @@ def test_api_login_unknown_error(_post, login_mocks):
5050
actual.login("foo")
5151

5252

53-
@patch.object(Session, "post", return_value=RequestsMock({}, status_code=403))
53+
@patch.object(Client, "post", return_value=RequestsMock({}, status_code=403))
5454
def test_api_login_http_error(_post, login_mocks):
5555
actual = Actual(token="foo")
5656
actual.api_url = "localhost"

tests/test_bank_sync.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import decimal
44

55
import pytest
6-
from requests import Session
6+
from httpx import Client
77

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

188188

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

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

199199

200200
def test_bank_sync_exception(session, mocker):
201-
mocker.patch.object(Session, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
202-
main_mock = mocker.patch.object(Session, "post")
201+
mocker.patch.object(Client, "get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
202+
main_mock = mocker.patch.object(Client, "post")
203203
main_mock.side_effect = [
204204
RequestsMock({"status": "ok", "data": {"configured": True}}),
205205
RequestsMock({"status": "ok", "data": fail_response}),

tests/test_integration.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,11 @@ def test_reset_password(actual_server):
163163
actual.reset_password("mynewpass")
164164
response = actual.list_user_files()
165165
assert len(response.data) == 1
166-
with Actual(f"http://localhost:{port}", password="mynewpass"):
167-
assert len(actual.list_user_files().data) == 1
166+
with Actual(f"http://localhost:{port}", password="mynewpass") as actual2:
167+
assert len(actual2.list_user_files().data) == 1
168168
with pytest.raises(AuthorizationError):
169169
# login with old password should fail
170-
actual.login("mypass")
170+
actual2.login("mypass")
171171

172172

173173
def test_models(actual_server):

tests/test_openid.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import threading
22
import time
33

4+
import httpx
45
import pytest
5-
from requests import Session, get
6+
from httpx import Client
67
from testcontainers.core.container import DockerContainer
78
from testcontainers.core.waiting_utils import wait_for_logs
89

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

6061
def _login_fn(_url: str, json: dict):
6162
assert "returnUrl" in json
@@ -66,7 +67,7 @@ def _login_fn(_url: str, json: dict):
6667
mocker.patch.object(Actual, "validate")
6768
mocker.patch.object(Actual, "is_open_id_owner_created", return_value=True)
6869
mocker.patch.object(Actual, "needs_bootstrap", return_value=True)
69-
mocker.patch.object(Session, "post").side_effect = _login_fn
70+
mocker.patch.object(Client, "post").side_effect = _login_fn
7071

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

0 commit comments

Comments
 (0)