From ec1938e60216e183e4197ea83152a09d3b8d007a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:35:49 +0100 Subject: [PATCH 01/64] added http client --- ntropy_sdk/http.py | 161 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 ntropy_sdk/http.py diff --git a/ntropy_sdk/http.py b/ntropy_sdk/http.py new file mode 100644 index 0000000..205f2b4 --- /dev/null +++ b/ntropy_sdk/http.py @@ -0,0 +1,161 @@ +import logging +import time +import uuid +from json import JSONDecodeError +from typing import Optional + +import requests +from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter + +from ntropy_sdk import VERSION +from ntropy_sdk.v2.errors import error_from_http_status_code, NtropyError + + +class HttpClient: + def __init__(self, session: requests.Session | None = None): + self._session = session + + def _get_session(self) -> requests.Session: + if self._session is None: + self._session = requests.Session() + self._session.mount("https://", TCPKeepAliveAdapter()) + return self._session + + @property + def session(self) -> requests.Session: + return self._get_session() + + @session.setter + def session(self, session: requests.Session): + self._session = session + + def retry_ratelimited_request( + self, + method: str, + url: str, + payload: Optional[object] = None, + payload_json_str: Optional[str] = None, + logger: logging.Logger | None = None, + log_level=logging.DEBUG, + request_id: Optional[str] = None, + api_key: Optional[str] = None, + session: Optional[requests.Session] = None, + retries: int = 1, + timeout: int = 10 * 60, + retry_on_unhandled_exception: bool = False, + extra_headers: Optional[dict] = None, + **request_kwargs, + ): + """Executes a request to an endpoint in the Ntropy API (given the `base_url` parameter). + Catches expected errors and wraps them in NtropyError. + Retries the request for Rate-Limiting errors or Unexpected Errors (50x) + + + Raises + ------ + NtropyError + If the request failed after the maximum number of retries. + """ + + if payload_json_str is not None and payload is not None: + raise ValueError( + "payload_json_str and payload cannot be used simultaneously" + ) + + if request_id is None: + request_id = uuid.uuid4().hex + cur_session = session + if cur_session is None: + cur_session = self._get_session() + + headers = { + "User-Agent": f"ntropy-sdk/{VERSION}", + "X-Request-ID": request_id, + } + if api_key is not None: + headers["X-API-Key"] = api_key + if payload_json_str is None: + request_kwargs["json"] = payload + else: + headers["Content-Type"] = "application/json" + request_kwargs["data"] = payload_json_str + if extra_headers: + headers.update(extra_headers) + + backoff = 1 + for _ in range(retries): + try: + resp = cur_session.request( + method, + url, + headers=headers, + timeout=timeout, + **request_kwargs, + ) + except requests.ConnectionError: + # Rebuild session on connection error and retry + if session is None: + self._session = None + cur_session = self._get_session() + continue + else: + raise + + if resp.status_code == 429: + try: + retry_after = int(resp.headers.get("retry-after", "1")) + except ValueError: + retry_after = 1 + if retry_after <= 0: + retry_after = 1 + + if logger: + logger.log( + log_level, + "Retrying in %s seconds due to ratelimit", + retry_after, + ) + time.sleep(retry_after) + + continue + elif resp.status_code == 503: + time.sleep(backoff) + backoff = min(backoff * 2, 8) + + if logger: + logger.log( + log_level, + "Retrying in %s seconds due to unavailability in the server side", + backoff, + ) + continue + + elif ( + resp.status_code >= 500 and resp.status_code <= 511 + ) and retry_on_unhandled_exception: + time.sleep(backoff) + backoff = min(backoff * 2, 8) + + if logger: + logger.log( + log_level, + "Retrying in %s seconds due to unhandled exception in the server side", + backoff, + ) + + continue + + try: + resp.raise_for_status() + except requests.HTTPError as e: + status_code = e.response.status_code + + try: + content = e.response.json() + except JSONDecodeError: + content = {} + + err = error_from_http_status_code(request_id, status_code, content) + raise err + return resp + raise NtropyError(f"Failed to {method} {url} after {retries} attempts") From bcfeb11fe6b1aa3c5ffb4f1422512a6126bbd5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:36:06 +0100 Subject: [PATCH 02/64] moved old to v2 --- ntropy_sdk/{v3 => }/paging.py | 2 +- ntropy_sdk/v2/__init__.py | 31 +++++ ntropy_sdk/v2/bank_statements.py | 50 ++++++++ ntropy_sdk/{ => v2}/errors.py | 0 ntropy_sdk/{ => v2}/income_check.py | 0 ntropy_sdk/{ => v2}/ntropy_sdk.py | 132 ++++------------------ ntropy_sdk/{ => v2}/recurring_payments.py | 0 7 files changed, 102 insertions(+), 113 deletions(-) rename ntropy_sdk/{v3 => }/paging.py (98%) create mode 100644 ntropy_sdk/v2/__init__.py create mode 100644 ntropy_sdk/v2/bank_statements.py rename ntropy_sdk/{ => v2}/errors.py (100%) rename ntropy_sdk/{ => v2}/income_check.py (100%) rename ntropy_sdk/{ => v2}/ntropy_sdk.py (95%) rename ntropy_sdk/{ => v2}/recurring_payments.py (100%) diff --git a/ntropy_sdk/v3/paging.py b/ntropy_sdk/paging.py similarity index 98% rename from ntropy_sdk/v3/paging.py rename to ntropy_sdk/paging.py index be80a60..9904890 100644 --- a/ntropy_sdk/v3/paging.py +++ b/ntropy_sdk/paging.py @@ -15,7 +15,7 @@ from ntropy_sdk.utils import PYDANTIC_V2 if TYPE_CHECKING: - from . import ExtraKwargs + from ntropy_sdk.v3 import ExtraKwargs from pydantic import BaseModel as GenericModel from typing_extensions import Unpack, Self diff --git a/ntropy_sdk/v2/__init__.py b/ntropy_sdk/v2/__init__.py new file mode 100644 index 0000000..6565797 --- /dev/null +++ b/ntropy_sdk/v2/__init__.py @@ -0,0 +1,31 @@ +from ntropy_sdk.v2.ntropy_sdk import ( + AccountHolder, + AccountHolderType, + Transaction, + SDK, + Batch, + EnrichedTransaction, + EnrichedTransactionList, + BankStatement, + BankStatementRequest, + Report, +) +from ntropy_sdk.v2.errors import ( + NtropyError, + NtropyBatchError, +) + +__all__ = ( + "AccountHolder", + "AccountHolderType", + "Transaction", + "SDK", + "Batch", + "NtropyError", + "NtropyBatchError", + "EnrichedTransaction", + "EnrichedTransactionList", + "BankStatement", + "BankStatementRequest", + "Report", +) diff --git a/ntropy_sdk/v2/bank_statements.py b/ntropy_sdk/v2/bank_statements.py new file mode 100644 index 0000000..4d843d9 --- /dev/null +++ b/ntropy_sdk/v2/bank_statements.py @@ -0,0 +1,50 @@ +from typing import List, Optional +from pydantic import BaseModel +from datetime import date + + +from ntropy_sdk.utils import AccountHolderType + + +class Address(BaseModel): + street: Optional[str] + postcode: Optional[str] + city: Optional[str] + state: Optional[str] + country: Optional[str] + + class Config: + extra = "allow" + + +class AccountHolder(BaseModel): + type: Optional[AccountHolderType] + name: Optional[str] + address: Optional[Address] + + class Config: + use_enum_values = True + extra = "allow" + + +class Account(BaseModel): + type: Optional[str] + number: Optional[str] + opening_balance: Optional[float] + closing_balance: Optional[float] + iso_currency_code: Optional[str] + + class Config: + extra = "allow" + + +class StatementInfo(BaseModel): + institution: Optional[str] + start_date: Optional[date] + end_date: Optional[date] + account_holder: Optional[AccountHolder] + accounts: Optional[List[Account]] + request_id: str + + class Config: + extra = "allow" diff --git a/ntropy_sdk/errors.py b/ntropy_sdk/v2/errors.py similarity index 100% rename from ntropy_sdk/errors.py rename to ntropy_sdk/v2/errors.py diff --git a/ntropy_sdk/income_check.py b/ntropy_sdk/v2/income_check.py similarity index 100% rename from ntropy_sdk/income_check.py rename to ntropy_sdk/v2/income_check.py diff --git a/ntropy_sdk/ntropy_sdk.py b/ntropy_sdk/v2/ntropy_sdk.py similarity index 95% rename from ntropy_sdk/ntropy_sdk.py rename to ntropy_sdk/v2/ntropy_sdk.py index 946d361..8d31d41 100644 --- a/ntropy_sdk/ntropy_sdk.py +++ b/ntropy_sdk/v2/ntropy_sdk.py @@ -38,7 +38,9 @@ from ntropy_sdk import __version__ from ntropy_sdk.bank_statements import StatementInfo from ntropy_sdk.income_check import IncomeReport, IncomeGroup -from ntropy_sdk.recurring_payments import ( + +from ntropy_sdk.http import HttpClient +from ntropy_sdk.v2.recurring_payments import ( RecurringPaymentsGroups, RecurringPaymentsGroup, ) @@ -1262,7 +1264,7 @@ def __init__( self.base_url = ALL_REGIONS[region] self.token = token - self._session: Optional[requests.Session] = None + self.http_client = HttpClient() self.logger = logging.getLogger("Ntropy-SDK") self.v3 = V3(self) @@ -1283,20 +1285,6 @@ def _validate_unique_ids(tx_ids: List[str]): UserWarning, ) - def _get_session(self) -> requests.Session: - if self._session is None: - self._session = requests.Session() - self._session.mount("https://", TCPKeepAliveAdapter()) - return self._session - - @property - def session(self) -> requests.Session: - return self._get_session() - - @session.setter - def session(self, session: requests.Session): - self._session = session - def retry_ratelimited_request( self, method: str, @@ -1329,102 +1317,22 @@ def retry_ratelimited_request( NtropyError If the request failed after the maximum number of retries. """ - - if payload_json_str is not None and payload is not None: - raise ValueError("payload_json_str and payload cannot be used simultaneously") - - if request_id is None: - request_id = uuid.uuid4().hex - if api_key is None: - api_key = self.token - cur_session = session - if cur_session is None: - cur_session = self._get_session() - - headers = { - "X-API-Key": api_key, - "User-Agent": f"ntropy-sdk/{__version__}", - "X-Request-ID": request_id, - } - if payload_json_str is None: - request_kwargs["json"] = payload - else: - headers["Content-Type"] = "application/json" - request_kwargs["data"] = payload_json_str - headers.update(self._extra_headers) - - backoff = 1 - for _ in range(self._retries): - try: - resp = cur_session.request( - method, - self.base_url + url, - headers=headers, - timeout=self._timeout, - **request_kwargs, - ) - except requests.ConnectionError: - # Rebuild session on connection error and retry - if session is None: - self._session = None - cur_session = self._get_session() - continue - else: - raise - - if resp.status_code == 429: - try: - retry_after = int(resp.headers.get("retry-after", "1")) - except ValueError: - retry_after = 1 - if retry_after <= 0: - retry_after = 1 - - self.logger.log( - log_level, "Retrying in %s seconds due to ratelimit", retry_after - ) - time.sleep(retry_after) - - continue - elif resp.status_code == 503: - time.sleep(backoff) - backoff = min(backoff * 2, 8) - - self.logger.log( - log_level, - "Retrying in %s seconds due to unavailability in the server side", - backoff, - ) - continue - - elif ( - resp.status_code >= 500 and resp.status_code <= 511 - ) and self._retry_on_unhandled_exception: - time.sleep(backoff) - backoff = min(backoff * 2, 8) - - self.logger.log( - log_level, - "Retrying in %s seconds due to unhandled exception in the server side", - backoff, - ) - - continue - - try: - resp.raise_for_status() - except requests.HTTPError as e: - status_code = e.response.status_code - - try: - content = e.response.json() - except JSONDecodeError: - content = {} - - err = error_from_http_status_code(request_id, status_code, content) - raise err - return resp - raise NtropyError(f"Failed to {method} {url} after {self._retries} attempts") + return self.http_client.retry_ratelimited_request( + method=method, + url=self.base_url + url, + payload=payload, + payload_json_str=payload_json_str, + log_level=log_level, + request_id=request_id, + api_key=api_key or self.token, + session=session, + request_kwargs=request_kwargs, + logger=self.logger, + retries=self._retries, + timeout=self._timeout, + retry_on_unhandled_exception=self._retry_on_unhandled_exception, + extra_headers=self._extra_headers, + ) def create_account_holder(self, account_holder: AccountHolder): """Creates an AccountHolder for the current user. diff --git a/ntropy_sdk/recurring_payments.py b/ntropy_sdk/v2/recurring_payments.py similarity index 100% rename from ntropy_sdk/recurring_payments.py rename to ntropy_sdk/v2/recurring_payments.py From 5cea972fbfbf9f2da08207753373055187dd68c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:36:28 +0100 Subject: [PATCH 03/64] moved v3 to top-level --- ntropy_sdk/__init__.py | 32 +--- ntropy_sdk/{v3 => }/account_holders.py | 8 +- ntropy_sdk/bank_statements.py | 227 +++++++++++++++++++++---- ntropy_sdk/{v3 => }/batches.py | 8 +- ntropy_sdk/{v3 => }/transactions.py | 8 +- ntropy_sdk/utils.py | 2 + ntropy_sdk/v3/__init__.py | 10 +- ntropy_sdk/v3/bank_statements.py | 215 ----------------------- tests/test_datasources.py | 2 +- tests/test_sdk.py | 3 +- 10 files changed, 221 insertions(+), 294 deletions(-) rename ntropy_sdk/{v3 => }/account_holders.py (95%) rename ntropy_sdk/{v3 => }/batches.py (96%) rename ntropy_sdk/{v3 => }/transactions.py (98%) delete mode 100644 ntropy_sdk/v3/bank_statements.py diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index 88739af..b558e4d 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -1,31 +1,3 @@ -__version__ = "4.26.0" +from .version import VERSION -from ntropy_sdk.ntropy_sdk import ( - AccountHolder, - AccountHolderType, - Transaction, - SDK, - Batch, - NtropyError, - NtropyBatchError, - EnrichedTransaction, - EnrichedTransactionList, - BankStatement, - BankStatementRequest, - Report, -) - -__all__ = ( - "AccountHolder", - "AccountHolderType", - "Transaction", - "SDK", - "Batch", - "NtropyError", - "NtropyBatchError", - "EnrichedTransaction", - "EnrichedTransactionList", - "BankStatement", - "BankStatementRequest", - "Report", -) +__version__ = VERSION diff --git a/ntropy_sdk/v3/account_holders.py b/ntropy_sdk/account_holders.py similarity index 95% rename from ntropy_sdk/v3/account_holders.py rename to ntropy_sdk/account_holders.py index eafceca..e6c59a3 100644 --- a/ntropy_sdk/v3/account_holders.py +++ b/ntropy_sdk/account_holders.py @@ -6,11 +6,11 @@ from pydantic import BaseModel, Field from ntropy_sdk.utils import pydantic_json -from ntropy_sdk.v3.paging import PagedResponse +from ntropy_sdk.paging import PagedResponse if TYPE_CHECKING: - from ntropy_sdk.ntropy_sdk import SDK - from . import ExtraKwargs + from ntropy_sdk.v2.ntropy_sdk import SDK + from ntropy_sdk.v3 import ExtraKwargs from typing_extensions import Unpack @@ -107,3 +107,5 @@ def create( **extra_kwargs, ) return AccountHolder(**resp.json(), request_id=request_id) + + # TODO: Recurring groups diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 4d843d9..194a1a4 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -1,50 +1,217 @@ -from typing import List, Optional -from pydantic import BaseModel -from datetime import date +from datetime import date, datetime +from enum import Enum +from io import IOBase +import time +from typing import List, Optional, TYPE_CHECKING, Union +import uuid +from pydantic import BaseModel, Field, NonNegativeFloat -from ntropy_sdk.utils import AccountHolderType +from ntropy_sdk.bank_statements import StatementInfo +from ntropy_sdk.errors import NtropyDatasourceError +from ntropy_sdk.utils import EntryType +from ntropy_sdk.paging import PagedResponse +if TYPE_CHECKING: + from ntropy_sdk.v2.ntropy_sdk import SDK + from ntropy_sdk.v3 import ExtraKwargs + from typing_extensions import Unpack -class Address(BaseModel): - street: Optional[str] - postcode: Optional[str] - city: Optional[str] - state: Optional[str] - country: Optional[str] - class Config: - extra = "allow" +class BankStatementJobStatus(str, Enum): + PROCESSING = "processing" + COMPLETED = "completed" + ERROR = "error" + +class BankStatementFile(BaseModel): + no_pages: int + size: Optional[int] -class AccountHolder(BaseModel): - type: Optional[AccountHolderType] + +class BankStatementJob(BaseModel): + id: str name: Optional[str] - address: Optional[Address] + status: BankStatementJobStatus + created_at: datetime + file: BankStatementFile + request_id: Optional[str] = None + + def is_completed(self): + ... + + def is_failed(self): + ... + + def wait_for_results( + self, + sdk: "SDK", + *, + timeout: int = 4 * 60 * 60, + poll_interval: int = 10, + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> "BankStatementResults": + """Continuously polls the status of this job, blocking until the job either succeeds + or fails. If the job is successful, returns the results. Otherwise, raises an + `NtropyDatasourceError` exception.""" + + finish_statuses = [ + BankStatementJobStatus.COMPLETED, + BankStatementJobStatus.ERROR, + ] + start_time = time.monotonic() + while time.monotonic() - start_time < timeout: + self.status = sdk.v3.bank_statements.get(id=self.id).status + if self.status in finish_statuses: + break + time.sleep(poll_interval) + + if self.status is BankStatementJobStatus.COMPLETED: + return sdk.v3.bank_statements.results(id=self.id, **extra_kwargs) + else: + raise NtropyDatasourceError() class Config: - use_enum_values = True extra = "allow" -class Account(BaseModel): - type: Optional[str] +class BankStatementTransaction(BaseModel): + date: date + entry_type: EntryType + amount: NonNegativeFloat + running_balance: Optional[float] + iso_currency_code: str = Field( + description="The currency of the transaction in ISO 4217 format" + ) + description: str + transaction_id: str = Field( + description="A generated unique identifier for the transaction", min_length=1 + ) + + +class BankStatementAccount(BaseModel): number: Optional[str] opening_balance: Optional[float] closing_balance: Optional[float] - iso_currency_code: Optional[str] + start_date: Optional[date] + end_date: Optional[date] + is_balance_reconciled: Optional[bool] + total_incoming: Optional[float] + total_outgoing: Optional[float] + transactions: List[BankStatementTransaction] - class Config: - extra = "allow" +class BankStatementResults(BankStatementJob): + accounts: List[BankStatementAccount] -class StatementInfo(BaseModel): - institution: Optional[str] - start_date: Optional[date] - end_date: Optional[date] - account_holder: Optional[AccountHolder] - accounts: Optional[List[Account]] - request_id: str - class Config: - extra = "allow" +class BankStatementsResource: + def __init__(self, sdk: "SDK"): + self._sdk = sdk + + def list( + self, + *, + created_before: Optional[datetime] = None, + created_after: Optional[datetime] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, + status: Optional[BankStatementJobStatus] = None, + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> PagedResponse[BankStatementJob]: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + "GET", + "/v3/bank_statements", + params={ + "created_before": created_before, + "created_after": created_after, + "cursor": cursor, + "limit": limit, + "status": status.value if status else None, + }, + payload=None, + **extra_kwargs, + ) + page = PagedResponse[BankStatementJob]( + **resp.json(), + request_id=request_id, + _resource=self, + _extra_kwargs=extra_kwargs, + ) + for b in page.data: + b.request_id = request_id + return page + + def create( + self, + *, + file: Union[IOBase, bytes], + filename: Optional[str] = None, + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> BankStatementJob: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + "POST", + "/v3/bank_statements", + payload=None, + files={ + "file": file if filename is None else (filename, file), + }, + **extra_kwargs, + ) + return BankStatementJob(**resp.json(), request_id=request_id) + + def get( + self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" + ) -> BankStatementJob: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + "GET", + f"/v3/bank_statements/{id}", + payload=None, + **extra_kwargs, + ) + return BankStatementJob(**resp.json(), request_id=request_id) + + def results( + self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" + ) -> BankStatementResults: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + "GET", + f"/v3/bank_statements/{id}/results", + payload=None, + **extra_kwargs, + ) + return BankStatementResults(**resp.json(), request_id=request_id) + + def overview( + self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" + ) -> StatementInfo: + """Waits for and returns preliminary statement information from the + first page of the PDF. This may not always be consistent with the + final results.""" + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + "GET", + f"/v3/bank_statements/{id}/overview", + payload=None, + **extra_kwargs, + ) + return StatementInfo(**resp.json(), request_id=request_id) diff --git a/ntropy_sdk/v3/batches.py b/ntropy_sdk/batches.py similarity index 96% rename from ntropy_sdk/v3/batches.py rename to ntropy_sdk/batches.py index 618395e..81d3135 100644 --- a/ntropy_sdk/v3/batches.py +++ b/ntropy_sdk/batches.py @@ -8,12 +8,12 @@ from ntropy_sdk.errors import NtropyBatchError from ntropy_sdk.utils import pydantic_json -from ntropy_sdk.v3.paging import PagedResponse -from ntropy_sdk.v3.transactions import EnrichedTransaction, EnrichmentInput +from ntropy_sdk.paging import PagedResponse +from ntropy_sdk.transactions import EnrichedTransaction, EnrichmentInput if TYPE_CHECKING: - from ntropy_sdk.ntropy_sdk import SDK - from . import ExtraKwargs + from ntropy_sdk.v2.ntropy_sdk import SDK + from ntropy_sdk.v3 import ExtraKwargs from typing_extensions import Unpack diff --git a/ntropy_sdk/v3/transactions.py b/ntropy_sdk/transactions.py similarity index 98% rename from ntropy_sdk/v3/transactions.py rename to ntropy_sdk/transactions.py index 109e342..ba0d135 100644 --- a/ntropy_sdk/v3/transactions.py +++ b/ntropy_sdk/transactions.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, NonNegativeFloat from ntropy_sdk.utils import EntryType, PYDANTIC_V2, pydantic_json -from ntropy_sdk.v3.paging import PagedResponse +from ntropy_sdk.paging import PagedResponse PYDANTIC_PATTERN = "pattern" if PYDANTIC_V2 else "regex" MAX_SYNC_BATCH = 1000 @@ -14,7 +14,7 @@ if TYPE_CHECKING: - from ntropy_sdk.ntropy_sdk import SDK + from ntropy_sdk.v2.ntropy_sdk import SDK from ntropy_sdk.v3 import ExtraKwargs from typing_extensions import Unpack @@ -62,7 +62,7 @@ class _TransactionBase(BaseModel): ) -class TransactionInput(_TransactionBase): +class InputTransaction(_TransactionBase): account_holder_id: Optional[str] = Field( None, description="The id of the account holder. Unsetting it will disable categorization.", @@ -280,7 +280,7 @@ class EnrichedTransaction(_EnrichedTransactionBase): class EnrichmentInput(BaseModel): - transactions: List[TransactionInput] + transactions: List[InputTransaction] class EnrichmentResult(BaseModel): diff --git a/ntropy_sdk/utils.py b/ntropy_sdk/utils.py index 9bb50bf..6b49704 100644 --- a/ntropy_sdk/utils.py +++ b/ntropy_sdk/utils.py @@ -7,6 +7,7 @@ PYDANTIC_V2 = pydantic.VERSION.startswith("2.") if PYDANTIC_V2: + class PydanticList(pydantic.RootModel[Any]): # type: ignore pass @@ -15,6 +16,7 @@ def pydantic_list_json(x: List[Any]) -> str: # type: ignore def pydantic_json(m: pydantic.BaseModel) -> str: return m.model_dump_json() + else: import pydantic.generics diff --git a/ntropy_sdk/v3/__init__.py b/ntropy_sdk/v3/__init__.py index e7c16a0..cc7812a 100644 --- a/ntropy_sdk/v3/__init__.py +++ b/ntropy_sdk/v3/__init__.py @@ -3,7 +3,7 @@ if TYPE_CHECKING: - from ntropy_sdk.ntropy_sdk import SDK + from ntropy_sdk.v2.ntropy_sdk import SDK from typing_extensions import TypedDict class ExtraKwargs(TypedDict, total=False): @@ -12,10 +12,10 @@ class ExtraKwargs(TypedDict, total=False): session: Optional[requests.Session] -from .transactions import TransactionsResource -from .batches import BatchesResource -from .bank_statements import BankStatementsResource -from .account_holders import AccountHoldersResource +from ntropy_sdk.transactions import TransactionsResource +from ntropy_sdk.batches import BatchesResource +from ntropy_sdk.bank_statements import BankStatementsResource +from ntropy_sdk.account_holders import AccountHoldersResource class V3: diff --git a/ntropy_sdk/v3/bank_statements.py b/ntropy_sdk/v3/bank_statements.py deleted file mode 100644 index e6af422..0000000 --- a/ntropy_sdk/v3/bank_statements.py +++ /dev/null @@ -1,215 +0,0 @@ -from datetime import date, datetime -from enum import Enum -from io import IOBase -import time -from typing import List, Optional, TYPE_CHECKING, Union -import uuid - -from pydantic import BaseModel, Field, NonNegativeFloat - -from ntropy_sdk.bank_statements import StatementInfo -from ntropy_sdk.errors import NtropyDatasourceError -from ntropy_sdk.utils import EntryType -from ntropy_sdk.v3.paging import PagedResponse - -if TYPE_CHECKING: - from ntropy_sdk.ntropy_sdk import SDK - from . import ExtraKwargs - from typing_extensions import Unpack - - -class BankStatementJobStatus(str, Enum): - PROCESSING = "processing" - COMPLETED = "completed" - ERROR = "error" - - -class BankStatementFile(BaseModel): - no_pages: int - size: Optional[int] - - -class BankStatementJob(BaseModel): - id: str - name: Optional[str] - status: BankStatementJobStatus - created_at: datetime - file: BankStatementFile - request_id: Optional[str] = None - - def wait( - self, - sdk: "SDK", - *, - timeout: int = 4 * 60 * 60, - poll_interval: int = 10, - **extra_kwargs: "Unpack[ExtraKwargs]", - ) -> "BankStatementResults": - """Continuously polls the status of this job, blocking until the job either succeeds - or fails. If the job is successful, returns the results. Otherwise, raises an - `NtropyDatasourceError` exception.""" - - finish_statuses = [ - BankStatementJobStatus.COMPLETED, - BankStatementJobStatus.ERROR, - ] - start_time = time.monotonic() - while time.monotonic() - start_time < timeout: - self.status = sdk.v3.bank_statements.get(id=self.id).status - if self.status in finish_statuses: - break - time.sleep(poll_interval) - - if self.status is BankStatementJobStatus.COMPLETED: - return sdk.v3.bank_statements.results(id=self.id, **extra_kwargs) - else: - raise NtropyDatasourceError() - - def overview(self, sdk: "SDK") -> StatementInfo: - """Convenience function for `sdk.v3.bank_statements.overview`.""" - return sdk.v3.bank_statements.overview(id=self.id) - - class Config: - extra = "allow" - - -class BankStatementTransaction(BaseModel): - date: date - entry_type: EntryType - amount: NonNegativeFloat - running_balance: Optional[float] - iso_currency_code: str = Field( - description="The currency of the transaction in ISO 4217 format" - ) - description: str - transaction_id: str = Field( - description="A generated unique identifier for the transaction", min_length=1 - ) - - -class BankStatementAccount(BaseModel): - number: Optional[str] - opening_balance: Optional[float] - closing_balance: Optional[float] - start_date: Optional[date] - end_date: Optional[date] - is_balance_reconciled: Optional[bool] - total_incoming: Optional[float] - total_outgoing: Optional[float] - transactions: List[BankStatementTransaction] - - -class BankStatementResults(BankStatementJob): - accounts: List[BankStatementAccount] - - -class BankStatementsResource: - def __init__(self, sdk: "SDK"): - self._sdk = sdk - - def list( - self, - *, - created_before: Optional[datetime] = None, - created_after: Optional[datetime] = None, - cursor: Optional[str] = None, - limit: Optional[int] = None, - status: Optional[BankStatementJobStatus] = None, - **extra_kwargs: "Unpack[ExtraKwargs]", - ) -> PagedResponse[BankStatementJob]: - request_id = extra_kwargs.get("request_id") - if request_id is None: - request_id = uuid.uuid4().hex - extra_kwargs["request_id"] = request_id - resp = self._sdk.retry_ratelimited_request( - "GET", - "/v3/bank_statements", - params={ - "created_before": created_before, - "created_after": created_after, - "cursor": cursor, - "limit": limit, - "status": status.value if status else None, - }, - payload=None, - **extra_kwargs, - ) - page = PagedResponse[BankStatementJob]( - **resp.json(), - request_id=request_id, - _resource=self, - _extra_kwargs=extra_kwargs, - ) - for b in page.data: - b.request_id = request_id - return page - - def create( - self, - *, - file: Union[IOBase, bytes], - filename: Optional[str] = None, - **extra_kwargs: "Unpack[ExtraKwargs]", - ) -> BankStatementJob: - request_id = extra_kwargs.get("request_id") - if request_id is None: - request_id = uuid.uuid4().hex - extra_kwargs["request_id"] = request_id - resp = self._sdk.retry_ratelimited_request( - "POST", - "/v3/bank_statements", - payload=None, - files={ - "file": file if filename is None else (filename, file), - }, - **extra_kwargs, - ) - return BankStatementJob(**resp.json(), request_id=request_id) - - def get( - self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" - ) -> BankStatementJob: - request_id = extra_kwargs.get("request_id") - if request_id is None: - request_id = uuid.uuid4().hex - extra_kwargs["request_id"] = request_id - resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/bank_statements/{id}", - payload=None, - **extra_kwargs, - ) - return BankStatementJob(**resp.json(), request_id=request_id) - - def results( - self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" - ) -> BankStatementResults: - request_id = extra_kwargs.get("request_id") - if request_id is None: - request_id = uuid.uuid4().hex - extra_kwargs["request_id"] = request_id - resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/bank_statements/{id}/results", - payload=None, - **extra_kwargs, - ) - return BankStatementResults(**resp.json(), request_id=request_id) - - def overview( - self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" - ) -> StatementInfo: - """Waits for and returns preliminary statement information from the - first page of the PDF. This may not always be consistent with the - final results.""" - request_id = extra_kwargs.get("request_id") - if request_id is None: - request_id = uuid.uuid4().hex - extra_kwargs["request_id"] = request_id - resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/bank_statements/{id}/overview", - payload=None, - **extra_kwargs, - ) - return StatementInfo(**resp.json(), request_id=request_id) diff --git a/tests/test_datasources.py b/tests/test_datasources.py index 3ef23af..91a01fb 100644 --- a/tests/test_datasources.py +++ b/tests/test_datasources.py @@ -3,7 +3,7 @@ import pytest from ntropy_sdk.errors import NtropyDatasourceError -from ntropy_sdk.ntropy_sdk import BankStatementRequest +from ntropy_sdk.v2.ntropy_sdk import BankStatementRequest from ntropy_sdk.utils import AccountHolderType diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 90cdbb9..ac6dfb9 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -13,11 +13,10 @@ EnrichedTransaction, SDK, Transaction, - NtropyError, ) from ntropy_sdk.errors import NtropyValueError, NtropyBatchError from ntropy_sdk.utils import TransactionType -from ntropy_sdk.ntropy_sdk import ACCOUNT_HOLDER_TYPES +from ntropy_sdk.v2.ntropy_sdk import ACCOUNT_HOLDER_TYPES from tests import API_KEY From 409a3bcc481029760c337057020fc3a1935184ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:36:42 +0100 Subject: [PATCH 04/64] version --- ntropy_sdk/version.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 ntropy_sdk/version.py diff --git a/ntropy_sdk/version.py b/ntropy_sdk/version.py new file mode 100644 index 0000000..3486778 --- /dev/null +++ b/ntropy_sdk/version.py @@ -0,0 +1 @@ +VERSION = "4.26.0" From 257a366dff8d894ba333e25e2a5472b9e4a4fc48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:52:47 +0100 Subject: [PATCH 05/64] moved tests --- tests/test_sdk.py | 857 +----------------- tests/test_v3.py | 10 - tests/{ => v2}/bank_statement_sample.pdf | Bin tests/{ => v2}/conftest.py | 0 .../test_bank_statements.py} | 2 +- tests/{ => v2}/test_income_check.py | 2 +- tests/{ => v2}/test_recurring_payments.py | 2 +- tests/v2/test_sdk.py | 851 +++++++++++++++++ 8 files changed, 862 insertions(+), 862 deletions(-) delete mode 100644 tests/test_v3.py rename tests/{ => v2}/bank_statement_sample.pdf (100%) rename tests/{ => v2}/conftest.py (100%) rename tests/{test_datasources.py => v2/test_bank_statements.py} (96%) rename tests/{ => v2}/test_income_check.py (99%) rename tests/{ => v2}/test_recurring_payments.py (98%) create mode 100644 tests/v2/test_sdk.py diff --git a/tests/test_sdk.py b/tests/test_sdk.py index ac6dfb9..27947d4 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -1,851 +1,10 @@ -import os -import uuid -from decimal import Decimal -from unittest.mock import patch +from itertools import islice +from ntropy_sdk import SDK -import pandas as pd -import pytest -from requests import Response -from ntropy_sdk import ( - AccountHolder, - Batch, - EnrichedTransaction, - SDK, - Transaction, -) -from ntropy_sdk.errors import NtropyValueError, NtropyBatchError -from ntropy_sdk.utils import TransactionType -from ntropy_sdk.v2.ntropy_sdk import ACCOUNT_HOLDER_TYPES -from tests import API_KEY - - -def test_account_holder_type(): - def create_account_holder(account_holder_type): - return AccountHolder( - id=str(uuid.uuid4()), - type=account_holder_type, - industry="fintech", - website="ntropy.com", - ) - - for t in ACCOUNT_HOLDER_TYPES: - account_holder = create_account_holder(t) - assert account_holder.type == t - with pytest.raises(ValueError): - create_account_holder("not_valid") - - -def test_get_account_holder(sdk): - account_holder = AccountHolder( - id=str(uuid.uuid4()), - type="business", - industry="fintech", - website="ntropy.com", - ) - - sdk.create_account_holder(account_holder) - account_holder2 = sdk.get_account_holder(account_holder.id) - - assert isinstance(account_holder2, AccountHolder) - assert account_holder2.type == "business" - assert account_holder2.industry == "fintech" - assert account_holder2.website == "ntropy.com" - - -def test_account_holder_type_or_id(sdk): - id_tx = Transaction( - amount=24.56, - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date="2012-12-10", - account_holder_id="1", - iso_currency_code="USD", - mcc=5432, - ) - enriched = sdk.add_transactions([id_tx])[0] - assert "missing account holder information" not in enriched.labels - - type_tx = Transaction( - amount=24.56, - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date="2012-12-10", - account_holder_type="business", - iso_currency_code="USD", - mcc=5432, - ) - enriched = sdk.add_transactions([type_tx])[0] - assert "missing account holder information" not in enriched.labels - - -def test_account_holder_type_or_id_pandas(sdk): - account_holder = AccountHolder( - id=str(uuid.uuid4()), type="business", industry="fintech", website="ntropy.com" - ) - sdk.create_account_holder(account_holder) - - df = pd.DataFrame( - data={ - "amount": [26], - "description": ["TARGET T- 5800 20th St 11/30/19 17:32"], - "entry_type": ["debit"], - "date": ["2012-12-10"], - "account_holder_id": [account_holder.id], - "iso_currency_code": ["USD"], - } - ) - enriched = sdk.add_transactions(df) - assert "missing account holder information" not in enriched.labels[0] - - df = pd.DataFrame( - { - "amount": [27], - "description": ["TARGET T- 5800 20th St 11/30/19 17:32"], - "entry_type": ["debit"], - "date": ["2012-12-10"], - "account_holder_type": ["business"], - "iso_currency_code": ["USD"], - } - ) - enriched = sdk.add_transactions(df) - assert "missing account holder information" not in enriched.labels[0] - - -def test_dataframe_inplace(sdk): - df = pd.DataFrame( - data={ - "amount": [26], - "description": ["TARGET T- 5800 20th St 11/30/19 17:32"], - "entry_type": ["debit"], - "date": ["2012-12-10"], - "account_holder_id": [str(uuid.uuid4())], - "account_holder_type": ["consumer"], - "iso_currency_code": ["USD"], - } - ) - original_columns = df.columns - # inplace = False should NOT change the original dataframe - enriched = sdk.add_transactions(df, inplace=False) - assert list(df.columns) == list(original_columns) - assert "goods" in enriched.labels[0] - - # inplace = True should change the original dataframe - sdk.add_transactions(df, inplace=True) - assert "goods" in df.labels[0] - - -def test_account_holder_type_or_id_iterable(sdk): - id_tx = Transaction( - amount=24.56, - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date="2012-12-10", - account_holder_id="1", - iso_currency_code="USD", - mcc=5432, - ) - enriched = sdk.add_transactions((t for t in [id_tx]))[0] - assert "missing account holder information" not in enriched.labels - - type_tx = Transaction( - amount=24.56, - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date="2012-12-10", - account_holder_type="business", - iso_currency_code="USD", - mcc=5432, - ) - enriched = sdk.add_transactions((t for t in [type_tx]))[0] - assert "missing account holder information" not in enriched.labels - - -def test_bad_date(): - def create_tx(date): - return Transaction( - amount=24.56, - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date=date, - account_holder_id="1", - iso_currency_code="USD", - ) - - assert isinstance(create_tx("2021-12-13"), Transaction) - - with pytest.raises(ValueError): - create_tx("bad date") - - with pytest.raises(ValueError): - create_tx("") - - with pytest.raises(ValueError): - create_tx(None) - - -def test_fields(): - tx = Transaction( - amount=24.56, - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date="2012-12-10", - account_holder_id="1", - iso_currency_code="USD", - transaction_id="one-two-three", - mcc=5432, - ) - - assert tx.to_dict() == { - "amount": 24.56, - "description": "TARGET T- 5800 20th St 11/30/19 17:32", - "entry_type": "debit", - "date": "2012-12-10", - "iso_currency_code": "USD", - "transaction_id": "one-two-three", - "mcc": 5432, - "account_holder_id": "1", - } - - with pytest.raises(ValueError): - tx = Transaction( - amount=float("nan"), - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date="2012-12-10", - account_holder_id="1", - iso_currency_code="USD", - transaction_id="one-two-three", - mcc=5432, - ) - - -def test_enrich_huge_batch(sdk): - account_holder = AccountHolder( - id=str(uuid.uuid4()), type="business", industry="fintech", website="ntropy.com" - ) - sdk.create_account_holder(account_holder) - - tx = Transaction( - amount=24.56, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2012-12-10", - account_holder_id=account_holder.id, - iso_currency_code="USD", - ) - - txs = [tx] * 10 - sdk.MAX_BATCH_SIZE = 4 - - enriched_txs = sdk.add_transactions(txs) - - assert len(enriched_txs) == len(txs) - - for i, enriched_tx in enumerate(enriched_txs): - assert isinstance(enriched_tx, EnrichedTransaction) - assert enriched_tx.merchant is not None - assert enriched_tx.transaction_id == txs[i].transaction_id - assert enriched_tx.parent_tx is txs[i] - - -# TODO: temporarily disabled until persistence timing is adjusted for reports -# def test_report(sdk): -# account_holder = AccountHolder( -# id=str(uuid.uuid4()), type="business", industry="fintech", website="ntropy.com" -# ) -# sdk.create_account_holder(account_holder) -# -# tx = Transaction( -# amount=24.56, -# description="TARGET T- 5800 20th St 11/30/19 17:32", -# entry_type="debit", -# date="2012-12-10", -# account_holder_id=account_holder.id, -# iso_currency_code="USD", -# ) -# enriched_tx = sdk.add_transactions([tx])[0] -# -# enriched_tx.create_report(website="ww2.target.com") -# enriched_tx.create_report(unplanned_kwarg="bar") - - -def test_hierarchy(sdk): - for account_holder_type in ["business", "consumer", "unknown"]: - h = sdk.get_labels(account_holder_type) - assert isinstance(h, dict) - - -def test_transaction_zero_amount(): - vals = { - "description": "foo", - "date": "2021-12-12", - "entry_type": "debit", - "account_holder_id": "1", - "country": "US", - "iso_currency_code": "USD", - } - - Transaction(amount=0, **vals) - Transaction(amount=1, **vals) - - with pytest.raises(ValueError): - Transaction(amount=-1, **vals) - - -def test_transaction_entry_type(): - for et in ["incoming", "outgoing", "debit", "credit"]: - Transaction( - amount=1.0, - description="foo", - date="2012-12-10", - entry_type=et, - account_holder_id="bar", - iso_currency_code="USD", - country="US", - ) - - with pytest.raises(ValueError): - Transaction( - amount=1.0, - description="foo", - date="2012-12-10", - entry_type="bar", - account_holder_id="bar", - iso_currency_code="bla", - country="FOO", - ) - - -def test_readme(): - readme_file = open( - os.path.join(os.path.dirname(__file__), "..", "README.md") - ).read() - readme_data = readme_file.split("```python")[1].split("```")[0] - readme_data = readme_data.replace("YOUR-API-KEY", API_KEY) - exec(readme_data, globals()) - - -def test_add_transactions_async(sdk): - tx = Transaction( - amount=24.56, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2012-12-10", - account_holder_type="business", - iso_currency_code="USD", - ) - - batch = sdk.add_transactions_async([tx]) - assert batch.batch_id and len(batch.batch_id) > 0 - - enriched = batch.wait() - assert enriched[0].merchant == "Amazon Web Services" - assert enriched[0].parent_tx is tx - - -def test_add_transactions_async_df(sdk): - df = pd.DataFrame( - data={ - "amount": [26], - "description": ["TARGET T- 5800 20th St 11/30/19 17:32"], - "entry_type": ["debit"], - "date": ["2012-12-10"], - "account_holder_type": ["business"], - "iso_currency_code": ["USD"], - } - ) - batch = sdk.add_transactions_async(df) - enriched = batch.wait() - assert enriched[0].merchant == "Target" - - -def test_add_transactions_async_iterable(sdk): - tx = Transaction( - amount=24.56, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2012-12-10", - account_holder_type="business", - iso_currency_code="USD", - ) - - batch = sdk.add_transactions_async((t for t in [tx])) - assert batch.batch_id and len(batch.batch_id) > 0 - - enriched = batch.wait() - assert enriched[0].merchant == "Amazon Web Services" - - -def test_batch(sdk): - tx = Transaction( - amount=24.56, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2012-12-10", - account_holder_type="business", - iso_currency_code="USD", - ) - - batch = sdk.add_transactions_async([tx] * 10) - resp, status = batch.poll() - - if status != "finished": - # Might've finished already - assert status == "started" and resp["total"] == 10 - - batch.wait() - - resp, status = batch.poll() - assert status == "finished" and resp[0].merchant == "Amazon Web Services" - - batch = Batch(sdk=sdk, batch_id=batch.batch_id) - resp, status = batch.poll() - assert status == "finished" and resp[0].merchant == "Amazon Web Services" - - -def test_numerical_support(): - tx = Transaction( - amount=Decimal(24.56), - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date="2012-12-10", - account_holder_id="1", - iso_currency_code="USD", - mcc="5432", - ) - - assert isinstance(tx.amount, float) - assert isinstance(tx.mcc, int) - - -def test_none_in_txns_error(sdk): - test_values = [[None]] - for test_value in test_values: - try: - sdk.add_transactions(test_value) - assert False - except ValueError as e: - assert str(e) == "transactions contains a None value" - - try: - sdk.add_transactions_async(test_value) - assert False - except ValueError as e: - assert str(e) == "transactions contains a None value" - - -def test_parent_tx(sdk): - id_tx = Transaction( - amount=24.56, - description="TARGET T- 5800 20th St 11/30/19 17:32", - entry_type="debit", - date="2012-12-10", - account_holder_id="1", - iso_currency_code="USD", - mcc=5432, - ) - enriched = sdk.add_transactions([id_tx])[0] - assert enriched.parent_tx is id_tx - - -def test_single_transaction_enrich_error(sdk): - tx = Transaction( - amount=24.56, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2012-12-10", - account_holder_type="business", - iso_currency_code="USD", - ) - try: - sdk.add_transactions(tx) - assert False - except TypeError as e: - assert str(e) == "transactions must be either a pandas.Dataframe or an iterable" - - -def test_enriched_fields(sdk): - tx = Transaction( - transaction_id="test-enriched-fields", - amount=12046.15, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2019-12-01", - account_holder_type="business", - iso_currency_code="USD", - country="US", - # mcc=5432, - ) - df = pd.DataFrame( - data={ - "amount": [12046.15], - "description": [ - "AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15" - ], - "entry_type": ["debit"], - "date": ["2019-12-01"], - "account_holder_type": ["business"], - "iso_currency_code": ["USD"], - "transaction_id": ["test-enriched-fields"], - # "mcc": [5432], - } - ) - - enriched_df = sdk.add_transactions(df).iloc[0] - enriched_list = sdk.add_transactions([tx])[0] - - for enriched in [enriched_df, enriched_list]: - print(enriched) - assert "infrastructure" in enriched.labels - # assert len(enriched.location) > 0 - assert enriched.logo == "https://logos.ntropy.com/aws.amazon.com" - assert enriched.merchant == "Amazon Web Services" - assert enriched.merchant_id == str( - uuid.uuid3(uuid.NAMESPACE_DNS, enriched.website) - ) - assert enriched.transaction_id == "test-enriched-fields" - assert enriched.website == "aws.amazon.com" - # assert enriched.recurrence == "one off" - assert 0 <= enriched.confidence <= 1 - assert any(enriched.transaction_type == t.value for t in TransactionType) - # assert 5432 in enriched.mcc - - -def test_sdk_region(): - ah_id = str(uuid.uuid4()) - tx = Transaction( - amount=24.56, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2012-12-10", - account_holder_id=ah_id, - account_holder_type="consumer", - iso_currency_code="USD", - ) - - _sdk = SDK(API_KEY) - assert _sdk.base_url == "https://api.ntropy.com" - res = _sdk.add_transactions([tx])[0] - assert res.website is not None - - _sdk = SDK(API_KEY, region="us") - assert _sdk.base_url == "https://api.ntropy.com" - res = _sdk.add_transactions([tx])[0] - assert res.website is not None - - _sdk = SDK(API_KEY, region="eu") - assert _sdk.base_url == "https://api.eu.ntropy.com" - res = _sdk.add_transactions([tx])[0] - assert res.website is not None - - with pytest.raises(ValueError): - _sdk = SDK(API_KEY, region="atlantida") - - -@pytest.fixture() -def async_sdk(): - sdk = SDK(API_KEY) - sdk._make_batch = make_batch - - with patch.object(sdk, "MAX_SYNC_BATCH", 0): - with patch.object(sdk, "MAX_BATCH_SIZE", 1): - yield sdk - - -@pytest.fixture() -def sync_sdk(): - sdk = SDK(API_KEY) - sdk._make_batch = lambda batch_status, results: results - - with patch.object(sdk, "MAX_SYNC_BATCH", 999999): - with patch.object(sdk, "MAX_BATCH_SIZE", 1): - yield sdk - - -@pytest.fixture() -def input_tx(): - ah_id = str(uuid.uuid4()) - return Transaction( - amount=24.56, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2012-12-10", - account_holder_id=ah_id, - account_holder_type="consumer", - iso_currency_code="USD", - ) - - -def make_batch(batch_status, results): - return { - "status": batch_status, - "created_at": "2023-01-01T00:00:00.000000+00:00", - "updated_at": "2023-01-01T00:00:00.000000+00:00", - "id": "mock-id", - "progress": 1, - "total": 1, - "results": results, - } - - -class MockResponse: - def __init__(self, status_code, json_data): - self.status_code = status_code - self.json_data = json_data - - def json(self): - return self.json_data - - def raise_for_status(self): - r = Response() - r.status_code = self.status_code - return r.raise_for_status() - - -def wrap_response(sdk, wrap_meth, responses): - """ - Mocks a response for `wrap_meth` iterating through `responses` to obtain return values - """ - orig = sdk.session.request - responses = iter(responses) - - def fn(meth, *args, **kwargs): - t = None - if meth == wrap_meth: - try: - t = next(responses) - except StopIteration: - pass - - if not t: - return orig(meth, *args, **kwargs) - - status_code, batch_status, results = t - return MockResponse( - status_code, - sdk._make_batch(batch_status, results), # noqa - ) - else: - return orig(meth, *args, **kwargs) - - return fn - - -@pytest.mark.parametrize("batch_status", ["finished", "error"]) -def test_async_batch_with_err_ignore_raise(async_sdk, input_tx, batch_status): - async_sdk._raise_on_enrichment_error = False - responses = wrap_response( - async_sdk, - "GET", - [ - ( - 200, - batch_status, - [ - { - "transaction_id": "err", - "error": "internal_error", - "error_details": "internal_error", - } - ], - ), - ], - ) - - with patch.object( - async_sdk.session, - "request", - side_effect=responses, - ) as m: - res = async_sdk.add_transactions([input_tx] * 2) - assert m.call_count >= 4 - assert len(res) == 2 - assert res[0].error is not None and "mock-id" in str(res[0].error) - assert res[1].merchant is not None and res[1].error is None - - -def test_async_batch_request_err_ignore_raise(async_sdk, input_tx): - async_sdk._raise_on_enrichment_error = False - responses = wrap_response( - async_sdk, - "GET", - [ - ( - 400, - "error", - [ - { - "transaction_id": "err", - "error": "internal_error", - "error_details": "internal_error", - } - ], - ), - ], - ) - - with patch.object( - async_sdk.session, - "request", - side_effect=responses, - ) as m: - res = async_sdk.add_transactions([input_tx] * 2) - assert m.call_count >= 4 - assert len(res) == 2 - assert res[0].error is not None and isinstance(res[0].error, NtropyValueError) - assert res[1].merchant is not None and res[1].error is None - - -def test_sync_batch_request_err_ignore_raise(sync_sdk, input_tx): - sync_sdk._raise_on_enrichment_error = False - responses = wrap_response( - sync_sdk, - "POST", - [ - ( - 400, - "error", - [ - { - "transaction_id": "err", - "error": "internal_error", - "error_details": "internal_error", - } - ], - ), - ], - ) - - with patch.object( - sync_sdk.session, - "request", - side_effect=responses, - ) as m: - res = sync_sdk.add_transactions([input_tx] * 2) - assert m.call_count == 2 - assert len(res) == 2 - assert res[0].error is not None and isinstance(res[0].error, NtropyValueError) - assert res[1].merchant is not None and res[1].error is None - - -@pytest.mark.parametrize("batch_status", ["finished", "error"]) -def test_async_batch_with_err(async_sdk, input_tx, batch_status): - async_sdk._raise_on_enrichment_error = True - responses = wrap_response( - async_sdk, - "GET", - [ - ( - 200, - batch_status, - [ - { - "transaction_id": "err", - "error": "internal_error", - "error_details": "internal_error", - } - ], - ), - ], - ) - - with patch.object( - async_sdk.session, - "request", - side_effect=responses, - ) as m: - with pytest.raises(NtropyBatchError) as be: - async_sdk.add_transactions([input_tx] * 2) - assert "mock-id" in str(be.value) - assert m.call_count == 2 - - -def test_async_batch_request_err(async_sdk, input_tx): - async_sdk._raise_on_enrichment_error = True - responses = wrap_response( - async_sdk, - "GET", - [ - ( - 400, - "error", - [ - { - "transaction_id": "err", - "error": "internal_error", - "error_details": "internal_error", - } - ], - ), - ], - ) - - with patch.object( - async_sdk.session, - "request", - side_effect=responses, - ) as m: - with pytest.raises(NtropyValueError): - async_sdk.add_transactions([input_tx] * 2) - assert m.call_count == 2 - - -def test_sync_batch_request_err(sync_sdk, input_tx): - sync_sdk._raise_on_enrichment_error = True - responses = wrap_response( - sync_sdk, - "POST", - [ - ( - 400, - "error", - [ - { - "transaction_id": "err", - "error": "internal_error", - "error_details": "internal_error", - } - ], - ), - ], - ) - - with patch.object( - sync_sdk.session, - "request", - side_effect=responses, - ) as m: - with pytest.raises(NtropyValueError): - sync_sdk.add_transactions([input_tx] * 2) - assert m.call_count == 1 - - -def test_mapping(sdk): - tx = Transaction( - transaction_id="test-enriched-fields", - amount=12046.15, - description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", - entry_type="debit", - date="2019-12-01", - account_holder_type="business", - iso_currency_code="USD", - country="US", - mcc=5432, - ) - - mapping = {"merchant": "company"} - mapping_invalid = {"invalid": "test"} - - enriched_mapping = sdk.add_transactions([tx], mapping=mapping) - try: - enriched_mapping_invalid = sdk.add_transactions([tx], mapping=mapping_invalid) - except KeyError as e: - assert "invalid mapping" in str(e) - - try: - enriched_mapping[0].merchant - except AttributeError as e: - assert str(e) == "'EnrichedTransaction' object has no attribute 'merchant'" - - assert enriched_mapping[0].company == "Amazon Web Services" +def test_pagination(sdk: SDK): + tx_ids = set() + it = sdk.v3.transactions.list(limit=2).auto_paginate(page_size=2) + for tx in islice(it, 10): + tx_ids.add(tx.id) + assert len(tx_ids) == 10 diff --git a/tests/test_v3.py b/tests/test_v3.py deleted file mode 100644 index 27947d4..0000000 --- a/tests/test_v3.py +++ /dev/null @@ -1,10 +0,0 @@ -from itertools import islice -from ntropy_sdk import SDK - - -def test_pagination(sdk: SDK): - tx_ids = set() - it = sdk.v3.transactions.list(limit=2).auto_paginate(page_size=2) - for tx in islice(it, 10): - tx_ids.add(tx.id) - assert len(tx_ids) == 10 diff --git a/tests/bank_statement_sample.pdf b/tests/v2/bank_statement_sample.pdf similarity index 100% rename from tests/bank_statement_sample.pdf rename to tests/v2/bank_statement_sample.pdf diff --git a/tests/conftest.py b/tests/v2/conftest.py similarity index 100% rename from tests/conftest.py rename to tests/v2/conftest.py diff --git a/tests/test_datasources.py b/tests/v2/test_bank_statements.py similarity index 96% rename from tests/test_datasources.py rename to tests/v2/test_bank_statements.py index 91a01fb..300b1e8 100644 --- a/tests/test_datasources.py +++ b/tests/v2/test_bank_statements.py @@ -2,7 +2,7 @@ import pytest -from ntropy_sdk.errors import NtropyDatasourceError +from ntropy_sdk.v2.errors import NtropyDatasourceError from ntropy_sdk.v2.ntropy_sdk import BankStatementRequest from ntropy_sdk.utils import AccountHolderType diff --git a/tests/test_income_check.py b/tests/v2/test_income_check.py similarity index 99% rename from tests/test_income_check.py rename to tests/v2/test_income_check.py index f5d8340..dd776a6 100644 --- a/tests/test_income_check.py +++ b/tests/v2/test_income_check.py @@ -1,6 +1,6 @@ import pytest import pandas as pd -from ntropy_sdk.income_check import ( +from ntropy_sdk.v2.income_check import ( UNDETERMINED_LABEL, IncomeGroup, IncomeLabelEnum, diff --git a/tests/test_recurring_payments.py b/tests/v2/test_recurring_payments.py similarity index 98% rename from tests/test_recurring_payments.py rename to tests/v2/test_recurring_payments.py index d8a7b81..e5a1cc0 100644 --- a/tests/test_recurring_payments.py +++ b/tests/v2/test_recurring_payments.py @@ -1,6 +1,6 @@ import os import pytest -from ntropy_sdk import SDK, Transaction +from ntropy_sdk.v2 import SDK, Transaction from tests import API_KEY diff --git a/tests/v2/test_sdk.py b/tests/v2/test_sdk.py new file mode 100644 index 0000000..7e944d0 --- /dev/null +++ b/tests/v2/test_sdk.py @@ -0,0 +1,851 @@ +import os +import uuid +from decimal import Decimal +from unittest.mock import patch + +import pandas as pd +import pytest +from requests import Response + +from ntropy_sdk.v2 import ( + AccountHolder, + Batch, + EnrichedTransaction, + SDK, + Transaction, +) +from ntropy_sdk.v2.errors import NtropyValueError, NtropyBatchError +from ntropy_sdk.utils import TransactionType +from ntropy_sdk.v2.ntropy_sdk import ACCOUNT_HOLDER_TYPES +from tests import API_KEY + + +def test_account_holder_type(): + def create_account_holder(account_holder_type): + return AccountHolder( + id=str(uuid.uuid4()), + type=account_holder_type, + industry="fintech", + website="ntropy.com", + ) + + for t in ACCOUNT_HOLDER_TYPES: + account_holder = create_account_holder(t) + assert account_holder.type == t + with pytest.raises(ValueError): + create_account_holder("not_valid") + + +def test_get_account_holder(sdk): + account_holder = AccountHolder( + id=str(uuid.uuid4()), + type="business", + industry="fintech", + website="ntropy.com", + ) + + sdk.create_account_holder(account_holder) + account_holder2 = sdk.get_account_holder(account_holder.id) + + assert isinstance(account_holder2, AccountHolder) + assert account_holder2.type == "business" + assert account_holder2.industry == "fintech" + assert account_holder2.website == "ntropy.com" + + +def test_account_holder_type_or_id(sdk): + id_tx = Transaction( + amount=24.56, + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date="2012-12-10", + account_holder_id="1", + iso_currency_code="USD", + mcc=5432, + ) + enriched = sdk.add_transactions([id_tx])[0] + assert "missing account holder information" not in enriched.labels + + type_tx = Transaction( + amount=24.56, + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date="2012-12-10", + account_holder_type="business", + iso_currency_code="USD", + mcc=5432, + ) + enriched = sdk.add_transactions([type_tx])[0] + assert "missing account holder information" not in enriched.labels + + +def test_account_holder_type_or_id_pandas(sdk): + account_holder = AccountHolder( + id=str(uuid.uuid4()), type="business", industry="fintech", website="ntropy.com" + ) + sdk.create_account_holder(account_holder) + + df = pd.DataFrame( + data={ + "amount": [26], + "description": ["TARGET T- 5800 20th St 11/30/19 17:32"], + "entry_type": ["debit"], + "date": ["2012-12-10"], + "account_holder_id": [account_holder.id], + "iso_currency_code": ["USD"], + } + ) + enriched = sdk.add_transactions(df) + assert "missing account holder information" not in enriched.labels[0] + + df = pd.DataFrame( + { + "amount": [27], + "description": ["TARGET T- 5800 20th St 11/30/19 17:32"], + "entry_type": ["debit"], + "date": ["2012-12-10"], + "account_holder_type": ["business"], + "iso_currency_code": ["USD"], + } + ) + enriched = sdk.add_transactions(df) + assert "missing account holder information" not in enriched.labels[0] + + +def test_dataframe_inplace(sdk): + df = pd.DataFrame( + data={ + "amount": [26], + "description": ["TARGET T- 5800 20th St 11/30/19 17:32"], + "entry_type": ["debit"], + "date": ["2012-12-10"], + "account_holder_id": [str(uuid.uuid4())], + "account_holder_type": ["consumer"], + "iso_currency_code": ["USD"], + } + ) + original_columns = df.columns + # inplace = False should NOT change the original dataframe + enriched = sdk.add_transactions(df, inplace=False) + assert list(df.columns) == list(original_columns) + assert "goods" in enriched.labels[0] + + # inplace = True should change the original dataframe + sdk.add_transactions(df, inplace=True) + assert "goods" in df.labels[0] + + +def test_account_holder_type_or_id_iterable(sdk): + id_tx = Transaction( + amount=24.56, + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date="2012-12-10", + account_holder_id="1", + iso_currency_code="USD", + mcc=5432, + ) + enriched = sdk.add_transactions((t for t in [id_tx]))[0] + assert "missing account holder information" not in enriched.labels + + type_tx = Transaction( + amount=24.56, + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date="2012-12-10", + account_holder_type="business", + iso_currency_code="USD", + mcc=5432, + ) + enriched = sdk.add_transactions((t for t in [type_tx]))[0] + assert "missing account holder information" not in enriched.labels + + +def test_bad_date(): + def create_tx(date): + return Transaction( + amount=24.56, + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date=date, + account_holder_id="1", + iso_currency_code="USD", + ) + + assert isinstance(create_tx("2021-12-13"), Transaction) + + with pytest.raises(ValueError): + create_tx("bad date") + + with pytest.raises(ValueError): + create_tx("") + + with pytest.raises(ValueError): + create_tx(None) + + +def test_fields(): + tx = Transaction( + amount=24.56, + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date="2012-12-10", + account_holder_id="1", + iso_currency_code="USD", + transaction_id="one-two-three", + mcc=5432, + ) + + assert tx.to_dict() == { + "amount": 24.56, + "description": "TARGET T- 5800 20th St 11/30/19 17:32", + "entry_type": "debit", + "date": "2012-12-10", + "iso_currency_code": "USD", + "transaction_id": "one-two-three", + "mcc": 5432, + "account_holder_id": "1", + } + + with pytest.raises(ValueError): + tx = Transaction( + amount=float("nan"), + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date="2012-12-10", + account_holder_id="1", + iso_currency_code="USD", + transaction_id="one-two-three", + mcc=5432, + ) + + +def test_enrich_huge_batch(sdk): + account_holder = AccountHolder( + id=str(uuid.uuid4()), type="business", industry="fintech", website="ntropy.com" + ) + sdk.create_account_holder(account_holder) + + tx = Transaction( + amount=24.56, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2012-12-10", + account_holder_id=account_holder.id, + iso_currency_code="USD", + ) + + txs = [tx] * 10 + sdk.MAX_BATCH_SIZE = 4 + + enriched_txs = sdk.add_transactions(txs) + + assert len(enriched_txs) == len(txs) + + for i, enriched_tx in enumerate(enriched_txs): + assert isinstance(enriched_tx, EnrichedTransaction) + assert enriched_tx.merchant is not None + assert enriched_tx.transaction_id == txs[i].transaction_id + assert enriched_tx.parent_tx is txs[i] + + +# TODO: temporarily disabled until persistence timing is adjusted for reports +# def test_report(sdk): +# account_holder = AccountHolder( +# id=str(uuid.uuid4()), type="business", industry="fintech", website="ntropy.com" +# ) +# sdk.create_account_holder(account_holder) +# +# tx = Transaction( +# amount=24.56, +# description="TARGET T- 5800 20th St 11/30/19 17:32", +# entry_type="debit", +# date="2012-12-10", +# account_holder_id=account_holder.id, +# iso_currency_code="USD", +# ) +# enriched_tx = sdk.add_transactions([tx])[0] +# +# enriched_tx.create_report(website="ww2.target.com") +# enriched_tx.create_report(unplanned_kwarg="bar") + + +def test_hierarchy(sdk): + for account_holder_type in ["business", "consumer", "unknown"]: + h = sdk.get_labels(account_holder_type) + assert isinstance(h, dict) + + +def test_transaction_zero_amount(): + vals = { + "description": "foo", + "date": "2021-12-12", + "entry_type": "debit", + "account_holder_id": "1", + "country": "US", + "iso_currency_code": "USD", + } + + Transaction(amount=0, **vals) + Transaction(amount=1, **vals) + + with pytest.raises(ValueError): + Transaction(amount=-1, **vals) + + +def test_transaction_entry_type(): + for et in ["incoming", "outgoing", "debit", "credit"]: + Transaction( + amount=1.0, + description="foo", + date="2012-12-10", + entry_type=et, + account_holder_id="bar", + iso_currency_code="USD", + country="US", + ) + + with pytest.raises(ValueError): + Transaction( + amount=1.0, + description="foo", + date="2012-12-10", + entry_type="bar", + account_holder_id="bar", + iso_currency_code="bla", + country="FOO", + ) + + +def test_readme(): + readme_file = open( + os.path.join(os.path.dirname(__file__), "..", "README.md") + ).read() + readme_data = readme_file.split("```python")[1].split("```")[0] + readme_data = readme_data.replace("YOUR-API-KEY", API_KEY) + exec(readme_data, globals()) + + +def test_add_transactions_async(sdk): + tx = Transaction( + amount=24.56, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2012-12-10", + account_holder_type="business", + iso_currency_code="USD", + ) + + batch = sdk.add_transactions_async([tx]) + assert batch.batch_id and len(batch.batch_id) > 0 + + enriched = batch.wait() + assert enriched[0].merchant == "Amazon Web Services" + assert enriched[0].parent_tx is tx + + +def test_add_transactions_async_df(sdk): + df = pd.DataFrame( + data={ + "amount": [26], + "description": ["TARGET T- 5800 20th St 11/30/19 17:32"], + "entry_type": ["debit"], + "date": ["2012-12-10"], + "account_holder_type": ["business"], + "iso_currency_code": ["USD"], + } + ) + batch = sdk.add_transactions_async(df) + enriched = batch.wait() + assert enriched[0].merchant == "Target" + + +def test_add_transactions_async_iterable(sdk): + tx = Transaction( + amount=24.56, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2012-12-10", + account_holder_type="business", + iso_currency_code="USD", + ) + + batch = sdk.add_transactions_async((t for t in [tx])) + assert batch.batch_id and len(batch.batch_id) > 0 + + enriched = batch.wait() + assert enriched[0].merchant == "Amazon Web Services" + + +def test_batch(sdk): + tx = Transaction( + amount=24.56, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2012-12-10", + account_holder_type="business", + iso_currency_code="USD", + ) + + batch = sdk.add_transactions_async([tx] * 10) + resp, status = batch.poll() + + if status != "finished": + # Might've finished already + assert status == "started" and resp["total"] == 10 + + batch.wait() + + resp, status = batch.poll() + assert status == "finished" and resp[0].merchant == "Amazon Web Services" + + batch = Batch(sdk=sdk, batch_id=batch.batch_id) + resp, status = batch.poll() + assert status == "finished" and resp[0].merchant == "Amazon Web Services" + + +def test_numerical_support(): + tx = Transaction( + amount=Decimal(24.56), + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date="2012-12-10", + account_holder_id="1", + iso_currency_code="USD", + mcc="5432", + ) + + assert isinstance(tx.amount, float) + assert isinstance(tx.mcc, int) + + +def test_none_in_txns_error(sdk): + test_values = [[None]] + for test_value in test_values: + try: + sdk.add_transactions(test_value) + assert False + except ValueError as e: + assert str(e) == "transactions contains a None value" + + try: + sdk.add_transactions_async(test_value) + assert False + except ValueError as e: + assert str(e) == "transactions contains a None value" + + +def test_parent_tx(sdk): + id_tx = Transaction( + amount=24.56, + description="TARGET T- 5800 20th St 11/30/19 17:32", + entry_type="debit", + date="2012-12-10", + account_holder_id="1", + iso_currency_code="USD", + mcc=5432, + ) + enriched = sdk.add_transactions([id_tx])[0] + assert enriched.parent_tx is id_tx + + +def test_single_transaction_enrich_error(sdk): + tx = Transaction( + amount=24.56, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2012-12-10", + account_holder_type="business", + iso_currency_code="USD", + ) + try: + sdk.add_transactions(tx) + assert False + except TypeError as e: + assert str(e) == "transactions must be either a pandas.Dataframe or an iterable" + + +def test_enriched_fields(sdk): + tx = Transaction( + transaction_id="test-enriched-fields", + amount=12046.15, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2019-12-01", + account_holder_type="business", + iso_currency_code="USD", + country="US", + # mcc=5432, + ) + df = pd.DataFrame( + data={ + "amount": [12046.15], + "description": [ + "AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15" + ], + "entry_type": ["debit"], + "date": ["2019-12-01"], + "account_holder_type": ["business"], + "iso_currency_code": ["USD"], + "transaction_id": ["test-enriched-fields"], + # "mcc": [5432], + } + ) + + enriched_df = sdk.add_transactions(df).iloc[0] + enriched_list = sdk.add_transactions([tx])[0] + + for enriched in [enriched_df, enriched_list]: + print(enriched) + assert "infrastructure" in enriched.labels + # assert len(enriched.location) > 0 + assert enriched.logo == "https://logos.ntropy.com/aws.amazon.com" + assert enriched.merchant == "Amazon Web Services" + assert enriched.merchant_id == str( + uuid.uuid3(uuid.NAMESPACE_DNS, enriched.website) + ) + assert enriched.transaction_id == "test-enriched-fields" + assert enriched.website == "aws.amazon.com" + # assert enriched.recurrence == "one off" + assert 0 <= enriched.confidence <= 1 + assert any(enriched.transaction_type == t.value for t in TransactionType) + # assert 5432 in enriched.mcc + + +def test_sdk_region(): + ah_id = str(uuid.uuid4()) + tx = Transaction( + amount=24.56, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2012-12-10", + account_holder_id=ah_id, + account_holder_type="consumer", + iso_currency_code="USD", + ) + + _sdk = SDK(API_KEY) + assert _sdk.base_url == "https://api.ntropy.com" + res = _sdk.add_transactions([tx])[0] + assert res.website is not None + + _sdk = SDK(API_KEY, region="us") + assert _sdk.base_url == "https://api.ntropy.com" + res = _sdk.add_transactions([tx])[0] + assert res.website is not None + + _sdk = SDK(API_KEY, region="eu") + assert _sdk.base_url == "https://api.eu.ntropy.com" + res = _sdk.add_transactions([tx])[0] + assert res.website is not None + + with pytest.raises(ValueError): + _sdk = SDK(API_KEY, region="atlantida") + + +@pytest.fixture() +def async_sdk(): + sdk = SDK(API_KEY) + sdk._make_batch = make_batch + + with patch.object(sdk, "MAX_SYNC_BATCH", 0): + with patch.object(sdk, "MAX_BATCH_SIZE", 1): + yield sdk + + +@pytest.fixture() +def sync_sdk(): + sdk = SDK(API_KEY) + sdk._make_batch = lambda batch_status, results: results + + with patch.object(sdk, "MAX_SYNC_BATCH", 999999): + with patch.object(sdk, "MAX_BATCH_SIZE", 1): + yield sdk + + +@pytest.fixture() +def input_tx(): + ah_id = str(uuid.uuid4()) + return Transaction( + amount=24.56, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2012-12-10", + account_holder_id=ah_id, + account_holder_type="consumer", + iso_currency_code="USD", + ) + + +def make_batch(batch_status, results): + return { + "status": batch_status, + "created_at": "2023-01-01T00:00:00.000000+00:00", + "updated_at": "2023-01-01T00:00:00.000000+00:00", + "id": "mock-id", + "progress": 1, + "total": 1, + "results": results, + } + + +class MockResponse: + def __init__(self, status_code, json_data): + self.status_code = status_code + self.json_data = json_data + + def json(self): + return self.json_data + + def raise_for_status(self): + r = Response() + r.status_code = self.status_code + return r.raise_for_status() + + +def wrap_response(sdk, wrap_meth, responses): + """ + Mocks a response for `wrap_meth` iterating through `responses` to obtain return values + """ + orig = sdk.session.request + responses = iter(responses) + + def fn(meth, *args, **kwargs): + t = None + if meth == wrap_meth: + try: + t = next(responses) + except StopIteration: + pass + + if not t: + return orig(meth, *args, **kwargs) + + status_code, batch_status, results = t + return MockResponse( + status_code, + sdk._make_batch(batch_status, results), # noqa + ) + else: + return orig(meth, *args, **kwargs) + + return fn + + +@pytest.mark.parametrize("batch_status", ["finished", "error"]) +def test_async_batch_with_err_ignore_raise(async_sdk, input_tx, batch_status): + async_sdk._raise_on_enrichment_error = False + responses = wrap_response( + async_sdk, + "GET", + [ + ( + 200, + batch_status, + [ + { + "transaction_id": "err", + "error": "internal_error", + "error_details": "internal_error", + } + ], + ), + ], + ) + + with patch.object( + async_sdk.session, + "request", + side_effect=responses, + ) as m: + res = async_sdk.add_transactions([input_tx] * 2) + assert m.call_count >= 4 + assert len(res) == 2 + assert res[0].error is not None and "mock-id" in str(res[0].error) + assert res[1].merchant is not None and res[1].error is None + + +def test_async_batch_request_err_ignore_raise(async_sdk, input_tx): + async_sdk._raise_on_enrichment_error = False + responses = wrap_response( + async_sdk, + "GET", + [ + ( + 400, + "error", + [ + { + "transaction_id": "err", + "error": "internal_error", + "error_details": "internal_error", + } + ], + ), + ], + ) + + with patch.object( + async_sdk.session, + "request", + side_effect=responses, + ) as m: + res = async_sdk.add_transactions([input_tx] * 2) + assert m.call_count >= 4 + assert len(res) == 2 + assert res[0].error is not None and isinstance(res[0].error, NtropyValueError) + assert res[1].merchant is not None and res[1].error is None + + +def test_sync_batch_request_err_ignore_raise(sync_sdk, input_tx): + sync_sdk._raise_on_enrichment_error = False + responses = wrap_response( + sync_sdk, + "POST", + [ + ( + 400, + "error", + [ + { + "transaction_id": "err", + "error": "internal_error", + "error_details": "internal_error", + } + ], + ), + ], + ) + + with patch.object( + sync_sdk.session, + "request", + side_effect=responses, + ) as m: + res = sync_sdk.add_transactions([input_tx] * 2) + assert m.call_count == 2 + assert len(res) == 2 + assert res[0].error is not None and isinstance(res[0].error, NtropyValueError) + assert res[1].merchant is not None and res[1].error is None + + +@pytest.mark.parametrize("batch_status", ["finished", "error"]) +def test_async_batch_with_err(async_sdk, input_tx, batch_status): + async_sdk._raise_on_enrichment_error = True + responses = wrap_response( + async_sdk, + "GET", + [ + ( + 200, + batch_status, + [ + { + "transaction_id": "err", + "error": "internal_error", + "error_details": "internal_error", + } + ], + ), + ], + ) + + with patch.object( + async_sdk.session, + "request", + side_effect=responses, + ) as m: + with pytest.raises(NtropyBatchError) as be: + async_sdk.add_transactions([input_tx] * 2) + assert "mock-id" in str(be.value) + assert m.call_count == 2 + + +def test_async_batch_request_err(async_sdk, input_tx): + async_sdk._raise_on_enrichment_error = True + responses = wrap_response( + async_sdk, + "GET", + [ + ( + 400, + "error", + [ + { + "transaction_id": "err", + "error": "internal_error", + "error_details": "internal_error", + } + ], + ), + ], + ) + + with patch.object( + async_sdk.session, + "request", + side_effect=responses, + ) as m: + with pytest.raises(NtropyValueError): + async_sdk.add_transactions([input_tx] * 2) + assert m.call_count == 2 + + +def test_sync_batch_request_err(sync_sdk, input_tx): + sync_sdk._raise_on_enrichment_error = True + responses = wrap_response( + sync_sdk, + "POST", + [ + ( + 400, + "error", + [ + { + "transaction_id": "err", + "error": "internal_error", + "error_details": "internal_error", + } + ], + ), + ], + ) + + with patch.object( + sync_sdk.session, + "request", + side_effect=responses, + ) as m: + with pytest.raises(NtropyValueError): + sync_sdk.add_transactions([input_tx] * 2) + assert m.call_count == 1 + + +def test_mapping(sdk): + tx = Transaction( + transaction_id="test-enriched-fields", + amount=12046.15, + description="AMAZON WEB SERVICES AWS.AMAZON.CO WA Ref5543286P25S Crd15", + entry_type="debit", + date="2019-12-01", + account_holder_type="business", + iso_currency_code="USD", + country="US", + mcc=5432, + ) + + mapping = {"merchant": "company"} + mapping_invalid = {"invalid": "test"} + + enriched_mapping = sdk.add_transactions([tx], mapping=mapping) + try: + enriched_mapping_invalid = sdk.add_transactions([tx], mapping=mapping_invalid) + except KeyError as e: + assert "invalid mapping" in str(e) + + try: + enriched_mapping[0].merchant + except AttributeError as e: + assert str(e) == "'EnrichedTransaction' object has no attribute 'merchant'" + + assert enriched_mapping[0].company == "Amazon Web Services" From 487c9d6b718b02359dcb24affc4dc2ea8569cf9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:52:51 +0100 Subject: [PATCH 06/64] remove ipynb --- tests/test_recurring_payments.ipynb | 293 ---------------------------- 1 file changed, 293 deletions(-) delete mode 100644 tests/test_recurring_payments.ipynb diff --git a/tests/test_recurring_payments.ipynb b/tests/test_recurring_payments.ipynb deleted file mode 100644 index 2f726cc..0000000 --- a/tests/test_recurring_payments.ipynb +++ /dev/null @@ -1,293 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "pycharm": { - "name": "#%%\n" - } - }, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "env: NTROPY_API_KEY=\n", - "env: NTROPY_API_URL=\n" - ] - } - ], - "source": [ - "%env NTROPY_API_KEY=\n", - "%env NTROPY_API_URL=" - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - } - } - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "pycharm": { - "name": "#%%\n" - } - }, - "outputs": [], - "source": [ - "import os\n", - "import uuid\n", - "from IPython.core.display_functions import display\n", - "from tests import API_KEY\n", - "from ntropy_sdk import SDK, Transaction\n", - "\n", - "sdk = SDK(API_KEY)\n", - "\n", - "url = os.environ.get(\"NTROPY_API_URL\")\n", - "if url is not None:\n", - " sdk.base_url = url" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "pycharm": { - "name": "#%%\n" - } - }, - "outputs": [], - "source": [ - "ah_id = str(uuid.uuid4())\n", - "transactions = [\n", - " (\"2021-01-01\", 17.99, \"Netflix\",),\n", - " (\"2021-02-01\", 17.99, \"Netflix\"),\n", - " (\"2021-03-01\", 17.99, \"Netflix\"),\n", - " (\"2021-04-01\", 17.99, \"Netflix\"),\n", - "\n", - " (\"2021-01-15\", 9.99, \"Spotify\"),\n", - " (\"2021-02-15\", 9.99, \"Spotify\"),\n", - " (\"2021-03-15\", 9.99, \"Spotify\"),\n", - "\n", - " (\"2021-03-15\", 11.99, \"Dropbox\"),\n", - "\n", - " (\"2021-01-01\", 100.0, \"Consolidated Edison\"),\n", - " (\"2021-02-01\", 100.0, \"Consolidated Edison\"),\n", - " (\"2021-03-01\", 100.0, \"Consolidated Edison\"),\n", - "\n", - " (\"2021-01-01\", 1000.0, 'Rent'),\n", - " (\"2021-02-01\", 1000.0, 'Rent'),\n", - " (\"2021-03-01\", 1000.0, 'Rent'),\n", - " ]\n", - "\n", - "transactions = [\n", - " Transaction(\n", - " date=tx[0],\n", - " amount=tx[1],\n", - " description=tx[2],\n", - " entry_type=\"debit\",\n", - " iso_currency_code=\"USD\",\n", - " transaction_id=f\"tx-{i}\",\n", - " account_holder_type=\"consumer\",\n", - " account_holder_id='rec-ah-1',\n", - " )\n", - " for i, tx in enumerate(transactions)\n", - "]" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "pycharm": { - "name": "#%%\n" - } - }, - "outputs": [ - { - "data": { - "text/plain": "date amount entry_type iso_currency_code description account_holder_id account_holder_type transaction_id labels location logo merchant merchant_id person website chart_of_accounts recurrence recurrence_group\n---------- -------- ------------ ------------------- ------------- ------------------------------------ --------------------- ---------------- ------------------------------------------------ ---------- ---------------------------------------------------- ------------------- ------------------------------------ -------- ----------- ------------------- ------------ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------\n2022-01-01 17.99 debit USD Netflix cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-1 ['entertainment', 'television', 'subscriptions'] https://logos.ntropy.com/netflix.com Netflix dc425051-df94-3509-9103-cf8c0f0bcf6a netflix.com [] subscription {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 4, 1), 'frequency_in_days': 31, 'average_amount_per_tx': 17.99, 'other_party': 'netflix.com', 'id': '1742734f-dca0-3549-b8af-515ebc272098', 'transaction_ids': ['tx-1', 'tx-2', 'tx-3', 'tx-4'], 'periodicity': 'monthly', 'total_amount': 71.96}\n2022-02-01 17.99 debit USD Netflix cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-2 ['entertainment', 'television', 'subscriptions'] https://logos.ntropy.com/netflix.com Netflix dc425051-df94-3509-9103-cf8c0f0bcf6a netflix.com [] subscription {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 4, 1), 'frequency_in_days': 31, 'average_amount_per_tx': 17.99, 'other_party': 'netflix.com', 'id': '1742734f-dca0-3549-b8af-515ebc272098', 'transaction_ids': ['tx-1', 'tx-2', 'tx-3', 'tx-4'], 'periodicity': 'monthly', 'total_amount': 71.96}\n2022-03-01 17.99 debit USD Netflix cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-3 ['entertainment', 'television', 'subscriptions'] https://logos.ntropy.com/netflix.com Netflix dc425051-df94-3509-9103-cf8c0f0bcf6a netflix.com [] subscription {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 4, 1), 'frequency_in_days': 31, 'average_amount_per_tx': 17.99, 'other_party': 'netflix.com', 'id': '1742734f-dca0-3549-b8af-515ebc272098', 'transaction_ids': ['tx-1', 'tx-2', 'tx-3', 'tx-4'], 'periodicity': 'monthly', 'total_amount': 71.96}\n2022-04-01 17.99 debit USD Netflix cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-4 ['entertainment', 'television', 'subscriptions'] https://logos.ntropy.com/netflix.com Netflix dc425051-df94-3509-9103-cf8c0f0bcf6a netflix.com [] subscription {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 4, 1), 'frequency_in_days': 31, 'average_amount_per_tx': 17.99, 'other_party': 'netflix.com', 'id': '1742734f-dca0-3549-b8af-515ebc272098', 'transaction_ids': ['tx-1', 'tx-2', 'tx-3', 'tx-4'], 'periodicity': 'monthly', 'total_amount': 71.96}\n2022-01-15 9.99 debit USD Spotify cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-5 ['entertainment', 'music', 'subscriptions'] https://logos.ntropy.com/spotify.com Spotify dff87b60-eb39-3df9-9232-39f89ff78df9 spotify.com [] subscription {'date_of_first_tx': datetime.date(2022, 1, 15), 'date_of_last_tx': datetime.date(2022, 3, 15), 'frequency_in_days': 29, 'average_amount_per_tx': 9.99, 'other_party': 'spotify.com', 'id': 'e2ec0a07-fd81-3780-b99b-3ca3f7445fbe', 'transaction_ids': ['tx-5', 'tx-6', 'tx-7'], 'periodicity': 'monthly', 'total_amount': 29.97}\n2022-02-15 9.99 debit USD Spotify cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-6 ['entertainment', 'music', 'subscriptions'] https://logos.ntropy.com/spotify.com Spotify dff87b60-eb39-3df9-9232-39f89ff78df9 spotify.com [] subscription {'date_of_first_tx': datetime.date(2022, 1, 15), 'date_of_last_tx': datetime.date(2022, 3, 15), 'frequency_in_days': 29, 'average_amount_per_tx': 9.99, 'other_party': 'spotify.com', 'id': 'e2ec0a07-fd81-3780-b99b-3ca3f7445fbe', 'transaction_ids': ['tx-5', 'tx-6', 'tx-7'], 'periodicity': 'monthly', 'total_amount': 29.97}\n2022-03-15 9.99 debit USD Spotify cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-7 ['entertainment', 'music', 'subscriptions'] https://logos.ntropy.com/spotify.com Spotify dff87b60-eb39-3df9-9232-39f89ff78df9 spotify.com [] subscription {'date_of_first_tx': datetime.date(2022, 1, 15), 'date_of_last_tx': datetime.date(2022, 3, 15), 'frequency_in_days': 29, 'average_amount_per_tx': 9.99, 'other_party': 'spotify.com', 'id': 'e2ec0a07-fd81-3780-b99b-3ca3f7445fbe', 'transaction_ids': ['tx-5', 'tx-6', 'tx-7'], 'periodicity': 'monthly', 'total_amount': 29.97}\n2022-03-15 11.99 debit USD Dropbox cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-8 ['goods', 'software', 'subscriptions'] https://logos.ntropy.com/dropbox.com Dropbox 65799e0f-90c4-3cc0-9ae6-499d81e79137 dropbox.com [] subscription {'date_of_first_tx': datetime.date(2022, 3, 15), 'date_of_last_tx': datetime.date(2022, 3, 15), 'frequency_in_days': 30, 'average_amount_per_tx': 11.99, 'other_party': 'dropbox.com', 'id': 'de657ab1-d4f5-3789-b728-a7a45908908b', 'transaction_ids': ['tx-8'], 'periodicity': 'monthly', 'total_amount': 11.99}\n2022-01-01 1000 debit USD Rent cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-9 ['housing', 'rent'] https://logos.ntropy.com/consumer_icons-housing-rent [] recurring {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 3, 1), 'frequency_in_days': 29, 'average_amount_per_tx': 1000.0, 'other_party': 'unknown', 'id': '9213c470-5a8e-3436-b857-e6f503476e7f', 'transaction_ids': ['tx-9', 'tx-10', 'tx-11'], 'periodicity': 'monthly', 'total_amount': 3000.0}\n2022-02-01 1000 debit USD Rent cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-10 ['housing', 'rent'] https://logos.ntropy.com/consumer_icons-housing-rent [] recurring {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 3, 1), 'frequency_in_days': 29, 'average_amount_per_tx': 1000.0, 'other_party': 'unknown', 'id': '9213c470-5a8e-3436-b857-e6f503476e7f', 'transaction_ids': ['tx-9', 'tx-10', 'tx-11'], 'periodicity': 'monthly', 'total_amount': 3000.0}\n2022-03-01 1000 debit USD Rent cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-11 ['housing', 'rent'] https://logos.ntropy.com/consumer_icons-housing-rent [] recurring {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 3, 1), 'frequency_in_days': 29, 'average_amount_per_tx': 1000.0, 'other_party': 'unknown', 'id': '9213c470-5a8e-3436-b857-e6f503476e7f', 'transaction_ids': ['tx-9', 'tx-10', 'tx-11'], 'periodicity': 'monthly', 'total_amount': 3000.0}\n2022-01-01 100 debit USD Con Edison cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-12 ['utilities'] https://logos.ntropy.com/coned.com Consolidated Edison 53f5eb62-74aa-39de-bd42-edb6cd8b53e1 coned.com [] recurring {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 3, 1), 'frequency_in_days': 29, 'average_amount_per_tx': 100.0, 'other_party': 'coned.com', 'id': 'b0032c36-89d1-346c-87e2-27f93a17df47', 'transaction_ids': ['tx-12', 'tx-13', 'tx-14'], 'periodicity': 'monthly', 'total_amount': 300.0}\n2022-02-01 100 debit USD Con Edison cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-13 ['utilities'] https://logos.ntropy.com/coned.com Consolidated Edison 53f5eb62-74aa-39de-bd42-edb6cd8b53e1 coned.com [] recurring {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 3, 1), 'frequency_in_days': 29, 'average_amount_per_tx': 100.0, 'other_party': 'coned.com', 'id': 'b0032c36-89d1-346c-87e2-27f93a17df47', 'transaction_ids': ['tx-12', 'tx-13', 'tx-14'], 'periodicity': 'monthly', 'total_amount': 300.0}\n2022-03-01 100 debit USD Con Edison cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 consumer tx-14 ['utilities'] https://logos.ntropy.com/coned.com Consolidated Edison 53f5eb62-74aa-39de-bd42-edb6cd8b53e1 coned.com [] recurring {'date_of_first_tx': datetime.date(2022, 1, 1), 'date_of_last_tx': datetime.date(2022, 3, 1), 'frequency_in_days': 29, 'average_amount_per_tx': 100.0, 'other_party': 'coned.com', 'id': 'b0032c36-89d1-346c-87e2-27f93a17df47', 'transaction_ids': ['tx-12', 'tx-13', 'tx-14'], 'periodicity': 'monthly', 'total_amount': 300.0}", - "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
dateamountentry_typeiso_currency_codedescriptionaccount_holder_idaccount_holder_typetransaction_idlabelslocationlogomerchantmerchant_idpersonwebsitechart_of_accountsrecurrencerecurrence_group
02022-01-0117.99debitUSDNetflixcd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-1[entertainment, television, subscriptions]Nonehttps://logos.ntropy.com/netflix.comNetflixdc425051-df94-3509-9103-cf8c0f0bcf6aNonenetflix.com[]subscription{'date_of_first_tx': 2022-01-01, 'date_of_last...
12022-02-0117.99debitUSDNetflixcd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-2[entertainment, television, subscriptions]Nonehttps://logos.ntropy.com/netflix.comNetflixdc425051-df94-3509-9103-cf8c0f0bcf6aNonenetflix.com[]subscription{'date_of_first_tx': 2022-01-01, 'date_of_last...
22022-03-0117.99debitUSDNetflixcd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-3[entertainment, television, subscriptions]Nonehttps://logos.ntropy.com/netflix.comNetflixdc425051-df94-3509-9103-cf8c0f0bcf6aNonenetflix.com[]subscription{'date_of_first_tx': 2022-01-01, 'date_of_last...
32022-04-0117.99debitUSDNetflixcd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-4[entertainment, television, subscriptions]Nonehttps://logos.ntropy.com/netflix.comNetflixdc425051-df94-3509-9103-cf8c0f0bcf6aNonenetflix.com[]subscription{'date_of_first_tx': 2022-01-01, 'date_of_last...
42022-01-159.99debitUSDSpotifycd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-5[entertainment, music, subscriptions]Nonehttps://logos.ntropy.com/spotify.comSpotifydff87b60-eb39-3df9-9232-39f89ff78df9Nonespotify.com[]subscription{'date_of_first_tx': 2022-01-15, 'date_of_last...
52022-02-159.99debitUSDSpotifycd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-6[entertainment, music, subscriptions]Nonehttps://logos.ntropy.com/spotify.comSpotifydff87b60-eb39-3df9-9232-39f89ff78df9Nonespotify.com[]subscription{'date_of_first_tx': 2022-01-15, 'date_of_last...
62022-03-159.99debitUSDSpotifycd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-7[entertainment, music, subscriptions]Nonehttps://logos.ntropy.com/spotify.comSpotifydff87b60-eb39-3df9-9232-39f89ff78df9Nonespotify.com[]subscription{'date_of_first_tx': 2022-01-15, 'date_of_last...
72022-03-1511.99debitUSDDropboxcd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-8[goods, software, subscriptions]Nonehttps://logos.ntropy.com/dropbox.comDropbox65799e0f-90c4-3cc0-9ae6-499d81e79137Nonedropbox.com[]subscription{'date_of_first_tx': 2022-03-15, 'date_of_last...
82022-01-011000.00debitUSDRentcd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-9[housing, rent]Nonehttps://logos.ntropy.com/consumer_icons-housin...NoneNoneNoneNone[]recurring{'date_of_first_tx': 2022-01-01, 'date_of_last...
92022-02-011000.00debitUSDRentcd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-10[housing, rent]Nonehttps://logos.ntropy.com/consumer_icons-housin...NoneNoneNoneNone[]recurring{'date_of_first_tx': 2022-01-01, 'date_of_last...
102022-03-011000.00debitUSDRentcd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-11[housing, rent]Nonehttps://logos.ntropy.com/consumer_icons-housin...NoneNoneNoneNone[]recurring{'date_of_first_tx': 2022-01-01, 'date_of_last...
112022-01-01100.00debitUSDCon Edisoncd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-12[utilities]Nonehttps://logos.ntropy.com/coned.comConsolidated Edison53f5eb62-74aa-39de-bd42-edb6cd8b53e1Noneconed.com[]recurring{'date_of_first_tx': 2022-01-01, 'date_of_last...
122022-02-01100.00debitUSDCon Edisoncd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-13[utilities]Nonehttps://logos.ntropy.com/coned.comConsolidated Edison53f5eb62-74aa-39de-bd42-edb6cd8b53e1Noneconed.com[]recurring{'date_of_first_tx': 2022-01-01, 'date_of_last...
132022-03-01100.00debitUSDCon Edisoncd1738c1-9e66-4d96-b25e-9fc9c7a510f5consumertx-14[utilities]Nonehttps://logos.ntropy.com/coned.comConsolidated Edison53f5eb62-74aa-39de-bd42-edb6cd8b53e1Noneconed.com[]recurring{'date_of_first_tx': 2022-01-01, 'date_of_last...
\n
" - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "sdk.add_transactions(transactions)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "outputs": [ - { - "data": { - "text/plain": " # txs amount merchant website labels periodicity is_active first_payment_date latest_payment_date next_expected_payment_date type is_essential\n------- -------- ------------ ----------- -------------- ------------- ----------- -------------------- --------------------- ---------------------------- ------------ --------------\n 4 17.99 Netflix netflix.com entertainment monthly True 2022-01-01 2022-04-01 2022-05-01 subscription False\n television\n subscriptions\n 1 11.99 Dropbox dropbox.com goods software monthly True 2022-03-15 2022-03-15 2022-04-15 subscription False\n subscriptions\n 3 9.99 Spotify spotify.com entertainment monthly True 2022-01-15 2022-03-15 2022-04-15 subscription False\n music\n subscriptions\n 3 1000 housing rent monthly True 2022-01-01 2022-03-01 2022-04-01 bill True\n 3 100 Consolidated coned.com utilities monthly True 2022-01-01 2022-03-01 2022-04-01 bill True\n Edison", - "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
# txsamountmerchantwebsitelabelsperiodicityis_activefirst_payment_datelatest_payment_datenext_expected_payment_datetypeis_essential
0417.99Netflixnetflix.com[entertainment, television, subscriptions]monthlyTrue2022-01-012022-04-012022-05-01subscriptionFalse
1111.99Dropboxdropbox.com[goods, software, subscriptions]monthlyTrue2022-03-152022-03-152022-04-15subscriptionFalse
239.99Spotifyspotify.com[entertainment, music, subscriptions]monthlyTrue2022-01-152022-03-152022-04-15subscriptionFalse
331000.00[housing, rent]monthlyTrue2022-01-012022-03-012022-04-01billTrue
43100.00Consolidated Edisonconed.com[utilities]monthlyTrue2022-01-012022-03-012022-04-01billTrue
\n
" - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "recurring_payments_groups = sdk.get_recurring_payments(ah_id)\n", - "recurring_payments_groups" - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - } - } - }, - { - "cell_type": "code", - "execution_count": 13, - "outputs": [ - { - "data": { - "text/plain": "-------------------------- ------------------------------------------------\n# txs 4\namount 17.99\nmerchant Netflix\nwebsite netflix.com\nlabels ['entertainment', 'television', 'subscriptions']\ntype subscription\nis_essential False\nperiodicity monthly\nis_active True\nfirst_payment_date 2022-01-01\nlatest_payment_date 2022-04-01\nnext_expected_payment_date 2022-05-01\n-------------------------- ------------------------------------------------", - "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
keyvalue
0# txs4
1amount17.99
2merchantNetflix
3websitenetflix.com
4labels[entertainment, television, subscriptions]
5typesubscription
6is_essentialFalse
7periodicitymonthly
8is_activeTrue
9first_payment_date2022-01-01
10latest_payment_date2022-04-01
11next_expected_payment_date2022-05-01
\n
" - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "recurring_payments_groups[0]" - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - } - } - }, - { - "cell_type": "code", - "execution_count": 14, - "outputs": [ - { - "data": { - "text/plain": "date amount entry_type iso_currency_code description account_holder_id transaction_id labels location logo merchant merchant_id person website chart_of_accounts recurrence recurrence_group\n---------- -------- ------------ ------------------- ------------- ------------------------------------ ---------------- ------------------------------------------------ ---------- ------------------------------------ ---------- ------------------------------------ -------- ----------- ------------------- ------------ ------------------\n2022-04-01 17.99 outgoing USD Netflix cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 tx-4 ['entertainment', 'television', 'subscriptions'] https://logos.ntropy.com/netflix.com Netflix dc425051-df94-3509-9103-cf8c0f0bcf6a netflix.com [] subscription\n2022-03-01 17.99 outgoing USD Netflix cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 tx-3 ['entertainment', 'television', 'subscriptions'] https://logos.ntropy.com/netflix.com Netflix dc425051-df94-3509-9103-cf8c0f0bcf6a netflix.com [] subscription\n2022-02-01 17.99 outgoing USD Netflix cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 tx-2 ['entertainment', 'television', 'subscriptions'] https://logos.ntropy.com/netflix.com Netflix dc425051-df94-3509-9103-cf8c0f0bcf6a netflix.com [] subscription\n2022-01-01 17.99 outgoing USD Netflix cd1738c1-9e66-4d96-b25e-9fc9c7a510f5 tx-1 ['entertainment', 'television', 'subscriptions'] https://logos.ntropy.com/netflix.com Netflix dc425051-df94-3509-9103-cf8c0f0bcf6a netflix.com [] subscription", - "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
dateamountentry_typeiso_currency_codedescriptionaccount_holder_idtransaction_idlabelslocationlogomerchantmerchant_idpersonwebsitechart_of_accountsrecurrencerecurrence_group
02022-04-0117.99outgoingUSDNetflixcd1738c1-9e66-4d96-b25e-9fc9c7a510f5tx-4[entertainment, television, subscriptions]Nonehttps://logos.ntropy.com/netflix.comNetflixdc425051-df94-3509-9103-cf8c0f0bcf6aNonenetflix.com[]subscriptionNone
12022-03-0117.99outgoingUSDNetflixcd1738c1-9e66-4d96-b25e-9fc9c7a510f5tx-3[entertainment, television, subscriptions]Nonehttps://logos.ntropy.com/netflix.comNetflixdc425051-df94-3509-9103-cf8c0f0bcf6aNonenetflix.com[]subscriptionNone
22022-02-0117.99outgoingUSDNetflixcd1738c1-9e66-4d96-b25e-9fc9c7a510f5tx-2[entertainment, television, subscriptions]Nonehttps://logos.ntropy.com/netflix.comNetflixdc425051-df94-3509-9103-cf8c0f0bcf6aNonenetflix.com[]subscriptionNone
32022-01-0117.99outgoingUSDNetflixcd1738c1-9e66-4d96-b25e-9fc9c7a510f5tx-1[entertainment, television, subscriptions]Nonehttps://logos.ntropy.com/netflix.comNetflixdc425051-df94-3509-9103-cf8c0f0bcf6aNonenetflix.com[]subscriptionNone
\n
" - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "recurring_payments_groups[0].transactions" - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - } - } - }, - { - "cell_type": "code", - "execution_count": 15, - "outputs": [ - { - "data": { - "text/plain": " # txs amount merchant website labels periodicity is_active first_payment_date latest_payment_date next_expected_payment_date type is_essential\n------- -------- ---------- ----------- -------------- ------------- ----------- -------------------- --------------------- ---------------------------- ------------ --------------\n 4 17.99 Netflix netflix.com entertainment monthly True 2022-01-01 2022-04-01 2022-05-01 subscription False\n television\n subscriptions\n 1 11.99 Dropbox dropbox.com goods software monthly True 2022-03-15 2022-03-15 2022-04-15 subscription False\n subscriptions\n 3 9.99 Spotify spotify.com entertainment monthly True 2022-01-15 2022-03-15 2022-04-15 subscription False\n music\n subscriptions", - "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
# txsamountmerchantwebsitelabelsperiodicityis_activefirst_payment_datelatest_payment_datenext_expected_payment_datetypeis_essential
0417.99Netflixnetflix.com[entertainment, television, subscriptions]monthlyTrue2022-01-012022-04-012022-05-01subscriptionFalse
1111.99Dropboxdropbox.com[goods, software, subscriptions]monthlyTrue2022-03-152022-03-152022-04-15subscriptionFalse
239.99Spotifyspotify.com[entertainment, music, subscriptions]monthlyTrue2022-01-152022-03-152022-04-15subscriptionFalse
\n
" - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": "113.92" - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "active_subscriptions = recurring_payments_groups.subscriptions().active()\n", - "display(active_subscriptions)\n", - "display(active_subscriptions.total_amount())" - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - } - } - }, - { - "cell_type": "code", - "execution_count": 16, - "outputs": [ - { - "data": { - "text/plain": " # txs amount merchant website labels periodicity is_active first_payment_date latest_payment_date next_expected_payment_date type is_essential\n------- -------- ------------ --------- ------------ ------------- ----------- -------------------- --------------------- ---------------------------- ------ --------------\n 3 1000 housing rent monthly True 2022-01-01 2022-03-01 2022-04-01 bill True\n 3 100 Consolidated coned.com utilities monthly True 2022-01-01 2022-03-01 2022-04-01 bill True\n Edison", - "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
# txsamountmerchantwebsitelabelsperiodicityis_activefirst_payment_datelatest_payment_datenext_expected_payment_datetypeis_essential
031000.0[housing, rent]monthlyTrue2022-01-012022-03-012022-04-01billTrue
13100.0Consolidated Edisonconed.com[utilities]monthlyTrue2022-01-012022-03-012022-04-01billTrue
\n
" - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": "3300.0" - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "active_recurring_bills = recurring_payments_groups.recurring_bills().active()\n", - "display(active_recurring_bills)\n", - "display(active_recurring_bills.total_amount())\n" - ], - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - } - } - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.9" - } - }, - "nbformat": 4, - "nbformat_minor": 1 -} From bdf9362ef69f7c4635c45ea682417c66b102f206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:52:57 +0100 Subject: [PATCH 07/64] fixed imports --- ntropy_sdk/__init__.py | 18 ++++++++++++++++++ ntropy_sdk/account_holders.py | 4 ++-- ntropy_sdk/bank_statements.py | 8 ++++---- ntropy_sdk/batches.py | 6 +++--- ntropy_sdk/paging.py | 2 +- ntropy_sdk/sdk.py | 14 ++++++++++++++ ntropy_sdk/transactions.py | 4 ++-- ntropy_sdk/v3/__init__.py | 27 --------------------------- tests/v2/conftest.py | 2 +- tests/v3/conftest.py | 17 +++++++++++++++++ tests/{ => v3}/test_sdk.py | 0 11 files changed, 62 insertions(+), 40 deletions(-) create mode 100644 ntropy_sdk/sdk.py delete mode 100644 ntropy_sdk/v3/__init__.py create mode 100644 tests/v3/conftest.py rename tests/{ => v3}/test_sdk.py (100%) diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index b558e4d..03f5a32 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -1,3 +1,21 @@ from .version import VERSION __version__ = VERSION + +from typing import TYPE_CHECKING, Optional +import requests + + +if TYPE_CHECKING: + from ntropy_sdk.v2.ntropy_sdk import SDK + from typing_extensions import TypedDict + + class ExtraKwargs(TypedDict, total=False): + request_id: Optional[str] + api_key: Optional[str] + session: Optional[requests.Session] + + +from .sdk import SDK + +__all__ = ["SDK"] diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py index e6c59a3..6344c36 100644 --- a/ntropy_sdk/account_holders.py +++ b/ntropy_sdk/account_holders.py @@ -9,8 +9,8 @@ from ntropy_sdk.paging import PagedResponse if TYPE_CHECKING: - from ntropy_sdk.v2.ntropy_sdk import SDK - from ntropy_sdk.v3 import ExtraKwargs + from ntropy_sdk import ExtraKwargs + from ntropy_sdk import SDK from typing_extensions import Unpack diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 194a1a4..8dcdf67 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -7,14 +7,14 @@ from pydantic import BaseModel, Field, NonNegativeFloat -from ntropy_sdk.bank_statements import StatementInfo -from ntropy_sdk.errors import NtropyDatasourceError +from ntropy_sdk.v2.bank_statements import StatementInfo +from ntropy_sdk.v2.errors import NtropyDatasourceError from ntropy_sdk.utils import EntryType from ntropy_sdk.paging import PagedResponse if TYPE_CHECKING: - from ntropy_sdk.v2.ntropy_sdk import SDK - from ntropy_sdk.v3 import ExtraKwargs + from ntropy_sdk import ExtraKwargs + from ntropy_sdk import SDK from typing_extensions import Unpack diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py index 81d3135..78f4119 100644 --- a/ntropy_sdk/batches.py +++ b/ntropy_sdk/batches.py @@ -6,14 +6,14 @@ from pydantic import BaseModel, Field -from ntropy_sdk.errors import NtropyBatchError +from ntropy_sdk.v2.errors import NtropyBatchError from ntropy_sdk.utils import pydantic_json from ntropy_sdk.paging import PagedResponse from ntropy_sdk.transactions import EnrichedTransaction, EnrichmentInput if TYPE_CHECKING: - from ntropy_sdk.v2.ntropy_sdk import SDK - from ntropy_sdk.v3 import ExtraKwargs + from ntropy_sdk import ExtraKwargs + from ntropy_sdk import SDK from typing_extensions import Unpack diff --git a/ntropy_sdk/paging.py b/ntropy_sdk/paging.py index 9904890..f330bcf 100644 --- a/ntropy_sdk/paging.py +++ b/ntropy_sdk/paging.py @@ -15,7 +15,7 @@ from ntropy_sdk.utils import PYDANTIC_V2 if TYPE_CHECKING: - from ntropy_sdk.v3 import ExtraKwargs + from ntropy_sdk import ExtraKwargs from pydantic import BaseModel as GenericModel from typing_extensions import Unpack, Self diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py new file mode 100644 index 0000000..6a6581f --- /dev/null +++ b/ntropy_sdk/sdk.py @@ -0,0 +1,14 @@ +from ntropy_sdk.http import HttpClient +from ntropy_sdk.transactions import TransactionsResource +from ntropy_sdk.batches import BatchesResource +from ntropy_sdk.bank_statements import BankStatementsResource +from ntropy_sdk.account_holders import AccountHoldersResource + + +class SDK: + def __init__(self): + self.http_client = HttpClient() + self.transactions = TransactionsResource(self) + self.batches = BatchesResource(self) + self.bank_statements = BankStatementsResource(self) + self.account_holders = AccountHoldersResource(self) diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index ba0d135..9d8f435 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -14,8 +14,8 @@ if TYPE_CHECKING: - from ntropy_sdk.v2.ntropy_sdk import SDK - from ntropy_sdk.v3 import ExtraKwargs + from ntropy_sdk import ExtraKwargs + from ntropy_sdk import SDK from typing_extensions import Unpack diff --git a/ntropy_sdk/v3/__init__.py b/ntropy_sdk/v3/__init__.py deleted file mode 100644 index cc7812a..0000000 --- a/ntropy_sdk/v3/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import TYPE_CHECKING, Optional -import requests - - -if TYPE_CHECKING: - from ntropy_sdk.v2.ntropy_sdk import SDK - from typing_extensions import TypedDict - - class ExtraKwargs(TypedDict, total=False): - request_id: Optional[str] - api_key: Optional[str] - session: Optional[requests.Session] - - -from ntropy_sdk.transactions import TransactionsResource -from ntropy_sdk.batches import BatchesResource -from ntropy_sdk.bank_statements import BankStatementsResource -from ntropy_sdk.account_holders import AccountHoldersResource - - -class V3: - def __init__(self, sdk: "SDK"): - self._sdk = sdk - self.transactions = TransactionsResource(sdk) - self.batches = BatchesResource(sdk) - self.bank_statements = BankStatementsResource(sdk) - self.account_holders = AccountHoldersResource(sdk) diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index b6f1e9e..11fb217 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -2,7 +2,7 @@ import pytest as pytest -from ntropy_sdk import SDK +from ntropy_sdk.v2 import SDK from tests import API_KEY diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py new file mode 100644 index 0000000..b6f1e9e --- /dev/null +++ b/tests/v3/conftest.py @@ -0,0 +1,17 @@ +import os + +import pytest as pytest + +from ntropy_sdk import SDK +from tests import API_KEY + + +@pytest.fixture +def sdk(): + sdk = SDK(API_KEY) + + url = os.environ.get("NTROPY_API_URL") + if url is not None: + sdk.base_url = url + + return sdk diff --git a/tests/test_sdk.py b/tests/v3/test_sdk.py similarity index 100% rename from tests/test_sdk.py rename to tests/v3/test_sdk.py From 3d396f945843005958d6d64cea07250be2361a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:10:09 +0100 Subject: [PATCH 08/64] add more overrides to requests --- ntropy_sdk/__init__.py | 4 ++++ ntropy_sdk/sdk.py | 25 +++++++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index 03f5a32..149b5aa 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -14,6 +14,10 @@ class ExtraKwargs(TypedDict, total=False): request_id: Optional[str] api_key: Optional[str] session: Optional[requests.Session] + retries: Optional[int] + timeout: Optional[int] + retry_on_unhandled_exception: Optional[int] + extra_headers: Optional[int] from .sdk import SDK diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py index 6a6581f..a9c9c10 100644 --- a/ntropy_sdk/sdk.py +++ b/ntropy_sdk/sdk.py @@ -1,14 +1,31 @@ +from typing import TYPE_CHECKING + +from ntropy_sdk.account_holders import AccountHoldersResource +from ntropy_sdk.bank_statements import BankStatementsResource +from ntropy_sdk.batches import BatchesResource from ntropy_sdk.http import HttpClient from ntropy_sdk.transactions import TransactionsResource -from ntropy_sdk.batches import BatchesResource -from ntropy_sdk.bank_statements import BankStatementsResource -from ntropy_sdk.account_holders import AccountHoldersResource + +if TYPE_CHECKING: + from ntropy_sdk import ExtraKwargs + from typing_extensions import Unpack class SDK: - def __init__(self): + def __init__(self, api_key: str | None = None): + self.api_key = api_key self.http_client = HttpClient() self.transactions = TransactionsResource(self) self.batches = BatchesResource(self) self.bank_statements = BankStatementsResource(self) self.account_holders = AccountHoldersResource(self) + + def retry_ratelimited_request( + self, + *args, + **kwargs: "Unpack[ExtraKwargs]", + ): + kwargs_copy = kwargs.copy() + if self.api_key and not kwargs_copy.get("api_key"): + kwargs_copy["api_key"] = self.api_key + return self.http_client.retry_ratelimited_request(*args, **kwargs_copy) From 0c62fc6469c7793813dd9bba2d4f1adbab25eff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:39:15 +0100 Subject: [PATCH 09/64] cleaned up signatures --- ntropy_sdk/account_holders.py | 7 +++---- ntropy_sdk/bank_statements.py | 20 ++++++++------------ ntropy_sdk/batches.py | 25 +++++++++++++++++-------- ntropy_sdk/transactions.py | 14 ++++++++------ 4 files changed, 36 insertions(+), 30 deletions(-) diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py index 6344c36..af9920f 100644 --- a/ntropy_sdk/account_holders.py +++ b/ntropy_sdk/account_holders.py @@ -74,7 +74,7 @@ def list( t.request_id = request_id return page - def get(self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolder: + def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolder: """Retrieve an account holder""" request_id = extra_kwargs.get("request_id") @@ -90,8 +90,7 @@ def get(self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolde def create( self, - *, - input: AccountHolder, + account_holder: AccountHolder, **extra_kwargs: "Unpack[ExtraKwargs]", ) -> AccountHolder: """Create an account holder""" @@ -103,7 +102,7 @@ def create( resp = self._sdk.retry_ratelimited_request( "POST", "/v3/account_holders", - payload_json_str=pydantic_json(input), + payload_json_str=pydantic_json(account_holder), **extra_kwargs, ) return AccountHolder(**resp.json(), request_id=request_id) diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 8dcdf67..57a784b 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -38,10 +38,10 @@ class BankStatementJob(BaseModel): request_id: Optional[str] = None def is_completed(self): - ... + return self.status == BankStatementJobStatus.COMPLETED - def is_failed(self): - ... + def is_error(self): + return self.status == BankStatementJobStatus.ERROR def wait_for_results( self, @@ -66,7 +66,7 @@ def wait_for_results( break time.sleep(poll_interval) - if self.status is BankStatementJobStatus.COMPLETED: + if self.is_completed(): return sdk.v3.bank_statements.results(id=self.id, **extra_kwargs) else: raise NtropyDatasourceError() @@ -148,8 +148,8 @@ def list( def create( self, - *, file: Union[IOBase, bytes], + *, filename: Optional[str] = None, **extra_kwargs: "Unpack[ExtraKwargs]", ) -> BankStatementJob: @@ -168,9 +168,7 @@ def create( ) return BankStatementJob(**resp.json(), request_id=request_id) - def get( - self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" - ) -> BankStatementJob: + def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BankStatementJob: request_id = extra_kwargs.get("request_id") if request_id is None: request_id = uuid.uuid4().hex @@ -184,7 +182,7 @@ def get( return BankStatementJob(**resp.json(), request_id=request_id) def results( - self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" + self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" ) -> BankStatementResults: request_id = extra_kwargs.get("request_id") if request_id is None: @@ -198,9 +196,7 @@ def results( ) return BankStatementResults(**resp.json(), request_id=request_id) - def overview( - self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" - ) -> StatementInfo: + def overview(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementInfo: """Waits for and returns preliminary statement information from the first page of the PDF. This may not always be consistent with the final results.""" diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py index 78f4119..9beb5b8 100644 --- a/ntropy_sdk/batches.py +++ b/ntropy_sdk/batches.py @@ -9,7 +9,11 @@ from ntropy_sdk.v2.errors import NtropyBatchError from ntropy_sdk.utils import pydantic_json from ntropy_sdk.paging import PagedResponse -from ntropy_sdk.transactions import EnrichedTransaction, EnrichmentInput +from ntropy_sdk.transactions import ( + EnrichedTransaction, + EnrichmentInput, + InputTransaction, +) if TYPE_CHECKING: from ntropy_sdk import ExtraKwargs @@ -40,7 +44,13 @@ class Batch(BaseModel): total: int = Field(description="The total number of transactions in the batch.") request_id: Optional[str] = None - def wait( + def is_completed(self): + return self.status == BatchStatus.COMPLETED + + def is_error(self): + return self.status == BatchStatus.ERROR + + def wait_for_results( self, sdk: "SDK", *, @@ -60,7 +70,7 @@ def wait( break time.sleep(poll_interval) - if self.status is BatchStatus.COMPLETED: + if self.is_completed(): return sdk.v3.batches.results(id=self.id, **extra_kwargs) else: raise NtropyBatchError(f"Batch[{self.id}] contains errors") @@ -125,7 +135,7 @@ def list( t.request_id = request_id return page - def get(self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Batch: + def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Batch: """Retrieve a batch""" request_id = extra_kwargs.get("request_id") @@ -141,8 +151,7 @@ def get(self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Batch: def create( self, - *, - input: EnrichmentInput, + transactions: List[InputTransaction], **extra_kwargs: "Unpack[ExtraKwargs]", ) -> Batch: """Submit a batch of transactions for enrichment""" @@ -154,12 +163,12 @@ def create( resp = self._sdk.retry_ratelimited_request( "POST", "/v3/batches", - payload_json_str=pydantic_json(input), + payload_json_str=pydantic_json(EnrichmentInput(transactions=transactions)), **extra_kwargs, ) return Batch(**resp.json(), request_id=request_id) - def results(self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BatchResult: + def results(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BatchResult: request_id = extra_kwargs.get("request_id") if request_id is None: request_id = uuid.uuid4().hex diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index 9d8f435..d3e7c94 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -344,7 +344,7 @@ def list( t.request_id = request_id return page - def get(self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Transaction: + def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Transaction: """Retrieve a transaction""" request_id = extra_kwargs.get("request_id") @@ -361,8 +361,7 @@ def get(self, *, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Transaction: def create( self, - *, - input: EnrichmentInput, + transactions: List[InputTransaction], **extra_kwargs: "Unpack[ExtraKwargs]", ) -> EnrichmentResult: """Synchronously enrich transactions""" @@ -374,13 +373,16 @@ def create( resp = self._sdk.retry_ratelimited_request( "POST", "/v3/transactions", - payload_json_str=pydantic_json(input), + payload_json_str=pydantic_json(EnrichmentInput(transactions=transactions)), **extra_kwargs, ) return EnrichmentResult(**resp.json(), request_id=request_id) def assign( - self, *, id: str, account_holder_id: str, **extra_kwargs: "Unpack[ExtraKwargs]" + self, + transaction_id: str, + account_holder_id: str, + **extra_kwargs: "Unpack[ExtraKwargs]", ) -> Transaction: """Assign a transaction to an account holder""" @@ -390,7 +392,7 @@ def assign( extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( "POST", - f"/v3/transactions/{id}/assign", + f"/v3/transactions/{transaction_id}/assign", params={ "account_holder_id": account_holder_id, }, From de9007791c56900605b469b47b6e5674b55d9cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:39:46 +0100 Subject: [PATCH 10/64] flake8 --- ntropy_sdk/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index 149b5aa..856a956 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -7,7 +7,6 @@ if TYPE_CHECKING: - from ntropy_sdk.v2.ntropy_sdk import SDK from typing_extensions import TypedDict class ExtraKwargs(TypedDict, total=False): From 7f940f16a4d8c26d2bfcb76b4fd5e586d9df7922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:48:38 +0100 Subject: [PATCH 11/64] updated errors --- ntropy_sdk/v2/errors.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ntropy_sdk/v2/errors.py b/ntropy_sdk/v2/errors.py index 02fbc7f..10d5e91 100644 --- a/ntropy_sdk/v2/errors.py +++ b/ntropy_sdk/v2/errors.py @@ -75,18 +75,26 @@ class NtropyValueError(NtropyHTTPError): class NtropyQuotaExceededError(NtropyHTTPError): DESCRIPTION = ( - "Reached the transaction limit for this API key. Please contact Ntropy support" + "Not enough credits to perform this operation. Please top up your account" ) class NtropyNotSupportedError(NtropyHTTPError): - DESCRIPTION = "The requested operation is not support for this API key. Please contact Ntropy support" + DESCRIPTION = "The requested operation is not supported for this API key. Please contact Ntropy support" class NtropyResourceOccupiedError(NtropyHTTPError): DESCRIPTION = "The resource you're trying to access is busy or not ready yet" +class NtropyServerConnectionError(NtropyHTTPError): + DESCRIPTION = "Server connection error. Please try again later." + + +class NtropyRateLimitError(NtropyHTTPError): + DESCRIPTION = "Too many requests. Please try again later." + + ERROR_MAP = { 400: NtropyValueError, 401: NtropyNotAuthorizedError, @@ -96,6 +104,9 @@ class NtropyResourceOccupiedError(NtropyHTTPError): 422: NtropyValidationError, 423: NtropyQuotaExceededError, 500: NtropyRuntimeError, + 502: NtropyServerConnectionError, + 503: NtropyServerConnectionError, + 504: NtropyServerConnectionError, } From 412842acca1aa2dc919adda46217a5fa9b36dddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:29:24 +0100 Subject: [PATCH 12/64] renamed to TransactionInput --- ntropy_sdk/batches.py | 4 ++-- ntropy_sdk/transactions.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py index 9beb5b8..8f47f42 100644 --- a/ntropy_sdk/batches.py +++ b/ntropy_sdk/batches.py @@ -12,7 +12,7 @@ from ntropy_sdk.transactions import ( EnrichedTransaction, EnrichmentInput, - InputTransaction, + TransactionInput, ) if TYPE_CHECKING: @@ -151,7 +151,7 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Batch: def create( self, - transactions: List[InputTransaction], + transactions: List[TransactionInput], **extra_kwargs: "Unpack[ExtraKwargs]", ) -> Batch: """Submit a batch of transactions for enrichment""" diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index d3e7c94..9b790f7 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -62,7 +62,7 @@ class _TransactionBase(BaseModel): ) -class InputTransaction(_TransactionBase): +class TransactionInput(_TransactionBase): account_holder_id: Optional[str] = Field( None, description="The id of the account holder. Unsetting it will disable categorization.", @@ -280,7 +280,7 @@ class EnrichedTransaction(_EnrichedTransactionBase): class EnrichmentInput(BaseModel): - transactions: List[InputTransaction] + transactions: List[TransactionInput] class EnrichmentResult(BaseModel): @@ -361,7 +361,7 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Transaction: def create( self, - transactions: List[InputTransaction], + transactions: List[TransactionInput], **extra_kwargs: "Unpack[ExtraKwargs]", ) -> EnrichmentResult: """Synchronously enrich transactions""" From 912f132d7fce1eec42bbcce21ebaa19f82e69498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:33:07 +0100 Subject: [PATCH 13/64] export errors --- ntropy_sdk/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index 856a956..a341ec2 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -20,5 +20,4 @@ class ExtraKwargs(TypedDict, total=False): from .sdk import SDK - -__all__ = ["SDK"] +from .v2.errors import * From 15dc4cf02b300a3caa4adababfa2726ae2fbcc1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Sun, 20 Oct 2024 10:55:55 +0100 Subject: [PATCH 14/64] imports --- ntropy_sdk/__init__.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index a341ec2..02996f5 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -20,4 +20,20 @@ class ExtraKwargs(TypedDict, total=False): from .sdk import SDK -from .v2.errors import * +from .v2.errors import ( + NtropyError, + NtropyBatchError, + NtropyDatasourceError, + NtropyTimeoutError, + NtropyHTTPError, + NtropyValidationError, + NtropyQuotaExceededError, + NtropyNotSupportedError, + NtropyResourceOccupiedError, + NtropyServerConnectionError, + NtropyRateLimitError, + NtropyNotFoundError, + NtropyNotAuthorizedError, + NtropyValueError, + NtropyRuntimeError, +) From 25a2c5503a2fb4a10838dc1ccbbfb9b38552d887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Mon, 21 Oct 2024 11:53:24 +0100 Subject: [PATCH 15/64] fixed tests --- README.md | 31 +++++++++++++++---------------- ntropy_sdk/__init__.py | 6 ++++++ ntropy_sdk/account_holders.py | 14 +++++++------- ntropy_sdk/bank_statements.py | 20 ++++++++++---------- ntropy_sdk/batches.py | 18 +++++++++--------- ntropy_sdk/http.py | 6 ++++-- ntropy_sdk/sdk.py | 17 ++++++++++++++--- ntropy_sdk/transactions.py | 16 ++++++++-------- ntropy_sdk/v2/__init__.py | 6 ++++-- ntropy_sdk/v2/ntropy_sdk.py | 18 ++++++------------ tests/v2/test_sdk.py | 23 +++++++---------------- tests/v3/test_sdk.py | 13 ++++++++++++- 12 files changed, 102 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index ccbb6c3..a74c8fa 100644 --- a/README.md +++ b/README.md @@ -17,24 +17,23 @@ $ python3 -m pip install --upgrade 'ntropy-sdk' Enriching your first transaction requires an `SDK` object and an input `Transaction` object. The API key can be set in the environment variable `NTROPY_API_KEY` or in the `SDK` constructor: ```python -from ntropy_sdk import SDK, Transaction +from ntropy_sdk import SDK, TransactionInput, LocationInput sdk = SDK("YOUR-API-KEY") -tx = Transaction( - description = "AMAZON WEB SERVICES", - entry_type = "outgoing", - amount = 12042.37, - iso_currency_code = "USD", - date = "2021-11-01", - transaction_id = "4yp49x3tbj9mD8DB4fM8DDY6Yxbx8YP14g565Xketw3tFmn", - country = "US", - account_holder_id = "id-1", - account_holder_type = "business", - account_holder_name = "Robin's Tacos", -) - -enriched_tx = sdk.add_transactions([tx])[0] -print(enriched_tx.merchant) +r = sdk.transactions.create([ + TransactionInput( + id = "4yp49x3tbj9mD8DB4fM8DDY6Yxbx8YP14g565Xketw3tFmn", + description = "AMAZON WEB SERVICES", + entry_type = "outgoing", + amount = 12042.37, + currency = "USD", + date = "2021-11-01", + location = LocationInput( + country="US" + ), + ) +]) +print(r.transactions[0].entities.counterparty) ``` The returned `EnrichedTransaction` contains the added information by Ntropy API. You can consult the Enrichment section of the documentation for more information on the parameters for both `Transaction` and `EnrichedTransaction`. diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index 02996f5..aeea0e8 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -37,3 +37,9 @@ class ExtraKwargs(TypedDict, total=False): NtropyValueError, NtropyRuntimeError, ) + +from .transactions import ( + TransactionInput, LocationInput +) + +from .account_holders import AccountHolder diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py index af9920f..ab73d40 100644 --- a/ntropy_sdk/account_holders.py +++ b/ntropy_sdk/account_holders.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Literal import uuid from pydantic import BaseModel, Field @@ -54,8 +54,8 @@ def list( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - "/v3/account_holders", + method="GET", + url="/v3/account_holders", params={ "created_before": created_before, "created_after": created_after, @@ -82,8 +82,8 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolder: request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/account_holders/{id}", + method="GET", + url=f"/v3/account_holders/{id}", **extra_kwargs, ) return AccountHolder(**resp.json(), request_id=request_id) @@ -100,8 +100,8 @@ def create( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "POST", - "/v3/account_holders", + method="POST", + url="/v3/account_holders", payload_json_str=pydantic_json(account_holder), **extra_kwargs, ) diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 57a784b..16aea64 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -124,8 +124,8 @@ def list( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - "/v3/bank_statements", + method="GET", + url="/v3/bank_statements", params={ "created_before": created_before, "created_after": created_after, @@ -158,8 +158,8 @@ def create( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "POST", - "/v3/bank_statements", + method="POST", + url="/v3/bank_statements", payload=None, files={ "file": file if filename is None else (filename, file), @@ -174,8 +174,8 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BankStatementJo request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/bank_statements/{id}", + method="GET", + url=f"/v3/bank_statements/{id}", payload=None, **extra_kwargs, ) @@ -189,8 +189,8 @@ def results( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/bank_statements/{id}/results", + method="GET", + url=f"/v3/bank_statements/{id}/results", payload=None, **extra_kwargs, ) @@ -205,8 +205,8 @@ def overview(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementI request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/bank_statements/{id}/overview", + method="GET", + url=f"/v3/bank_statements/{id}/overview", payload=None, **extra_kwargs, ) diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py index 8f47f42..e32663e 100644 --- a/ntropy_sdk/batches.py +++ b/ntropy_sdk/batches.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field -from ntropy_sdk.v2.errors import NtropyBatchError +from ntropy_sdk.v2 import NtropyBatchError from ntropy_sdk.utils import pydantic_json from ntropy_sdk.paging import PagedResponse from ntropy_sdk.transactions import ( @@ -114,8 +114,8 @@ def list( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - "/v3/batches", + method="GET", + url="/v3/batches", params={ "created_before": created_before, "created_after": created_after, @@ -143,8 +143,8 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Batch: request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/batches/{id}", + method="GET", + url=f"/v3/batches/{id}", **extra_kwargs, ) return Batch(**resp.json(), request_id=request_id) @@ -161,8 +161,8 @@ def create( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "POST", - "/v3/batches", + method="POST", + url="/v3/batches", payload_json_str=pydantic_json(EnrichmentInput(transactions=transactions)), **extra_kwargs, ) @@ -174,8 +174,8 @@ def results(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BatchResult request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/batches/{id}/results", + method="GET", + url=f"/v3/batches/{id}/results", **extra_kwargs, ) return BatchResult(**resp.json(), request_id=request_id) diff --git a/ntropy_sdk/http.py b/ntropy_sdk/http.py index 205f2b4..15c8b75 100644 --- a/ntropy_sdk/http.py +++ b/ntropy_sdk/http.py @@ -5,7 +5,6 @@ from typing import Optional import requests -from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter from ntropy_sdk import VERSION from ntropy_sdk.v2.errors import error_from_http_status_code, NtropyError @@ -18,7 +17,9 @@ def __init__(self, session: requests.Session | None = None): def _get_session(self) -> requests.Session: if self._session is None: self._session = requests.Session() - self._session.mount("https://", TCPKeepAliveAdapter()) + # from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter + # self._session.mount("https://", TCPKeepAliveAdapter()) + # self._session.mount("https://") return self._session @property @@ -31,6 +32,7 @@ def session(self, session: requests.Session): def retry_ratelimited_request( self, + *, method: str, url: str, payload: Optional[object] = None, diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py index a9c9c10..25e1ddf 100644 --- a/ntropy_sdk/sdk.py +++ b/ntropy_sdk/sdk.py @@ -5,6 +5,7 @@ from ntropy_sdk.batches import BatchesResource from ntropy_sdk.http import HttpClient from ntropy_sdk.transactions import TransactionsResource +from ntropy_sdk.v2.ntropy_sdk import DEFAULT_REGION, ALL_REGIONS if TYPE_CHECKING: from ntropy_sdk import ExtraKwargs @@ -12,7 +13,12 @@ class SDK: - def __init__(self, api_key: str | None = None): + def __init__( + self, + api_key: str | None = None, + region: str = DEFAULT_REGION, + ): + self.base_url = ALL_REGIONS[region] self.api_key = api_key self.http_client = HttpClient() self.transactions = TransactionsResource(self) @@ -22,10 +28,15 @@ def __init__(self, api_key: str | None = None): def retry_ratelimited_request( self, - *args, + *, + method: str, + url: str, **kwargs: "Unpack[ExtraKwargs]", ): kwargs_copy = kwargs.copy() if self.api_key and not kwargs_copy.get("api_key"): kwargs_copy["api_key"] = self.api_key - return self.http_client.retry_ratelimited_request(*args, **kwargs_copy) + + return self.http_client.retry_ratelimited_request(method=method, + url=self.base_url + url, + **kwargs_copy) diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index 9b790f7..72b77c2 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -321,8 +321,8 @@ def list( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - "/v3/transactions", + method="GET", + url="/v3/transactions", params={ "created_before": created_before, "created_after": created_after, @@ -352,8 +352,8 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Transaction: request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "GET", - f"/v3/transactions/{id}", + method="GET", + url=f"/v3/transactions/{id}", payload=None, **extra_kwargs, ) @@ -371,8 +371,8 @@ def create( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "POST", - "/v3/transactions", + method="POST", + url="/v3/transactions", payload_json_str=pydantic_json(EnrichmentInput(transactions=transactions)), **extra_kwargs, ) @@ -391,8 +391,8 @@ def assign( request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - "POST", - f"/v3/transactions/{transaction_id}/assign", + method="POST", + url=f"/v3/transactions/{transaction_id}/assign", params={ "account_holder_id": account_holder_id, }, diff --git a/ntropy_sdk/v2/__init__.py b/ntropy_sdk/v2/__init__.py index 6565797..4c634ec 100644 --- a/ntropy_sdk/v2/__init__.py +++ b/ntropy_sdk/v2/__init__.py @@ -1,4 +1,4 @@ -from ntropy_sdk.v2.ntropy_sdk import ( +from .ntropy_sdk import ( AccountHolder, AccountHolderType, Transaction, @@ -9,8 +9,9 @@ BankStatement, BankStatementRequest, Report, + StatementInfo, ) -from ntropy_sdk.v2.errors import ( +from .errors import ( NtropyError, NtropyBatchError, ) @@ -28,4 +29,5 @@ "BankStatement", "BankStatementRequest", "Report", + "StatementInfo", ) diff --git a/ntropy_sdk/v2/ntropy_sdk.py b/ntropy_sdk/v2/ntropy_sdk.py index 8d31d41..347bc9b 100644 --- a/ntropy_sdk/v2/ntropy_sdk.py +++ b/ntropy_sdk/v2/ntropy_sdk.py @@ -20,9 +20,8 @@ Union, ) from itertools import islice -from json import JSONDecodeError -import requests # type: ignore +import requests from pydantic import ( BaseModel, Field, @@ -31,16 +30,14 @@ root_validator, Extra, ) -from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter # type: ignore from tabulate import tabulate from tqdm.auto import tqdm -from ntropy_sdk import __version__ -from ntropy_sdk.bank_statements import StatementInfo -from ntropy_sdk.income_check import IncomeReport, IncomeGroup +from .bank_statements import StatementInfo +from .income_check import IncomeReport, IncomeGroup from ntropy_sdk.http import HttpClient -from ntropy_sdk.v2.recurring_payments import ( +from .recurring_payments import ( RecurringPaymentsGroups, RecurringPaymentsGroup, ) @@ -52,8 +49,7 @@ dict_to_str, validate_date, ) -from ntropy_sdk.errors import ( - error_from_http_status_code, +from .errors import ( NtropyTimeoutError, NtropyBatchError, NtropyError, @@ -64,7 +60,6 @@ NtropyQuotaExceededError, NtropyValidationError, ) -from ntropy_sdk.v3 import V3 DEFAULT_TIMEOUT = 10 * 60 DEFAULT_RETRIES = 10 @@ -1266,7 +1261,6 @@ def __init__( self.token = token self.http_client = HttpClient() self.logger = logging.getLogger("Ntropy-SDK") - self.v3 = V3(self) self._extra_headers = {} self._timeout = timeout @@ -1326,12 +1320,12 @@ def retry_ratelimited_request( request_id=request_id, api_key=api_key or self.token, session=session, - request_kwargs=request_kwargs, logger=self.logger, retries=self._retries, timeout=self._timeout, retry_on_unhandled_exception=self._retry_on_unhandled_exception, extra_headers=self._extra_headers, + **request_kwargs, ) def create_account_holder(self, account_holder: AccountHolder): diff --git a/tests/v2/test_sdk.py b/tests/v2/test_sdk.py index 7e944d0..205c928 100644 --- a/tests/v2/test_sdk.py +++ b/tests/v2/test_sdk.py @@ -317,15 +317,6 @@ def test_transaction_entry_type(): ) -def test_readme(): - readme_file = open( - os.path.join(os.path.dirname(__file__), "..", "README.md") - ).read() - readme_data = readme_file.split("```python")[1].split("```")[0] - readme_data = readme_data.replace("YOUR-API-KEY", API_KEY) - exec(readme_data, globals()) - - def test_add_transactions_async(sdk): tx = Transaction( amount=24.56, @@ -607,7 +598,7 @@ def wrap_response(sdk, wrap_meth, responses): """ Mocks a response for `wrap_meth` iterating through `responses` to obtain return values """ - orig = sdk.session.request + orig = sdk.http_client.session.request responses = iter(responses) def fn(meth, *args, **kwargs): @@ -654,7 +645,7 @@ def test_async_batch_with_err_ignore_raise(async_sdk, input_tx, batch_status): ) with patch.object( - async_sdk.session, + async_sdk.http_client.session, "request", side_effect=responses, ) as m: @@ -686,7 +677,7 @@ def test_async_batch_request_err_ignore_raise(async_sdk, input_tx): ) with patch.object( - async_sdk.session, + async_sdk.http_client.session, "request", side_effect=responses, ) as m: @@ -718,7 +709,7 @@ def test_sync_batch_request_err_ignore_raise(sync_sdk, input_tx): ) with patch.object( - sync_sdk.session, + sync_sdk.http_client.session, "request", side_effect=responses, ) as m: @@ -751,7 +742,7 @@ def test_async_batch_with_err(async_sdk, input_tx, batch_status): ) with patch.object( - async_sdk.session, + async_sdk.http_client.session, "request", side_effect=responses, ) as m: @@ -782,7 +773,7 @@ def test_async_batch_request_err(async_sdk, input_tx): ) with patch.object( - async_sdk.session, + async_sdk.http_client.session, "request", side_effect=responses, ) as m: @@ -812,7 +803,7 @@ def test_sync_batch_request_err(sync_sdk, input_tx): ) with patch.object( - sync_sdk.session, + sync_sdk.http_client.session, "request", side_effect=responses, ) as m: diff --git a/tests/v3/test_sdk.py b/tests/v3/test_sdk.py index 27947d4..664424c 100644 --- a/tests/v3/test_sdk.py +++ b/tests/v3/test_sdk.py @@ -1,10 +1,21 @@ +import os from itertools import islice from ntropy_sdk import SDK +from tests import API_KEY def test_pagination(sdk: SDK): tx_ids = set() - it = sdk.v3.transactions.list(limit=2).auto_paginate(page_size=2) + it = sdk.transactions.list(limit=2).auto_paginate(page_size=2) for tx in islice(it, 10): tx_ids.add(tx.id) assert len(tx_ids) == 10 + + +def test_readme(): + readme_file = open( + os.path.join(os.path.dirname(__file__), "..", "..", "README.md") + ).read() + readme_data = readme_file.split("```python")[1].split("```")[0] + readme_data = readme_data.replace("YOUR-API-KEY", API_KEY) + exec(readme_data, globals()) From 002f36cc49e2936d931cabae742015ba81ab850b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Mon, 21 Oct 2024 11:57:19 +0100 Subject: [PATCH 16/64] keepalive --- ntropy_sdk/http.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ntropy_sdk/http.py b/ntropy_sdk/http.py index 15c8b75..fc3e5bc 100644 --- a/ntropy_sdk/http.py +++ b/ntropy_sdk/http.py @@ -17,9 +17,8 @@ def __init__(self, session: requests.Session | None = None): def _get_session(self) -> requests.Session: if self._session is None: self._session = requests.Session() - # from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter - # self._session.mount("https://", TCPKeepAliveAdapter()) - # self._session.mount("https://") + from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter + self._session.mount("https://", TCPKeepAliveAdapter()) return self._session @property From 5f210dec1d4bf985b512599c2636d7af4b4bdd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Mon, 21 Oct 2024 13:59:34 +0100 Subject: [PATCH 17/64] typing --- ntropy_sdk/http.py | 4 ++-- ntropy_sdk/sdk.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ntropy_sdk/http.py b/ntropy_sdk/http.py index fc3e5bc..514d0b4 100644 --- a/ntropy_sdk/http.py +++ b/ntropy_sdk/http.py @@ -11,7 +11,7 @@ class HttpClient: - def __init__(self, session: requests.Session | None = None): + def __init__(self, session: Optional[requests.Session] = None): self._session = session def _get_session(self) -> requests.Session: @@ -36,7 +36,7 @@ def retry_ratelimited_request( url: str, payload: Optional[object] = None, payload_json_str: Optional[str] = None, - logger: logging.Logger | None = None, + logger: Optional[logging.Logger] = None, log_level=logging.DEBUG, request_id: Optional[str] = None, api_key: Optional[str] = None, diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py index 25e1ddf..eb2ffe9 100644 --- a/ntropy_sdk/sdk.py +++ b/ntropy_sdk/sdk.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from ntropy_sdk.account_holders import AccountHoldersResource from ntropy_sdk.bank_statements import BankStatementsResource @@ -15,7 +15,7 @@ class SDK: def __init__( self, - api_key: str | None = None, + api_key: Optional[str] = None, region: str = DEFAULT_REGION, ): self.base_url = ALL_REGIONS[region] From a7ce64f54faa04a98bda37bdf342ee3358f0d985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Mon, 21 Oct 2024 14:29:14 +0100 Subject: [PATCH 18/64] path --- tests/v2/conftest.py | 2 +- tests/v3/conftest.py | 2 +- tests/v3/test_sdk.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index 11fb217..e57075f 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -3,7 +3,7 @@ import pytest as pytest from ntropy_sdk.v2 import SDK -from tests import API_KEY +from .. import API_KEY @pytest.fixture diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py index b6f1e9e..da41dcd 100644 --- a/tests/v3/conftest.py +++ b/tests/v3/conftest.py @@ -3,7 +3,7 @@ import pytest as pytest from ntropy_sdk import SDK -from tests import API_KEY +from .. import API_KEY @pytest.fixture diff --git a/tests/v3/test_sdk.py b/tests/v3/test_sdk.py index 664424c..af532a5 100644 --- a/tests/v3/test_sdk.py +++ b/tests/v3/test_sdk.py @@ -1,7 +1,7 @@ import os from itertools import islice from ntropy_sdk import SDK -from tests import API_KEY +from .. import API_KEY def test_pagination(sdk: SDK): From e1dc3b855e3313e993526e8fa15e36445e7be629 Mon Sep 17 00:00:00 2001 From: Matthew Tran <0e4ef622@gmail.com> Date: Mon, 21 Oct 2024 09:16:28 -0500 Subject: [PATCH 19/64] Add webhooks resource --- ntropy_sdk/__init__.py | 8 +-- ntropy_sdk/bank_statements.py | 4 +- ntropy_sdk/http.py | 4 +- ntropy_sdk/sdk.py | 10 ++- ntropy_sdk/webhooks.py | 121 ++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 ntropy_sdk/webhooks.py diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index aeea0e8..3783a97 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -13,10 +13,10 @@ class ExtraKwargs(TypedDict, total=False): request_id: Optional[str] api_key: Optional[str] session: Optional[requests.Session] - retries: Optional[int] - timeout: Optional[int] - retry_on_unhandled_exception: Optional[int] - extra_headers: Optional[int] + retries: int + timeout: int + retry_on_unhandled_exception: bool + extra_headers: Optional[dict] from .sdk import SDK diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 16aea64..1b77065 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -61,13 +61,13 @@ def wait_for_results( ] start_time = time.monotonic() while time.monotonic() - start_time < timeout: - self.status = sdk.v3.bank_statements.get(id=self.id).status + self.status = sdk.bank_statements.get(id=self.id).status if self.status in finish_statuses: break time.sleep(poll_interval) if self.is_completed(): - return sdk.v3.bank_statements.results(id=self.id, **extra_kwargs) + return sdk.bank_statements.results(id=self.id, **extra_kwargs) else: raise NtropyDatasourceError() diff --git a/ntropy_sdk/http.py b/ntropy_sdk/http.py index 514d0b4..6df0892 100644 --- a/ntropy_sdk/http.py +++ b/ntropy_sdk/http.py @@ -2,7 +2,7 @@ import time import uuid from json import JSONDecodeError -from typing import Optional +from typing import Dict, Optional import requests @@ -34,6 +34,7 @@ def retry_ratelimited_request( *, method: str, url: str, + params: Optional[Dict[str, str]] = None, payload: Optional[object] = None, payload_json_str: Optional[str] = None, logger: Optional[logging.Logger] = None, @@ -89,6 +90,7 @@ def retry_ratelimited_request( resp = cur_session.request( method, url, + params=params, headers=headers, timeout=timeout, **request_kwargs, diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py index eb2ffe9..60e0f69 100644 --- a/ntropy_sdk/sdk.py +++ b/ntropy_sdk/sdk.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Dict, Optional from ntropy_sdk.account_holders import AccountHoldersResource from ntropy_sdk.bank_statements import BankStatementsResource @@ -6,6 +6,7 @@ from ntropy_sdk.http import HttpClient from ntropy_sdk.transactions import TransactionsResource from ntropy_sdk.v2.ntropy_sdk import DEFAULT_REGION, ALL_REGIONS +from ntropy_sdk.webhooks import WebhooksResource if TYPE_CHECKING: from ntropy_sdk import ExtraKwargs @@ -25,12 +26,16 @@ def __init__( self.batches = BatchesResource(self) self.bank_statements = BankStatementsResource(self) self.account_holders = AccountHoldersResource(self) + self.webhooks = WebhooksResource(self) def retry_ratelimited_request( self, *, method: str, url: str, + params: Optional[Dict[str, str]] = None, + payload: Optional[object] = None, + payload_json_str: Optional[str] = None, **kwargs: "Unpack[ExtraKwargs]", ): kwargs_copy = kwargs.copy() @@ -39,4 +44,7 @@ def retry_ratelimited_request( return self.http_client.retry_ratelimited_request(method=method, url=self.base_url + url, + params=params, + payload=payload, + payload_json_str=payload_json_str, **kwargs_copy) diff --git a/ntropy_sdk/webhooks.py b/ntropy_sdk/webhooks.py new file mode 100644 index 0000000..b068cb8 --- /dev/null +++ b/ntropy_sdk/webhooks.py @@ -0,0 +1,121 @@ +from datetime import datetime +from typing import TYPE_CHECKING, List, Literal, Optional +import uuid +from pydantic import BaseModel, Field + +from ntropy_sdk.paging import PagedResponse + +if TYPE_CHECKING: + from ntropy_sdk import ExtraKwargs + from ntropy_sdk import SDK + from typing_extensions import Unpack + +WebhookEventType = Literal[ + "reports.resolved", + "reports.rejected", + "reports.pending", + "bank_statements.processing", + "bank_statements.processed", + "bank_statements.failed", + "batches.completed", + "batches.error", +] + + +class Webhook(BaseModel): + id: str = Field( + description="A generated unique identifier for the webhook", + ) + created_at: datetime = Field( + description="The date and time when the webhook was created.", + ) + url: str = Field( + description="The URL of the webhook", + ) + events: List[WebhookEventType] = Field( + description="A list of events that this webhook subscribes to", + ) + token: Optional[str] = Field( + description="A secret string used to authenticate the webhook. This " + "value will be included in the `X-Ntropy-Token` header when sending " + "requests to the webhook", + ) + request_id: Optional[str] = None + + def delete(self, sdk: "SDK", **extra_kwargs: "Unpack[ExtraKwargs]"): + return sdk.webhooks.delete(self.id, **extra_kwargs) + + +class WebhooksResource: + def __init__(self, sdk: "SDK"): + self._sdk = sdk + + def list( + self, + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> PagedResponse[Webhook]: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + method="GET", + url="/v3/webhooks", + **extra_kwargs, + ) + page = PagedResponse[Webhook]( + **resp.json(), + request_id=request_id, + _resource=self, + _extra_kwargs=extra_kwargs, + ) + for w in page.data: + w.request_id = request_id + return page + + def create( + self, + *, + url: str, + events: List[WebhookEventType], + token: Optional[str], + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> Webhook: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + method="POST", + url="/v3/webhooks", + payload={ + "url": url, + "events": events, + "token": token, + }, + **extra_kwargs, + ) + return Webhook(**resp.json(), request_id=request_id) + + def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Webhook: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + method="GET", + url=f"/v3/webhooks/{id}", + **extra_kwargs, + ) + return Webhook(**resp.json(), request_id=request_id) + + def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]"): + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + self._sdk.retry_ratelimited_request( + method="DELETE", + url=f"/v3/webhooks/{id}", + **extra_kwargs, + ) From d609da89b449b6a65afbf878904096b1a41f0919 Mon Sep 17 00:00:00 2001 From: Matthew Tran <0e4ef622@gmail.com> Date: Mon, 21 Oct 2024 14:55:11 -0500 Subject: [PATCH 20/64] Add rules resource --- ntropy_sdk/rules.py | 96 ++++++++++++++++++++++++++++++++++++++++++ ntropy_sdk/sdk.py | 2 + ntropy_sdk/webhooks.py | 3 +- 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 ntropy_sdk/rules.py diff --git a/ntropy_sdk/rules.py b/ntropy_sdk/rules.py new file mode 100644 index 0000000..5e68d77 --- /dev/null +++ b/ntropy_sdk/rules.py @@ -0,0 +1,96 @@ +from typing import TYPE_CHECKING, Any, Dict, List +import uuid + + +if TYPE_CHECKING: + from ntropy_sdk import ExtraKwargs + from ntropy_sdk import SDK + from typing_extensions import Unpack + + +Rule = Dict[str, Any] +Rules = List[Dict[str, Any]] + + +class RulesResource: + def __init__(self, sdk: "SDK"): + self._sdk = sdk + + def set( + self, + rules: Rules, + **extra_kwargs: "Unpack[ExtraKwargs]", + ): + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + self._sdk.retry_ratelimited_request( + method="POST", + url="/v3/rules", + payload=rules, + **extra_kwargs, + ) + + def get( + self, + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> Rules: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + method="GET", + url="/v3/rules", + **extra_kwargs, + ) + return resp.json() + + def append( + self, + rule: Rule, + **extra_kwargs: "Unpack[ExtraKwargs]", + ): + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + self._sdk.retry_ratelimited_request( + method="POST", + url="/v3/rules/append", + payload=rule, + **extra_kwargs, + ) + + def patch( + self, + index: int, + rule: Rule, + **extra_kwargs: "Unpack[ExtraKwargs]", + ): + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + self._sdk.retry_ratelimited_request( + method="PATCH", + url=f"/v3/rules/{index}", + payload=rule, + **extra_kwargs, + ) + + def delete( + self, + index: int, + **extra_kwargs: "Unpack[ExtraKwargs]", + ): + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + self._sdk.retry_ratelimited_request( + method="DELETE", + url=f"/v3/rules/{index}", + **extra_kwargs, + ) diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py index 60e0f69..67158c9 100644 --- a/ntropy_sdk/sdk.py +++ b/ntropy_sdk/sdk.py @@ -4,6 +4,7 @@ from ntropy_sdk.bank_statements import BankStatementsResource from ntropy_sdk.batches import BatchesResource from ntropy_sdk.http import HttpClient +from ntropy_sdk.rules import RulesResource from ntropy_sdk.transactions import TransactionsResource from ntropy_sdk.v2.ntropy_sdk import DEFAULT_REGION, ALL_REGIONS from ntropy_sdk.webhooks import WebhooksResource @@ -27,6 +28,7 @@ def __init__( self.bank_statements = BankStatementsResource(self) self.account_holders = AccountHoldersResource(self) self.webhooks = WebhooksResource(self) + self.rules = RulesResource(self) def retry_ratelimited_request( self, diff --git a/ntropy_sdk/webhooks.py b/ntropy_sdk/webhooks.py index b068cb8..06c8fc0 100644 --- a/ntropy_sdk/webhooks.py +++ b/ntropy_sdk/webhooks.py @@ -1,6 +1,7 @@ from datetime import datetime -from typing import TYPE_CHECKING, List, Literal, Optional +from typing import List, Literal, Optional, TYPE_CHECKING import uuid + from pydantic import BaseModel, Field from ntropy_sdk.paging import PagedResponse From 70c3b85460a76c6d400f3da228ab2fca74479dc9 Mon Sep 17 00:00:00 2001 From: Matthew Tran <0e4ef622@gmail.com> Date: Mon, 21 Oct 2024 15:51:26 -0500 Subject: [PATCH 21/64] Add sdk.webhooks.patch --- ntropy_sdk/webhooks.py | 43 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/ntropy_sdk/webhooks.py b/ntropy_sdk/webhooks.py index 06c8fc0..542bc5e 100644 --- a/ntropy_sdk/webhooks.py +++ b/ntropy_sdk/webhooks.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Literal, Optional, TYPE_CHECKING +from typing import List, Literal, Optional, TYPE_CHECKING, Union import uuid from pydantic import BaseModel, Field @@ -11,6 +11,13 @@ from ntropy_sdk import SDK from typing_extensions import Unpack + +class _Unset: + pass + + +UNSET = _Unset() + WebhookEventType = Literal[ "reports.resolved", "reports.rejected", @@ -41,6 +48,9 @@ class Webhook(BaseModel): "value will be included in the `X-Ntropy-Token` header when sending " "requests to the webhook", ) + enabled: bool = Field( + description="Whether the webhook is enabled or not.", + ) request_id: Optional[str] = None def delete(self, sdk: "SDK", **extra_kwargs: "Unpack[ExtraKwargs]"): @@ -120,3 +130,34 @@ def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]"): url=f"/v3/webhooks/{id}", **extra_kwargs, ) + + def patch( + self, + id: str, + *, + url: Union[str, _Unset] = UNSET, + events: Union[List[WebhookEventType], _Unset] = UNSET, + token: Union[str, None, _Unset] = UNSET, + enabled: Union[bool, _Unset] = UNSET, + **extra_kwargs: "Unpack[ExtraKwargs]", + ): + payload = {} + if url is not UNSET: + payload["url"] = url + if events is not UNSET: + payload["events"] = events + if token is not UNSET: + payload["token"] = token + if enabled is not UNSET: + payload["enabled"] = enabled + + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + self._sdk.retry_ratelimited_request( + method="PATCH", + url=f"/v3/webhooks/{id}", + payload=payload, + **extra_kwargs, + ) From 44392c073312aa6eed731e155283eb1f7e1656ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Mon, 21 Oct 2024 23:19:06 +0100 Subject: [PATCH 22/64] api_key fixture --- ntropy_sdk/__init__.py | 4 +--- ntropy_sdk/http.py | 1 + ntropy_sdk/sdk.py | 14 ++++++++------ tests/__init__.py | 8 -------- tests/conftest.py | 13 +++++++++++++ tests/v2/conftest.py | 5 ++--- tests/v2/test_recurring_payments.py | 12 ------------ tests/v2/{test_sdk.py => test_v2_sdk.py} | 19 +++++++++---------- tests/v3/conftest.py | 5 ++--- tests/v3/test_sdk.py | 5 ++--- 10 files changed, 38 insertions(+), 48 deletions(-) delete mode 100644 tests/__init__.py create mode 100644 tests/conftest.py rename tests/v2/{test_sdk.py => test_v2_sdk.py} (98%) diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index 3783a97..d7b7dbf 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -38,8 +38,6 @@ class ExtraKwargs(TypedDict, total=False): NtropyRuntimeError, ) -from .transactions import ( - TransactionInput, LocationInput -) +from .transactions import TransactionInput, LocationInput from .account_holders import AccountHolder diff --git a/ntropy_sdk/http.py b/ntropy_sdk/http.py index 6df0892..bc7e7e5 100644 --- a/ntropy_sdk/http.py +++ b/ntropy_sdk/http.py @@ -18,6 +18,7 @@ def _get_session(self) -> requests.Session: if self._session is None: self._session = requests.Session() from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter + self._session.mount("https://", TCPKeepAliveAdapter()) return self._session diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py index 67158c9..f0c157d 100644 --- a/ntropy_sdk/sdk.py +++ b/ntropy_sdk/sdk.py @@ -44,9 +44,11 @@ def retry_ratelimited_request( if self.api_key and not kwargs_copy.get("api_key"): kwargs_copy["api_key"] = self.api_key - return self.http_client.retry_ratelimited_request(method=method, - url=self.base_url + url, - params=params, - payload=payload, - payload_json_str=payload_json_str, - **kwargs_copy) + return self.http_client.retry_ratelimited_request( + method=method, + url=self.base_url + url, + params=params, + payload=payload, + payload_json_str=payload_json_str, + **kwargs_copy, + ) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 197d0e6..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - - -API_KEY = os.environ.get("NTROPY_API_KEY") - - -if not API_KEY: - raise RuntimeError("Environment variable NTROPY_API_KEY is not defined") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6afd8f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import os + +import pytest + + +@pytest.fixture() +def api_key(): + key = os.environ.get("NTROPY_API_KEY") + + if not key: + raise RuntimeError("Environment variable NTROPY_API_KEY is not defined") + + return key diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index e57075f..881a949 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -3,12 +3,11 @@ import pytest as pytest from ntropy_sdk.v2 import SDK -from .. import API_KEY @pytest.fixture -def sdk(): - sdk = SDK(API_KEY) +def sdk(api_key): + sdk = SDK(api_key) url = os.environ.get("NTROPY_API_URL") if url is not None: diff --git a/tests/v2/test_recurring_payments.py b/tests/v2/test_recurring_payments.py index e5a1cc0..11ce374 100644 --- a/tests/v2/test_recurring_payments.py +++ b/tests/v2/test_recurring_payments.py @@ -1,18 +1,6 @@ import os import pytest from ntropy_sdk.v2 import SDK, Transaction -from tests import API_KEY - - -@pytest.fixture -def sdk(): - sdk = SDK(API_KEY) - - url = os.environ.get("NTROPY_API_URL") - if url is not None: - sdk.base_url = url - - return sdk @pytest.fixture diff --git a/tests/v2/test_sdk.py b/tests/v2/test_v2_sdk.py similarity index 98% rename from tests/v2/test_sdk.py rename to tests/v2/test_v2_sdk.py index 205c928..af0893c 100644 --- a/tests/v2/test_sdk.py +++ b/tests/v2/test_v2_sdk.py @@ -17,7 +17,6 @@ from ntropy_sdk.v2.errors import NtropyValueError, NtropyBatchError from ntropy_sdk.utils import TransactionType from ntropy_sdk.v2.ntropy_sdk import ACCOUNT_HOLDER_TYPES -from tests import API_KEY def test_account_holder_type(): @@ -503,7 +502,7 @@ def test_enriched_fields(sdk): # assert 5432 in enriched.mcc -def test_sdk_region(): +def test_sdk_region(api_key): ah_id = str(uuid.uuid4()) tx = Transaction( amount=24.56, @@ -515,28 +514,28 @@ def test_sdk_region(): iso_currency_code="USD", ) - _sdk = SDK(API_KEY) + _sdk = SDK(api_key) assert _sdk.base_url == "https://api.ntropy.com" res = _sdk.add_transactions([tx])[0] assert res.website is not None - _sdk = SDK(API_KEY, region="us") + _sdk = SDK(api_key, region="us") assert _sdk.base_url == "https://api.ntropy.com" res = _sdk.add_transactions([tx])[0] assert res.website is not None - _sdk = SDK(API_KEY, region="eu") + _sdk = SDK(api_key, region="eu") assert _sdk.base_url == "https://api.eu.ntropy.com" res = _sdk.add_transactions([tx])[0] assert res.website is not None with pytest.raises(ValueError): - _sdk = SDK(API_KEY, region="atlantida") + _sdk = SDK(api_key, region="atlantida") @pytest.fixture() -def async_sdk(): - sdk = SDK(API_KEY) +def async_sdk(api_key): + sdk = SDK(api_key) sdk._make_batch = make_batch with patch.object(sdk, "MAX_SYNC_BATCH", 0): @@ -545,8 +544,8 @@ def async_sdk(): @pytest.fixture() -def sync_sdk(): - sdk = SDK(API_KEY) +def sync_sdk(api_key): + sdk = SDK(api_key) sdk._make_batch = lambda batch_status, results: results with patch.object(sdk, "MAX_SYNC_BATCH", 999999): diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py index da41dcd..f4fd923 100644 --- a/tests/v3/conftest.py +++ b/tests/v3/conftest.py @@ -3,12 +3,11 @@ import pytest as pytest from ntropy_sdk import SDK -from .. import API_KEY @pytest.fixture -def sdk(): - sdk = SDK(API_KEY) +def sdk(api_key): + sdk = SDK(api_key) url = os.environ.get("NTROPY_API_URL") if url is not None: diff --git a/tests/v3/test_sdk.py b/tests/v3/test_sdk.py index af532a5..37ff327 100644 --- a/tests/v3/test_sdk.py +++ b/tests/v3/test_sdk.py @@ -1,7 +1,6 @@ import os from itertools import islice from ntropy_sdk import SDK -from .. import API_KEY def test_pagination(sdk: SDK): @@ -12,10 +11,10 @@ def test_pagination(sdk: SDK): assert len(tx_ids) == 10 -def test_readme(): +def test_readme(api_key): readme_file = open( os.path.join(os.path.dirname(__file__), "..", "..", "README.md") ).read() readme_data = readme_file.split("```python")[1].split("```")[0] - readme_data = readme_data.replace("YOUR-API-KEY", API_KEY) + readme_data = readme_data.replace("YOUR-API-KEY", api_key) exec(readme_data, globals()) From 29dc02e212e0039eb78aafc8159e10c1ee061793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Mon, 21 Oct 2024 23:54:40 +0100 Subject: [PATCH 23/64] recurring groups --- ntropy_sdk/account_holders.py | 25 +++++++++++++++++---- ntropy_sdk/transactions.py | 5 +++++ tests/v3/test_sdk.py | 42 ++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py index ab73d40..231c0fb 100644 --- a/ntropy_sdk/account_holders.py +++ b/ntropy_sdk/account_holders.py @@ -1,12 +1,13 @@ +import uuid from datetime import datetime from enum import Enum -from typing import Optional, TYPE_CHECKING, Literal -import uuid +from typing import Optional, TYPE_CHECKING from pydantic import BaseModel, Field -from ntropy_sdk.utils import pydantic_json from ntropy_sdk.paging import PagedResponse +from ntropy_sdk.transactions import RecurrenceGroup, RecurrenceGroups +from ntropy_sdk.utils import pydantic_json if TYPE_CHECKING: from ntropy_sdk import ExtraKwargs @@ -107,4 +108,20 @@ def create( ) return AccountHolder(**resp.json(), request_id=request_id) - # TODO: Recurring groups + def recurring_groups( + self, + id: str, + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> RecurrenceGroups: + request_id = extra_kwargs.get("request_id") + if request_id is None: + request_id = uuid.uuid4().hex + extra_kwargs["request_id"] = request_id + resp = self._sdk.retry_ratelimited_request( + method="POST", + url=f"/v3/account_holders/{id}/recurring_groups", + **extra_kwargs, + ) + return RecurrenceGroups( + groups=[RecurrenceGroup(**r) for r in resp.json()], request_id=request_id + ) diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index 72b77c2..b74a99e 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -237,6 +237,11 @@ class RecurrenceGroup(BaseModel): ) +class RecurrenceGroups(BaseModel): + groups: List[RecurrenceGroup] + request_id: Optional[str] + + class Recurrence(BaseModel): """ The `Recurrence` object represents the recurrence pattern of a transaction. It provides information about diff --git a/tests/v3/test_sdk.py b/tests/v3/test_sdk.py index 37ff327..141547c 100644 --- a/tests/v3/test_sdk.py +++ b/tests/v3/test_sdk.py @@ -1,6 +1,12 @@ import os from itertools import islice -from ntropy_sdk import SDK + +from ntropy_sdk import ( + SDK, + TransactionInput, + AccountHolder, + NtropyValueError, +) def test_pagination(sdk: SDK): @@ -18,3 +24,37 @@ def test_readme(api_key): readme_data = readme_file.split("```python")[1].split("```")[0] readme_data = readme_data.replace("YOUR-API-KEY", api_key) exec(readme_data, globals()) + + +def test_recurrence_groups(sdk): + try: + sdk.account_holders.create( + AccountHolder( + id="Xksd9SWd", + type="consumer", + ) + ) + except NtropyValueError: + pass + + txs = [] + txs.extend( + [ + TransactionInput( + id=f"netflix-{i}", + description=f"Recurring Debit Purchase Card 1350 #{i} netflix.com Netflix.com CA", + amount=17.99, + currency="USD", + entry_type="outgoing", + date=f"2021-0{i}-01", + account_holder_id="Xksd9SWd", + ) + for i in range(1, 5) + ] + ) + + sdk.transactions.create(txs) + recurring_groups = sdk.account_holders.recurring_groups("Xksd9SWd") + + assert recurring_groups.groups[0].counterparty.website == "netflix.com" + assert recurring_groups.groups[0].periodicity == "monthly" From 453ebfb27f6d95b85ee708c402a573990a4e3534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:46:27 +0100 Subject: [PATCH 24/64] rename create --- ntropy_sdk/rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ntropy_sdk/rules.py b/ntropy_sdk/rules.py index 5e68d77..6cc6f7b 100644 --- a/ntropy_sdk/rules.py +++ b/ntropy_sdk/rules.py @@ -16,7 +16,7 @@ class RulesResource: def __init__(self, sdk: "SDK"): self._sdk = sdk - def set( + def create( self, rules: Rules, **extra_kwargs: "Unpack[ExtraKwargs]", From c628f6e54e0e288ceb6e561c228ad3830846052c Mon Sep 17 00:00:00 2001 From: Matthew Tran <0e4ef622@gmail.com> Date: Tue, 22 Oct 2024 12:59:23 -0500 Subject: [PATCH 25/64] Use request id from response --- ntropy_sdk/account_holders.py | 13 +++++++++---- ntropy_sdk/bank_statements.py | 18 +++++++++++++----- ntropy_sdk/batches.py | 14 ++++++++++---- ntropy_sdk/rules.py | 12 ++++++------ ntropy_sdk/transactions.py | 14 ++++++++++---- ntropy_sdk/v2/ntropy_sdk.py | 6 +++--- ntropy_sdk/webhooks.py | 10 +++++++--- 7 files changed, 58 insertions(+), 29 deletions(-) diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py index 231c0fb..15d188c 100644 --- a/ntropy_sdk/account_holders.py +++ b/ntropy_sdk/account_holders.py @@ -67,7 +67,7 @@ def list( ) page = PagedResponse[AccountHolder]( **resp.json(), - request_id=request_id, + request_id=resp.headers.get("x-request-id", request_id), _resource=self, _extra_kwargs=extra_kwargs, ) @@ -87,7 +87,9 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolder: url=f"/v3/account_holders/{id}", **extra_kwargs, ) - return AccountHolder(**resp.json(), request_id=request_id) + return AccountHolder( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def create( self, @@ -106,7 +108,9 @@ def create( payload_json_str=pydantic_json(account_holder), **extra_kwargs, ) - return AccountHolder(**resp.json(), request_id=request_id) + return AccountHolder( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def recurring_groups( self, @@ -123,5 +127,6 @@ def recurring_groups( **extra_kwargs, ) return RecurrenceGroups( - groups=[RecurrenceGroup(**r) for r in resp.json()], request_id=request_id + groups=[RecurrenceGroup(**r) for r in resp.json()], + request_id=resp.headers.get("x-request-id", request_id), ) diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 1b77065..10f617b 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -138,7 +138,7 @@ def list( ) page = PagedResponse[BankStatementJob]( **resp.json(), - request_id=request_id, + request_id=resp.headers.get("x-request-id", request_id), _resource=self, _extra_kwargs=extra_kwargs, ) @@ -166,7 +166,9 @@ def create( }, **extra_kwargs, ) - return BankStatementJob(**resp.json(), request_id=request_id) + return BankStatementJob( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BankStatementJob: request_id = extra_kwargs.get("request_id") @@ -179,7 +181,9 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BankStatementJo payload=None, **extra_kwargs, ) - return BankStatementJob(**resp.json(), request_id=request_id) + return BankStatementJob( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def results( self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]" @@ -194,7 +198,9 @@ def results( payload=None, **extra_kwargs, ) - return BankStatementResults(**resp.json(), request_id=request_id) + return BankStatementResults( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def overview(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementInfo: """Waits for and returns preliminary statement information from the @@ -210,4 +216,6 @@ def overview(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementI payload=None, **extra_kwargs, ) - return StatementInfo(**resp.json(), request_id=request_id) + return StatementInfo( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py index e32663e..ef21f5e 100644 --- a/ntropy_sdk/batches.py +++ b/ntropy_sdk/batches.py @@ -127,7 +127,7 @@ def list( ) page = PagedResponse[Batch]( **resp.json(), - request_id=request_id, + request_id=resp.headers.get("x-request-id", request_id), _resource=self, _extra_kwargs=extra_kwargs, ) @@ -147,7 +147,9 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Batch: url=f"/v3/batches/{id}", **extra_kwargs, ) - return Batch(**resp.json(), request_id=request_id) + return Batch( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def create( self, @@ -166,7 +168,9 @@ def create( payload_json_str=pydantic_json(EnrichmentInput(transactions=transactions)), **extra_kwargs, ) - return Batch(**resp.json(), request_id=request_id) + return Batch( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def results(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BatchResult: request_id = extra_kwargs.get("request_id") @@ -178,4 +182,6 @@ def results(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BatchResult url=f"/v3/batches/{id}/results", **extra_kwargs, ) - return BatchResult(**resp.json(), request_id=request_id) + return BatchResult( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) diff --git a/ntropy_sdk/rules.py b/ntropy_sdk/rules.py index 6cc6f7b..a5940ae 100644 --- a/ntropy_sdk/rules.py +++ b/ntropy_sdk/rules.py @@ -18,7 +18,7 @@ def __init__(self, sdk: "SDK"): def create( self, - rules: Rules, + rule: Rule, **extra_kwargs: "Unpack[ExtraKwargs]", ): request_id = extra_kwargs.get("request_id") @@ -28,7 +28,7 @@ def create( self._sdk.retry_ratelimited_request( method="POST", url="/v3/rules", - payload=rules, + payload=rule, **extra_kwargs, ) @@ -47,9 +47,9 @@ def get( ) return resp.json() - def append( + def replace( self, - rule: Rule, + rules: Rules, **extra_kwargs: "Unpack[ExtraKwargs]", ): request_id = extra_kwargs.get("request_id") @@ -58,8 +58,8 @@ def append( extra_kwargs["request_id"] = request_id self._sdk.retry_ratelimited_request( method="POST", - url="/v3/rules/append", - payload=rule, + url="/v3/rules/replace", + payload=rules, **extra_kwargs, ) diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index b74a99e..00025bb 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -341,7 +341,7 @@ def list( ) page = PagedResponse[Transaction]( **resp.json(), - request_id=request_id, + request_id=resp.headers.get("x-request-id", request_id), _resource=self, _extra_kwargs=extra_kwargs, ) @@ -362,7 +362,9 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Transaction: payload=None, **extra_kwargs, ) - return Transaction(**resp.json(), request_id=request_id) + return Transaction( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def create( self, @@ -381,7 +383,9 @@ def create( payload_json_str=pydantic_json(EnrichmentInput(transactions=transactions)), **extra_kwargs, ) - return EnrichmentResult(**resp.json(), request_id=request_id) + return EnrichmentResult( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def assign( self, @@ -403,4 +407,6 @@ def assign( }, **extra_kwargs, ) - return Transaction(**resp.json(), request_id=request_id) + return Transaction( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) diff --git a/ntropy_sdk/v2/ntropy_sdk.py b/ntropy_sdk/v2/ntropy_sdk.py index 347bc9b..9b87d22 100644 --- a/ntropy_sdk/v2/ntropy_sdk.py +++ b/ntropy_sdk/v2/ntropy_sdk.py @@ -1048,13 +1048,13 @@ def statement_info(self) -> StatementInfo: """ url = f"/datasources/bank_statements/{self.bs_id}/statement-info" request_id = uuid.uuid4().hex - json_resp = self.sdk.retry_ratelimited_request( + resp = self.sdk.retry_ratelimited_request( "GET", url, None, request_id=request_id, - ).json() - return StatementInfo(**json_resp, request_id=request_id) + ) + return StatementInfo(**resp.json(), request_id=resp.headers.get("x-request-id", request_id)) def poll(self) -> BankStatement: """Polls the current bank statement status and returns the server response diff --git a/ntropy_sdk/webhooks.py b/ntropy_sdk/webhooks.py index 542bc5e..105ccf9 100644 --- a/ntropy_sdk/webhooks.py +++ b/ntropy_sdk/webhooks.py @@ -76,7 +76,7 @@ def list( ) page = PagedResponse[Webhook]( **resp.json(), - request_id=request_id, + request_id=resp.headers.get("x-request-id", request_id), _resource=self, _extra_kwargs=extra_kwargs, ) @@ -106,7 +106,9 @@ def create( }, **extra_kwargs, ) - return Webhook(**resp.json(), request_id=request_id) + return Webhook( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Webhook: request_id = extra_kwargs.get("request_id") @@ -118,7 +120,9 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Webhook: url=f"/v3/webhooks/{id}", **extra_kwargs, ) - return Webhook(**resp.json(), request_id=request_id) + return Webhook( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]"): request_id = extra_kwargs.get("request_id") From bb44a54cb8345fb3511701521b4bd8e2273f19c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:02:26 +0100 Subject: [PATCH 26/64] remove group_id --- ntropy_sdk/transactions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index 00025bb..5537baf 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -252,9 +252,6 @@ class Recurrence(BaseModel): description="Whether the transaction is a one-time transfer `one-off`, regularly with varying pricing " "`recurring` or with fixed pricing `subscription`", ) - group_id: Optional[str] = Field( - None, description="If the transaction is recurrent, the group it belongs to." - ) class TransactionErrorCode(str, enum.Enum): From d49401d90c29ade8fe4f93d4bb1cab233df3fae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 23:32:38 +0100 Subject: [PATCH 27/64] bank_statements: add error --- ntropy_sdk/bank_statements.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 10f617b..34e250f 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -8,7 +8,11 @@ from pydantic import BaseModel, Field, NonNegativeFloat from ntropy_sdk.v2.bank_statements import StatementInfo -from ntropy_sdk.v2.errors import NtropyDatasourceError +from ntropy_sdk.v2.errors import ( + NtropyDatasourceError, + NtropyTimeoutError, + NtropyBankStatementError, +) from ntropy_sdk.utils import EntryType from ntropy_sdk.paging import PagedResponse @@ -29,6 +33,11 @@ class BankStatementFile(BaseModel): size: Optional[int] +class BankStatementError(BaseModel): + code: str + message: str + + class BankStatementJob(BaseModel): id: str name: Optional[str] @@ -36,6 +45,7 @@ class BankStatementJob(BaseModel): created_at: datetime file: BankStatementFile request_id: Optional[str] = None + error: BankStatementError | None = None def is_completed(self): return self.status == BankStatementJobStatus.COMPLETED From 4597e52cbd1c7ce9c7920850b2f1f4a738f01bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 23:32:50 +0100 Subject: [PATCH 28/64] bank_statements: fix schema --- ntropy_sdk/bank_statements.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 34e250f..1c6f718 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -90,11 +90,11 @@ class BankStatementTransaction(BaseModel): entry_type: EntryType amount: NonNegativeFloat running_balance: Optional[float] - iso_currency_code: str = Field( + currency: str = Field( description="The currency of the transaction in ISO 4217 format" ) description: str - transaction_id: str = Field( + id: str = Field( description="A generated unique identifier for the transaction", min_length=1 ) @@ -109,9 +109,10 @@ class BankStatementAccount(BaseModel): total_incoming: Optional[float] total_outgoing: Optional[float] transactions: List[BankStatementTransaction] + request_id: Optional[str] = None -class BankStatementResults(BankStatementJob): +class BankStatementResults(BaseModel): accounts: List[BankStatementAccount] From 6e62863ccc3f1d432ca9e1a2f32d85ada7f5392c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 23:33:11 +0100 Subject: [PATCH 29/64] wait_for_results --- ntropy_sdk/bank_statements.py | 62 +++++++++++++++++++---------------- ntropy_sdk/batches.py | 28 ++++++++++++++++ ntropy_sdk/v2/errors.py | 18 ++++++++-- 3 files changed, 78 insertions(+), 30 deletions(-) diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 1c6f718..1bbeec1 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -53,34 +53,6 @@ def is_completed(self): def is_error(self): return self.status == BankStatementJobStatus.ERROR - def wait_for_results( - self, - sdk: "SDK", - *, - timeout: int = 4 * 60 * 60, - poll_interval: int = 10, - **extra_kwargs: "Unpack[ExtraKwargs]", - ) -> "BankStatementResults": - """Continuously polls the status of this job, blocking until the job either succeeds - or fails. If the job is successful, returns the results. Otherwise, raises an - `NtropyDatasourceError` exception.""" - - finish_statuses = [ - BankStatementJobStatus.COMPLETED, - BankStatementJobStatus.ERROR, - ] - start_time = time.monotonic() - while time.monotonic() - start_time < timeout: - self.status = sdk.bank_statements.get(id=self.id).status - if self.status in finish_statuses: - break - time.sleep(poll_interval) - - if self.is_completed(): - return sdk.bank_statements.results(id=self.id, **extra_kwargs) - else: - raise NtropyDatasourceError() - class Config: extra = "allow" @@ -230,3 +202,37 @@ def overview(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementI return StatementInfo( **resp.json(), request_id=resp.headers.get("x-request-id", request_id) ) + + def wait_for_results( + self, + id: str, + *, + timeout: int = 10 * 60 * 60, + poll_interval: int = 10, + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> "BankStatementResults": + """Continuously polls the status of this job, blocking until the job either succeeds + or fails. If the job is successful, returns the results. Otherwise, raises an + `NtropyBankStatementError` on a bank statement processing error or `NtropyTimeoutError` + if the `timeout` is exceeded.""" + + finish_statuses = [ + BankStatementJobStatus.COMPLETED, + BankStatementJobStatus.ERROR, + ] + start_time = time.monotonic() + stmt = None + while time.monotonic() - start_time < timeout: + stmt = self._sdk.bank_statements.get(id=id) + if stmt.status in finish_statuses: + break + time.sleep(poll_interval) + + if stmt and stmt.status not in finish_statuses: + raise NtropyTimeoutError() + if stmt.is_error(): + assert stmt.error is not None + raise NtropyBankStatementError( + id=stmt.id, code=stmt.error.code, message=stmt.error.message + ) + return self._sdk.bank_statements.results(id=id, **extra_kwargs) diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py index ef21f5e..c8e9423 100644 --- a/ntropy_sdk/batches.py +++ b/ntropy_sdk/batches.py @@ -185,3 +185,31 @@ def results(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> BatchResult return BatchResult( **resp.json(), request_id=resp.headers.get("x-request-id", request_id) ) + + def wait_for_results( + self, + id: str, + *, + timeout: int = 10 * 60 * 60, + poll_interval: int = 10, + **extra_kwargs: "Unpack[ExtraKwargs]", + ) -> "BatchResult": + """Continuously polls the status of this batch, blocking until the batch + either succeeds or fails. Raises `NtropyTimeoutError` if the `timeout` is exceeded or `NtropyBatchError` + if the batch contains errors.""" + + finish_statuses = [BatchStatus.COMPLETED, BatchStatus.ERROR] + start_time = time.monotonic() + + batch = None + while time.monotonic() - start_time < timeout: + batch = self._sdk.batches.get(id=id) + if batch.status in finish_statuses: + break + time.sleep(poll_interval) + + if batch and batch.status not in finish_statuses: + raise NtropyTimeoutError() + if batch.is_error(): + raise NtropyBatchError("Some transactions contain errors", id=batch.id) + return self._sdk.batches.results(id=id, **extra_kwargs) diff --git a/ntropy_sdk/v2/errors.py b/ntropy_sdk/v2/errors.py index 10d5e91..f3840a6 100644 --- a/ntropy_sdk/v2/errors.py +++ b/ntropy_sdk/v2/errors.py @@ -14,9 +14,9 @@ def __str__(self): class NtropyBatchError(Exception): """One or more errors in one or more transactions of a submitted transaction batch""" - def __init__(self, message, batch_id=None, errors=None): + def __init__(self, message, id=None, errors=None): super().__init__(message) - self.batch_id = batch_id + self.id = id self.errors = errors @@ -33,6 +33,20 @@ def __str__(self): return f"{self.DESCRIPTION}: {self.error_code}: {self.error}" +class NtropyBankStatementError(Exception): + """Errors in processing underlying document""" + + DESCRIPTION = "Error processing submitted document" + + def __init__(self, id: str, code, message): + self.id = id + self.code = code + self.message = message + + def __str__(self): + return f"{self.DESCRIPTION}: {self.code}: {self.message}" + + class NtropyTimeoutError(NtropyError): DESCRIPTION = "Operation timed out" From 3b21cdc44c56e35e9d294aff8eb3343c287f765d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 23:33:26 +0100 Subject: [PATCH 30/64] fixes --- ntropy_sdk/bank_statements.py | 2 +- ntropy_sdk/batches.py | 29 ++++------------------------- ntropy_sdk/transactions.py | 2 -- 3 files changed, 5 insertions(+), 28 deletions(-) diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 1bbeec1..169fc79 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -194,7 +194,7 @@ def overview(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementI request_id = uuid.uuid4().hex extra_kwargs["request_id"] = request_id resp = self._sdk.retry_ratelimited_request( - method="GET", + method="POST", url=f"/v3/bank_statements/{id}/overview", payload=None, **extra_kwargs, diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py index c8e9423..f6b0c23 100644 --- a/ntropy_sdk/batches.py +++ b/ntropy_sdk/batches.py @@ -16,7 +16,7 @@ ) if TYPE_CHECKING: - from ntropy_sdk import ExtraKwargs + from ntropy_sdk import ExtraKwargs, NtropyTimeoutError from ntropy_sdk import SDK from typing_extensions import Unpack @@ -50,30 +50,9 @@ def is_completed(self): def is_error(self): return self.status == BatchStatus.ERROR - def wait_for_results( - self, - sdk: "SDK", - *, - timeout: int = 4 * 60 * 60, - poll_interval: int = 10, - **extra_kwargs: "Unpack[ExtraKwargs]", - ) -> "BatchResult": - """Continuously polls the status of this batch, blocking until the batch - either succeeds or fails. If successful, returns the results. Otherwise, - raises an `NtropyBatchError` exception.""" - - finish_statuses = [BatchStatus.COMPLETED, BatchStatus.ERROR] - start_time = time.monotonic() - while time.monotonic() - start_time < timeout: - self.status = sdk.v3.batches.get(id=self.id).status - if self.status in finish_statuses: - break - time.sleep(poll_interval) - if self.is_completed(): - return sdk.v3.batches.results(id=self.id, **extra_kwargs) - else: - raise NtropyBatchError(f"Batch[{self.id}] contains errors") +class EnrichmentResult(BaseModel): + transactions: List[EnrichedTransaction] class BatchResult(BaseModel): @@ -87,7 +66,7 @@ class BatchResult(BaseModel): description="The total number of transactions in the batch result." ) status: BatchStatus = Field(description="The current status of the batch job.") - results: List[EnrichedTransaction] = Field( + results: EnrichmentResult = Field( description="A list of enriched transactions resulting from the enrichment of this batch." ) request_id: Optional[str] = None diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index 5537baf..4d6d14c 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -256,8 +256,6 @@ class Recurrence(BaseModel): class TransactionErrorCode(str, enum.Enum): ACCOUNT_HOLDER_NOT_FOUND = "account_holder_not_found" - UNSUPPORTED_CURRENCY = "unsupported_currency" - UNSUPPORTED_COUNTRY = "unsupported_country" INTERNAL_ERROR = "internal_error" From 68ed9ff23be8f57483a2531e0c377e250d650dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 23:49:37 +0100 Subject: [PATCH 31/64] optional --- ntropy_sdk/bank_statements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py index 169fc79..7c37e54 100644 --- a/ntropy_sdk/bank_statements.py +++ b/ntropy_sdk/bank_statements.py @@ -45,7 +45,7 @@ class BankStatementJob(BaseModel): created_at: datetime file: BankStatementFile request_id: Optional[str] = None - error: BankStatementError | None = None + error: Optional[BankStatementError] = None def is_completed(self): return self.status == BankStatementJobStatus.COMPLETED From 8f89745b3f84e852c0fab575d15137f688ed22d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 23:57:53 +0100 Subject: [PATCH 32/64] versions --- ntropy_sdk/__init__.py | 2 +- ntropy_sdk/v2/ntropy_sdk.py | 4 +++- ntropy_sdk/version.py | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index d7b7dbf..56410d9 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -1,6 +1,6 @@ from .version import VERSION -__version__ = VERSION +__version__ = "4.26.0" from typing import TYPE_CHECKING, Optional import requests diff --git a/ntropy_sdk/v2/ntropy_sdk.py b/ntropy_sdk/v2/ntropy_sdk.py index 9b87d22..ab761ad 100644 --- a/ntropy_sdk/v2/ntropy_sdk.py +++ b/ntropy_sdk/v2/ntropy_sdk.py @@ -1054,7 +1054,9 @@ def statement_info(self) -> StatementInfo: None, request_id=request_id, ) - return StatementInfo(**resp.json(), request_id=resp.headers.get("x-request-id", request_id)) + return StatementInfo( + **resp.json(), request_id=resp.headers.get("x-request-id", request_id) + ) def poll(self) -> BankStatement: """Polls the current bank statement status and returns the server response diff --git a/ntropy_sdk/version.py b/ntropy_sdk/version.py index 3486778..ba908e1 100644 --- a/ntropy_sdk/version.py +++ b/ntropy_sdk/version.py @@ -1 +1,3 @@ -VERSION = "4.26.0" +from ntropy_sdk import __version__ + +VERSION = __version__ From 504710fce2e4efdb04a341510eb953e32e2bebdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Santos?= <17305792+adgsantos@users.noreply.github.com> Date: Tue, 22 Oct 2024 23:57:55 +0100 Subject: [PATCH 33/64] =?UTF-8?q?Bump=20version:=204.26.0=20=E2=86=92=205.?= =?UTF-8?q?0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ntropy_sdk/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index 56410d9..1d8e604 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -1,6 +1,6 @@ from .version import VERSION -__version__ = "4.26.0" +__version__ = "5.0.0" from typing import TYPE_CHECKING, Optional import requests diff --git a/setup.cfg b/setup.cfg index ac4a9af..849391a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.26.0 +current_version = 5.0.0 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)(rc(?P
\d+))?
diff --git a/setup.py b/setup.py
index d50df9d..6628867 100644
--- a/setup.py
+++ b/setup.py
@@ -54,6 +54,6 @@
     test_suite="tests",
     tests_require=test_requirements,
     url="https://github.com/ntropy-network/ntropy-sdk",
-    version="4.26.0",
+    version="5.0.0",
     zip_safe=False,
 )

From 10523fadc3cbf75f547cdf28c3d28874a591fab5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Wed, 23 Oct 2024 00:02:08 +0100
Subject: [PATCH 34/64] =?UTF-8?q?Bump=20version:=205.0.0=20=E2=86=92=205.0?=
 =?UTF-8?q?.0rc1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 ntropy_sdk/__init__.py | 2 +-
 setup.cfg              | 2 +-
 setup.py               | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py
index 1d8e604..387c64f 100644
--- a/ntropy_sdk/__init__.py
+++ b/ntropy_sdk/__init__.py
@@ -1,6 +1,6 @@
 from .version import VERSION
 
-__version__ = "5.0.0"
+__version__ = "5.0.0rc1"
 
 from typing import TYPE_CHECKING, Optional
 import requests
diff --git a/setup.cfg b/setup.cfg
index 849391a..8043785 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 5.0.0
+current_version = 5.0.0rc1
 commit = True
 tag = True
 parse = (?P\d+)\.(?P\d+)\.(?P\d+)(rc(?P
\d+))?
diff --git a/setup.py b/setup.py
index 6628867..36a246f 100644
--- a/setup.py
+++ b/setup.py
@@ -54,6 +54,6 @@
     test_suite="tests",
     tests_require=test_requirements,
     url="https://github.com/ntropy-network/ntropy-sdk",
-    version="5.0.0",
+    version="5.0.0rc1",
     zip_safe=False,
 )

From e5af925c3bdd2c4beeb71d9bb40905a970884198 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Wed, 23 Oct 2024 00:48:11 +0100
Subject: [PATCH 35/64] version

---
 ntropy_sdk/__init__.py | 2 --
 ntropy_sdk/http.py     | 2 +-
 2 files changed, 1 insertion(+), 3 deletions(-)

diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py
index 387c64f..a02745e 100644
--- a/ntropy_sdk/__init__.py
+++ b/ntropy_sdk/__init__.py
@@ -1,5 +1,3 @@
-from .version import VERSION
-
 __version__ = "5.0.0rc1"
 
 from typing import TYPE_CHECKING, Optional
diff --git a/ntropy_sdk/http.py b/ntropy_sdk/http.py
index bc7e7e5..faffdeb 100644
--- a/ntropy_sdk/http.py
+++ b/ntropy_sdk/http.py
@@ -6,7 +6,7 @@
 
 import requests
 
-from ntropy_sdk import VERSION
+from ntropy_sdk.version import VERSION
 from ntropy_sdk.v2.errors import error_from_http_status_code, NtropyError
 
 

From 1186dfeb9bf630f477bece6142c125631f0fc040 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Thu, 24 Oct 2024 17:32:01 +0100
Subject: [PATCH 36/64] add transaction ids

---
 ntropy_sdk/transactions.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index 4d6d14c..f285426 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -235,6 +235,9 @@ class RecurrenceGroup(BaseModel):
     categories: Categories = Field(
         description="Categories of the transactions in the recurrence group"
     )
+    transaction_ids: List[str] = Field(
+        description="Transactions that belong to the group"
+    )
 
 
 class RecurrenceGroups(BaseModel):

From 53dc758f68dadcc806c9fe4a1940bd7c0a9ca611 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Fri, 25 Oct 2024 12:47:25 +0100
Subject: [PATCH 37/64] update webhook

---
 ntropy_sdk/webhooks.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ntropy_sdk/webhooks.py b/ntropy_sdk/webhooks.py
index 105ccf9..70f5afb 100644
--- a/ntropy_sdk/webhooks.py
+++ b/ntropy_sdk/webhooks.py
@@ -24,7 +24,7 @@ class _Unset:
     "reports.pending",
     "bank_statements.processing",
     "bank_statements.processed",
-    "bank_statements.failed",
+    "bank_statements.error",
     "batches.completed",
     "batches.error",
 ]

From cf3f531e9fc19d3b43bd22d7a363207bc38b97dd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Fri, 25 Oct 2024 14:50:46 +0100
Subject: [PATCH 38/64] added categories

---
 ntropy_sdk/categories.py | 62 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 62 insertions(+)
 create mode 100644 ntropy_sdk/categories.py

diff --git a/ntropy_sdk/categories.py b/ntropy_sdk/categories.py
new file mode 100644
index 0000000..781b070
--- /dev/null
+++ b/ntropy_sdk/categories.py
@@ -0,0 +1,62 @@
+from typing import TYPE_CHECKING
+import uuid
+
+from ntropy_sdk.account_holders import AccountHolderType
+
+if TYPE_CHECKING:
+    from ntropy_sdk import ExtraKwargs
+    from ntropy_sdk import SDK
+    from typing_extensions import Unpack
+
+
+class CategoriesResource:
+    def __init__(self, sdk: "SDK"):
+        self._sdk = sdk
+
+    def get(
+        self,
+        account_holder_type: AccountHolderType,
+        **extra_kwargs: "Unpack[ExtraKwargs]",
+    ) -> dict:
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        resp = self._sdk.retry_ratelimited_request(
+            method="GET",
+            url=f"/v3/categories/{account_holder_type.value}",
+            **extra_kwargs,
+        )
+        return resp.json()
+
+    def set(
+        self,
+        account_holder_type: AccountHolderType,
+        categories: dict,
+        **extra_kwargs: "Unpack[ExtraKwargs]",
+    ):
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        self._sdk.retry_ratelimited_request(
+            method="POST",
+            url=f"/v3/categories/{account_holder_type.value}",
+            json=categories,
+            **extra_kwargs,
+        )
+
+    def reset(
+        self,
+        account_holder_type: AccountHolderType,
+        **extra_kwargs: "Unpack[ExtraKwargs]",
+    ):
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        self._sdk.retry_ratelimited_request(
+            method="POST",
+            url=f"/v3/categories/{account_holder_type.value}/reset",
+            **extra_kwargs,
+        )

From 8728185dd5f31a77bb32dcdb53e9e6a30f3e6076 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 28 Oct 2024 15:46:16 +0000
Subject: [PATCH 39/64] widen mcc validation

---
 ntropy_sdk/v2/ntropy_sdk.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ntropy_sdk/v2/ntropy_sdk.py b/ntropy_sdk/v2/ntropy_sdk.py
index ab761ad..9abbcc0 100644
--- a/ntropy_sdk/v2/ntropy_sdk.py
+++ b/ntropy_sdk/v2/ntropy_sdk.py
@@ -148,7 +148,7 @@ class Transaction(BaseModel):
     )
     mcc: Optional[int] = Field(
         None,
-        ge=700,
+        ge=0,
         le=9999,
         description="The Merchant Category Code of the merchant, according to ISO 18245.",
     )

From 69ef4a2e646c3864b9736caa313a3431956ac972 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 28 Oct 2024 15:46:23 +0000
Subject: [PATCH 40/64] remove intermediary type

---
 ntropy_sdk/transactions.py | 8 +-------
 1 file changed, 1 insertion(+), 7 deletions(-)

diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index f285426..7f52c35 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -148,14 +148,8 @@ class Counterparty(Entity):
     type: CounterpartyType
 
 
-class IntermediaryType(str, enum.Enum):
-    DELIVERY_SERVICE = "delivery_service"
-    PAYMENT_PROCESSOR = "payment_processor"
-    MARKETPLACE = "marketplace"
-
-
 class Intermediary(Entity):
-    type: IntermediaryType
+    ...
 
 
 class Entities(BaseModel):

From 11ab649b63c3228e9e880a1056fdf08ece6d7e98 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 28 Oct 2024 19:27:32 +0000
Subject: [PATCH 41/64] update schemas

---
 README.md                     | 28 +++++++++++-------------
 ntropy_sdk/account_holders.py | 12 ++++++++--
 ntropy_sdk/batches.py         | 31 ++++++++++++--------------
 ntropy_sdk/paging.py          |  3 +--
 ntropy_sdk/transactions.py    | 41 ++++++++++++++++++++---------------
 requirements_dev.txt          |  2 +-
 tests/v3/test_sdk.py          | 34 +++++++++++------------------
 7 files changed, 76 insertions(+), 75 deletions(-)

diff --git a/README.md b/README.md
index a74c8fa..4198163 100644
--- a/README.md
+++ b/README.md
@@ -17,23 +17,21 @@ $ python3 -m pip install --upgrade 'ntropy-sdk'
 Enriching your first transaction requires an `SDK` object and an input `Transaction` object. The API key can be set in the environment variable `NTROPY_API_KEY` or in the `SDK` constructor:
 
 ```python
-from ntropy_sdk import SDK, TransactionInput, LocationInput
+from ntropy_sdk import SDK
 
 sdk = SDK("YOUR-API-KEY")
-r = sdk.transactions.create([
-    TransactionInput(
-        id = "4yp49x3tbj9mD8DB4fM8DDY6Yxbx8YP14g565Xketw3tFmn",
-        description = "AMAZON WEB SERVICES",
-        entry_type = "outgoing",
-        amount = 12042.37,
-        currency = "USD",
-        date = "2021-11-01",
-        location = LocationInput(
-            country="US"
-        ),
-    )
-])
-print(r.transactions[0].entities.counterparty)
+r = sdk.transactions.create(
+    id="4yp49x3tbj9mD8DB4fM8DDY6Yxbx8YP14g565Xketw3tFmn",
+    description="AMAZON WEB SERVICES",
+    entry_type="outgoing",
+    amount=12042.37,
+    currency="USD",
+    date="2021-11-01",
+    location=dict(
+        country="US"
+    ),
+)
+print(r)
 ```
 
 The returned `EnrichedTransaction` contains the added information by Ntropy API.  You can consult the Enrichment section of the documentation for more information on the parameters for both `Transaction` and `EnrichedTransaction`.
diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py
index 15d188c..826e1ed 100644
--- a/ntropy_sdk/account_holders.py
+++ b/ntropy_sdk/account_holders.py
@@ -93,7 +93,9 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolder:
 
     def create(
         self,
-        account_holder: AccountHolder,
+        id: str,
+        type: AccountHolderType,
+        name: Optional[str] = None,
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ) -> AccountHolder:
         """Create an account holder"""
@@ -105,7 +107,13 @@ def create(
         resp = self._sdk.retry_ratelimited_request(
             method="POST",
             url="/v3/account_holders",
-            payload_json_str=pydantic_json(account_holder),
+            payload_json_str=pydantic_json(
+                AccountHolder(
+                    id=id,
+                    type=type,
+                    name=name,
+                )
+            ),
             **extra_kwargs,
         )
         return AccountHolder(
diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py
index f6b0c23..249bf67 100644
--- a/ntropy_sdk/batches.py
+++ b/ntropy_sdk/batches.py
@@ -1,19 +1,16 @@
+import time
+import uuid
 from datetime import datetime
 from enum import Enum
-import time
 from typing import List, Optional, TYPE_CHECKING
-import uuid
 
 from pydantic import BaseModel, Field
 
-from ntropy_sdk.v2 import NtropyBatchError
-from ntropy_sdk.utils import pydantic_json
 from ntropy_sdk.paging import PagedResponse
 from ntropy_sdk.transactions import (
     EnrichedTransaction,
-    EnrichmentInput,
-    TransactionInput,
 )
+from ntropy_sdk.v2 import NtropyBatchError
 
 if TYPE_CHECKING:
     from ntropy_sdk import ExtraKwargs, NtropyTimeoutError
@@ -33,6 +30,7 @@ class Batch(BaseModel):
     """
 
     id: str = Field(description="A unique identifier for the batch.")
+    operation: str = Field(description="Operation for the batch")
     status: BatchStatus = Field(description="The current status of the batch.")
     created_at: datetime = Field(
         description="The timestamp of when the batch was created."
@@ -40,8 +38,8 @@ class Batch(BaseModel):
     updated_at: datetime = Field(
         description="The timestamp of when the batch was last updated."
     )
-    progress: int = Field(description="The number of transactions processed so far.")
-    total: int = Field(description="The total number of transactions in the batch.")
+    progress: int = Field(description="The number of requests processed so far.")
+    total: int = Field(description="The total number of requests in the batch.")
     request_id: Optional[str] = None
 
     def is_completed(self):
@@ -51,14 +49,9 @@ def is_error(self):
         return self.status == BatchStatus.ERROR
 
 
-class EnrichmentResult(BaseModel):
-    transactions: List[EnrichedTransaction]
-
-
 class BatchResult(BaseModel):
     """
-    The `BatchResult` object represents the result of a batch enrichment job, including its status and
-    enriched transactions.
+    The `BatchResult` object represents the result of a batch enrichment job
     """
 
     id: str = Field(description="A unique identifier for the batch.")
@@ -66,7 +59,7 @@ class BatchResult(BaseModel):
         description="The total number of transactions in the batch result."
     )
     status: BatchStatus = Field(description="The current status of the batch job.")
-    results: EnrichmentResult = Field(
+    results: list[EnrichedTransaction] = Field(
         description="A list of enriched transactions resulting from the enrichment of this batch."
     )
     request_id: Optional[str] = None
@@ -132,7 +125,8 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Batch:
 
     def create(
         self,
-        transactions: List[TransactionInput],
+        operation: str,
+        data: List[dict],
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ) -> Batch:
         """Submit a batch of transactions for enrichment"""
@@ -144,7 +138,10 @@ def create(
         resp = self._sdk.retry_ratelimited_request(
             method="POST",
             url="/v3/batches",
-            payload_json_str=pydantic_json(EnrichmentInput(transactions=transactions)),
+            payload_json={
+                "operation": operation,
+                "data": data,
+            },
             **extra_kwargs,
         )
         return Batch(
diff --git a/ntropy_sdk/paging.py b/ntropy_sdk/paging.py
index f330bcf..a2e2e58 100644
--- a/ntropy_sdk/paging.py
+++ b/ntropy_sdk/paging.py
@@ -34,8 +34,7 @@ def list(
         cursor: str,
         limit: Optional[int],
         **extra_kwargs: "Unpack[ExtraKwargs]",
-    ) -> "PagedResponse[T]":
-        ...
+    ) -> "PagedResponse[T]": ...
 
 
 class PagedResponse(GenericModel, Generic[T]):
diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index 7f52c35..644dc08 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -148,8 +148,7 @@ class Counterparty(Entity):
     type: CounterpartyType
 
 
-class Intermediary(Entity):
-    ...
+class Intermediary(Entity): ...
 
 
 class Entities(BaseModel):
@@ -229,9 +228,6 @@ class RecurrenceGroup(BaseModel):
     categories: Categories = Field(
         description="Categories of the transactions in the recurrence group"
     )
-    transaction_ids: List[str] = Field(
-        description="Transactions that belong to the group"
-    )
 
 
 class RecurrenceGroups(BaseModel):
@@ -276,12 +272,7 @@ class EnrichedTransaction(_EnrichedTransactionBase):
     )
 
 
-class EnrichmentInput(BaseModel):
-    transactions: List[TransactionInput]
-
-
-class EnrichmentResult(BaseModel):
-    transactions: List[EnrichedTransaction]
+class EnrichedTransactionResponse(EnrichedTransaction):
     request_id: Optional[str] = None
 
 
@@ -360,11 +351,16 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Transaction:
 
     def create(
         self,
-        transactions: List[TransactionInput],
+        id: str,
+        description: str,
+        date: str,
+        amount: int,
+        entry_type: str,
+        currency: str,
+        account_holder_id: Optional[str] = None,
+        location: Optional[dict] = None,
         **extra_kwargs: "Unpack[ExtraKwargs]",
-    ) -> EnrichmentResult:
-        """Synchronously enrich transactions"""
-
+    ):
         request_id = extra_kwargs.get("request_id")
         if request_id is None:
             request_id = uuid.uuid4().hex
@@ -372,10 +368,21 @@ def create(
         resp = self._sdk.retry_ratelimited_request(
             method="POST",
             url="/v3/transactions",
-            payload_json_str=pydantic_json(EnrichmentInput(transactions=transactions)),
+            payload_json_str=pydantic_json(
+                TransactionInput(
+                    id=id,
+                    description=description,
+                    date=date,
+                    amount=amount,
+                    entry_type=entry_type,
+                    currency=currency,
+                    account_holder_id=account_holder_id,
+                    location=location,
+                )
+            ),
             **extra_kwargs,
         )
-        return EnrichmentResult(
+        return EnrichedTransactionResponse(
             **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
         )
 
diff --git a/requirements_dev.txt b/requirements_dev.txt
index ac0c00d..b2fc590 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -7,4 +7,4 @@ tox==3.14.0
 coverage==4.5.4
 Sphinx==1.8.5
 twine==1.14.0
-requests_toolbelt==0.9.1
+requests_toolbelt==1.0.0
diff --git a/tests/v3/test_sdk.py b/tests/v3/test_sdk.py
index 141547c..c566fbe 100644
--- a/tests/v3/test_sdk.py
+++ b/tests/v3/test_sdk.py
@@ -29,31 +29,23 @@ def test_readme(api_key):
 def test_recurrence_groups(sdk):
     try:
         sdk.account_holders.create(
-            AccountHolder(
-                id="Xksd9SWd",
-                type="consumer",
-            )
+            id="Xksd9SWd",
+            type="consumer",
         )
     except NtropyValueError:
         pass
 
-    txs = []
-    txs.extend(
-        [
-            TransactionInput(
-                id=f"netflix-{i}",
-                description=f"Recurring Debit Purchase Card 1350 #{i} netflix.com Netflix.com CA",
-                amount=17.99,
-                currency="USD",
-                entry_type="outgoing",
-                date=f"2021-0{i}-01",
-                account_holder_id="Xksd9SWd",
-            )
-            for i in range(1, 5)
-        ]
-    )
-
-    sdk.transactions.create(txs)
+    for i in range(1, 5):
+        sdk.transactions.create(
+            id=f"netflix-{i}",
+            description=f"Recurring Debit Purchase Card 1350 #{i} netflix.com Netflix.com CA",
+            amount=17.99,
+            currency="USD",
+            entry_type="outgoing",
+            date=f"2021-0{i}-01",
+            account_holder_id="Xksd9SWd",
+        )
+
     recurring_groups = sdk.account_holders.recurring_groups("Xksd9SWd")
 
     assert recurring_groups.groups[0].counterparty.website == "netflix.com"

From b0d19a11905368ffd060d542c9c2850af036c137 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 28 Oct 2024 19:42:00 +0000
Subject: [PATCH 42/64] transactions: remove recurrence group

---
 ntropy_sdk/transactions.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index 644dc08..5633557 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -245,6 +245,9 @@ class Recurrence(BaseModel):
         description="Whether the transaction is a one-time transfer `one-off`, regularly with varying pricing "
         "`recurring` or with fixed pricing `subscription`",
     )
+    group_id: str = Field(
+        description="ID of recurrence group"
+    )
 
 
 class TransactionErrorCode(str, enum.Enum):
@@ -283,7 +286,6 @@ class Transaction(_EnrichedTransactionBase, _TransactionBase):
         min_length=1,
     )
 
-    recurrence: Optional[Recurrence] = None
     request_id: Optional[str] = None
 
 

From c7dc802b18fcb382c0f1dd3f754511fff9cc2f30 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Tue, 29 Oct 2024 10:15:11 +0000
Subject: [PATCH 43/64] lint

---
 ntropy_sdk/paging.py       | 3 ++-
 ntropy_sdk/transactions.py | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/ntropy_sdk/paging.py b/ntropy_sdk/paging.py
index a2e2e58..f330bcf 100644
--- a/ntropy_sdk/paging.py
+++ b/ntropy_sdk/paging.py
@@ -34,7 +34,8 @@ def list(
         cursor: str,
         limit: Optional[int],
         **extra_kwargs: "Unpack[ExtraKwargs]",
-    ) -> "PagedResponse[T]": ...
+    ) -> "PagedResponse[T]":
+        ...
 
 
 class PagedResponse(GenericModel, Generic[T]):
diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index 5633557..abd08a9 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -148,7 +148,8 @@ class Counterparty(Entity):
     type: CounterpartyType
 
 
-class Intermediary(Entity): ...
+class Intermediary(Entity):
+    ...
 
 
 class Entities(BaseModel):

From a416a9180ce138d16f1e8601b07aa0c26f6a5a24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Tue, 29 Oct 2024 12:14:45 +0000
Subject: [PATCH 44/64] overview -> verify

---
 ntropy_sdk/bank_statements.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py
index 7c37e54..3a4b7c3 100644
--- a/ntropy_sdk/bank_statements.py
+++ b/ntropy_sdk/bank_statements.py
@@ -185,7 +185,7 @@ def results(
             **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
         )
 
-    def overview(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementInfo:
+    def verify(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementInfo:
         """Waits for and returns preliminary statement information from the
         first page of the PDF. This may not always be consistent with the
         final results."""
@@ -195,7 +195,7 @@ def overview(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> StatementI
             extra_kwargs["request_id"] = request_id
         resp = self._sdk.retry_ratelimited_request(
             method="POST",
-            url=f"/v3/bank_statements/{id}/overview",
+            url=f"/v3/bank_statements/{id}/verify",
             payload=None,
             **extra_kwargs,
         )

From a52313bc693006966445b5b20d770dfb29594cd1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Tue, 29 Oct 2024 15:44:28 +0000
Subject: [PATCH 45/64] added entities

---
 ntropy_sdk/__init__.py     |  4 ---
 ntropy_sdk/entities.py     | 68 ++++++++++++++++++++++++++++++++++++++
 ntropy_sdk/sdk.py          |  8 +++--
 ntropy_sdk/transactions.py |  4 +--
 4 files changed, 75 insertions(+), 9 deletions(-)
 create mode 100644 ntropy_sdk/entities.py

diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py
index a02745e..1d6e07f 100644
--- a/ntropy_sdk/__init__.py
+++ b/ntropy_sdk/__init__.py
@@ -35,7 +35,3 @@ class ExtraKwargs(TypedDict, total=False):
     NtropyValueError,
     NtropyRuntimeError,
 )
-
-from .transactions import TransactionInput, LocationInput
-
-from .account_holders import AccountHolder
diff --git a/ntropy_sdk/entities.py b/ntropy_sdk/entities.py
new file mode 100644
index 0000000..3d2f5ce
--- /dev/null
+++ b/ntropy_sdk/entities.py
@@ -0,0 +1,68 @@
+import uuid
+from typing import Optional, TYPE_CHECKING, List
+
+from pydantic import BaseModel
+
+if TYPE_CHECKING:
+    from ntropy_sdk import ExtraKwargs
+    from ntropy_sdk import SDK
+    from typing_extensions import Unpack
+
+
+class Entity(BaseModel):
+    id: Optional[str]
+    name: Optional[str]
+    website: Optional[str]
+    logo: Optional[str]
+    mccs: List[int]
+
+
+class EntityResponse(Entity):
+    request_id: Optional[str] = None
+
+
+class EntitiesResource:
+    def __init__(self, sdk: "SDK"):
+        self._sdk = sdk
+
+    def get(
+        self,
+        id: str,
+        **extra_kwargs: "Unpack[ExtraKwargs]",
+    ) -> EntityResponse:
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        resp = self._sdk.retry_ratelimited_request(
+            method="GET",
+            url=f"/v3/entities/{id}",
+            **extra_kwargs,
+        )
+        return EntityResponse(
+            **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
+        )
+
+    def resolve(
+        self,
+        name: Optional[str] = None,
+        website: Optional[str] = None,
+        location: Optional[str] = None,
+        **extra_kwargs: "Unpack[ExtraKwargs]",
+    ) -> EntityResponse:
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        resp = self._sdk.retry_ratelimited_request(
+            method="POST",
+            url=f"/v3/entities/resolve",
+            payload={
+                "name": name,
+                "website": website,
+                "location": location,
+            },
+        )
+        return EntityResponse(
+            **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
+        )
diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py
index f0c157d..f60a294 100644
--- a/ntropy_sdk/sdk.py
+++ b/ntropy_sdk/sdk.py
@@ -3,6 +3,8 @@
 from ntropy_sdk.account_holders import AccountHoldersResource
 from ntropy_sdk.bank_statements import BankStatementsResource
 from ntropy_sdk.batches import BatchesResource
+from ntropy_sdk.categories import CategoriesResource
+from ntropy_sdk.entities import EntitiesResource
 from ntropy_sdk.http import HttpClient
 from ntropy_sdk.rules import RulesResource
 from ntropy_sdk.transactions import TransactionsResource
@@ -23,12 +25,14 @@ def __init__(
         self.base_url = ALL_REGIONS[region]
         self.api_key = api_key
         self.http_client = HttpClient()
-        self.transactions = TransactionsResource(self)
+        self.account_holders = AccountHoldersResource(self)
         self.batches = BatchesResource(self)
         self.bank_statements = BankStatementsResource(self)
-        self.account_holders = AccountHoldersResource(self)
+        self.categories = CategoriesResource(self)
+        self.entities = EntitiesResource(self)
         self.webhooks = WebhooksResource(self)
         self.rules = RulesResource(self)
+        self.transactions = TransactionsResource(self)
 
     def retry_ratelimited_request(
         self,
diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index abd08a9..ed1b0f7 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -246,9 +246,7 @@ class Recurrence(BaseModel):
         description="Whether the transaction is a one-time transfer `one-off`, regularly with varying pricing "
         "`recurring` or with fixed pricing `subscription`",
     )
-    group_id: str = Field(
-        description="ID of recurrence group"
-    )
+    group_id: str = Field(description="ID of recurrence group")
 
 
 class TransactionErrorCode(str, enum.Enum):

From a13c6de81e9d6e2ba8a8e9683028c4518699888b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Tue, 29 Oct 2024 17:00:01 +0000
Subject: [PATCH 46/64] msg

---
 ntropy_sdk/batches.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py
index 249bf67..a9e6e1b 100644
--- a/ntropy_sdk/batches.py
+++ b/ntropy_sdk/batches.py
@@ -172,7 +172,7 @@ def wait_for_results(
     ) -> "BatchResult":
         """Continuously polls the status of this batch, blocking until the batch
         either succeeds or fails. Raises `NtropyTimeoutError` if the `timeout` is exceeded or `NtropyBatchError`
-        if the batch contains errors."""
+        if the batch encountered an error during processing."""
 
         finish_statuses = [BatchStatus.COMPLETED, BatchStatus.ERROR]
         start_time = time.monotonic()
@@ -187,5 +187,5 @@ def wait_for_results(
         if batch and batch.status not in finish_statuses:
             raise NtropyTimeoutError()
         if batch.is_error():
-            raise NtropyBatchError("Some transactions contain errors", id=batch.id)
+            raise NtropyBatchError("Batch terminated with an error", id=batch.id)
         return self._sdk.batches.results(id=id, **extra_kwargs)

From 489fec521672e8f25cec47fd04cdec7ce7345e3b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Tue, 29 Oct 2024 17:40:19 +0000
Subject: [PATCH 47/64] added reports resources

---
 ntropy_sdk/reports.py | 112 ++++++++++++++++++++++++++++++++++++++++++
 ntropy_sdk/sdk.py     |   2 +
 2 files changed, 114 insertions(+)
 create mode 100644 ntropy_sdk/reports.py

diff --git a/ntropy_sdk/reports.py b/ntropy_sdk/reports.py
new file mode 100644
index 0000000..9ca1ff5
--- /dev/null
+++ b/ntropy_sdk/reports.py
@@ -0,0 +1,112 @@
+import uuid
+from datetime import datetime
+from typing import Optional, TYPE_CHECKING
+
+from pydantic import BaseModel
+
+from ntropy_sdk.paging import PagedResponse
+
+if TYPE_CHECKING:
+    from ntropy_sdk import ExtraKwargs
+    from ntropy_sdk import SDK
+    from typing_extensions import Unpack
+
+
+class Report(BaseModel):
+    id: str
+    created_at: datetime
+    status: str
+    rejection_reason: Optional[str]
+
+    transaction_id: str
+    description: str
+    fields: dict[str, str]
+
+
+class ReportResponse(Report):
+    request_id: Optional[str] = None
+
+
+class ReportsResource:
+    def __init__(self, sdk: "SDK"):
+        self._sdk = sdk
+
+    def create(
+        self,
+        *,
+        transaction_id: str,
+        description: str,
+        fields: str,
+        **extra_kwargs: "Unpack[ExtraKwargs]",
+    ) -> ReportResponse:
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        resp = self._sdk.retry_ratelimited_request(
+            method="POST",
+            url="/v3/reports",
+            payload={
+                "transaction_id": transaction_id,
+                "description": description,
+                "fields": fields,
+            },
+            **extra_kwargs,
+        )
+        return ReportResponse(
+            **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
+        )
+
+    def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Report:
+        """Retrieve an account holder"""
+
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        resp = self._sdk.retry_ratelimited_request(
+            method="GET",
+            url=f"/v3/reports/{id}",
+            **extra_kwargs,
+        )
+        return ReportResponse(
+            **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
+        )
+
+    def list(
+        self,
+        *,
+        created_before: Optional[datetime] = None,
+        created_after: Optional[datetime] = None,
+        status: str = None,
+        cursor: Optional[str] = None,
+        limit: Optional[int] = None,
+        **extra_kwargs: "Unpack[ExtraKwargs]",
+    ) -> PagedResponse[ReportResponse]:
+        """List all account holders"""
+
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        resp = self._sdk.retry_ratelimited_request(
+            method="GET",
+            url="/v3/reports",
+            params={
+                "created_before": created_before,
+                "created_after": created_after,
+                "status": status,
+                "cursor": cursor,
+                "limit": limit,
+            },
+            **extra_kwargs,
+        )
+        page = PagedResponse[ReportResponse](
+            **resp.json(),
+            request_id=resp.headers.get("x-request-id", request_id),
+            _resource=self,
+            _extra_kwargs=extra_kwargs,
+        )
+        for t in page.data:
+            t.request_id = request_id
+        return page
diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py
index f60a294..1f8916d 100644
--- a/ntropy_sdk/sdk.py
+++ b/ntropy_sdk/sdk.py
@@ -6,6 +6,7 @@
 from ntropy_sdk.categories import CategoriesResource
 from ntropy_sdk.entities import EntitiesResource
 from ntropy_sdk.http import HttpClient
+from ntropy_sdk.reports import ReportsResource
 from ntropy_sdk.rules import RulesResource
 from ntropy_sdk.transactions import TransactionsResource
 from ntropy_sdk.v2.ntropy_sdk import DEFAULT_REGION, ALL_REGIONS
@@ -31,6 +32,7 @@ def __init__(
         self.categories = CategoriesResource(self)
         self.entities = EntitiesResource(self)
         self.webhooks = WebhooksResource(self)
+        self.reports = ReportsResource(self)
         self.rules = RulesResource(self)
         self.transactions = TransactionsResource(self)
 

From 7ce15d164f622eb4e5e67ef77e4bd2d524c63bd6 Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Tue, 29 Oct 2024 12:42:45 -0500
Subject: [PATCH 48/64] Add __all__ to __init__.py

---
 ntropy_sdk/__init__.py | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py
index 1d6e07f..4405976 100644
--- a/ntropy_sdk/__init__.py
+++ b/ntropy_sdk/__init__.py
@@ -35,3 +35,23 @@ class ExtraKwargs(TypedDict, total=False):
     NtropyValueError,
     NtropyRuntimeError,
 )
+
+
+__all__ = (
+    "SDK",
+    "NtropyError",
+    "NtropyBatchError",
+    "NtropyDatasourceError",
+    "NtropyTimeoutError",
+    "NtropyHTTPError",
+    "NtropyValidationError",
+    "NtropyQuotaExceededError",
+    "NtropyNotSupportedError",
+    "NtropyResourceOccupiedError",
+    "NtropyServerConnectionError",
+    "NtropyRateLimitError",
+    "NtropyNotFoundError",
+    "NtropyNotAuthorizedError",
+    "NtropyValueError",
+    "NtropyRuntimeError",
+)

From 7eb280da25b50113c7bff647dd2751d5eb813fec Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Tue, 29 Oct 2024 18:01:16 -0500
Subject: [PATCH 49/64] =?UTF-8?q?Bump=20version:=205.0.0rc1=20=E2=86=92=20?=
 =?UTF-8?q?5.0.0rc2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 ntropy_sdk/__init__.py | 2 +-
 setup.cfg              | 2 +-
 setup.py               | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py
index 4405976..4aa3add 100644
--- a/ntropy_sdk/__init__.py
+++ b/ntropy_sdk/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "5.0.0rc1"
+__version__ = "5.0.0rc2"
 
 from typing import TYPE_CHECKING, Optional
 import requests
diff --git a/setup.cfg b/setup.cfg
index 8043785..3219475 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 5.0.0rc1
+current_version = 5.0.0rc2
 commit = True
 tag = True
 parse = (?P\d+)\.(?P\d+)\.(?P\d+)(rc(?P
\d+))?
diff --git a/setup.py b/setup.py
index 36a246f..e07f04f 100644
--- a/setup.py
+++ b/setup.py
@@ -54,6 +54,6 @@
     test_suite="tests",
     tests_require=test_requirements,
     url="https://github.com/ntropy-network/ntropy-sdk",
-    version="5.0.0rc1",
+    version="5.0.0rc2",
     zip_safe=False,
 )

From bab653edc46b279d8916864bf21706722c1a1f69 Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Tue, 29 Oct 2024 18:12:13 -0500
Subject: [PATCH 50/64] Update requirements_dev.txt

---
 requirements_dev.txt | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/requirements_dev.txt b/requirements_dev.txt
index b2fc590..b0c9448 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,10 +1,9 @@
-pip==19.2.3
 bump2version==0.5.11
-wheel==0.33.6
+wheel==0.44.6
 watchdog==0.9.0
 flake8==3.7.8
 tox==3.14.0
 coverage==4.5.4
 Sphinx==1.8.5
-twine==1.14.0
+twine==5.1.1
 requests_toolbelt==1.0.0

From 29ff82b76ea7e05fec6b1f706c266d096d3fa629 Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Thu, 31 Oct 2024 05:39:55 +0000
Subject: [PATCH 51/64] Fix some types and add delete methods

---
 ntropy_sdk/bank_statements.py | 12 ++++++++++++
 ntropy_sdk/reports.py         | 19 ++++++++++++++++---
 ntropy_sdk/transactions.py    | 16 +++++++++++++++-
 ntropy_sdk/webhooks.py        |  3 +--
 4 files changed, 44 insertions(+), 6 deletions(-)

diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py
index 3a4b7c3..40ecee8 100644
--- a/ntropy_sdk/bank_statements.py
+++ b/ntropy_sdk/bank_statements.py
@@ -236,3 +236,15 @@ def wait_for_results(
                 id=stmt.id, code=stmt.error.code, message=stmt.error.message
             )
         return self._sdk.bank_statements.results(id=id, **extra_kwargs)
+
+    def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]"):
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        self._sdk.retry_ratelimited_request(
+            method="DELETE",
+            url=f"/v3/bank_statements/{id}",
+            payload=None,
+            **extra_kwargs,
+        )
diff --git a/ntropy_sdk/reports.py b/ntropy_sdk/reports.py
index 9ca1ff5..dbc01ef 100644
--- a/ntropy_sdk/reports.py
+++ b/ntropy_sdk/reports.py
@@ -36,7 +36,7 @@ def create(
         *,
         transaction_id: str,
         description: str,
-        fields: str,
+        fields: list[str],
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ) -> ReportResponse:
         request_id = extra_kwargs.get("request_id")
@@ -58,7 +58,7 @@ def create(
         )
 
     def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Report:
-        """Retrieve an account holder"""
+        """Retrieve a report"""
 
         request_id = extra_kwargs.get("request_id")
         if request_id is None:
@@ -83,7 +83,7 @@ def list(
         limit: Optional[int] = None,
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ) -> PagedResponse[ReportResponse]:
-        """List all account holders"""
+        """List all reports"""
 
         request_id = extra_kwargs.get("request_id")
         if request_id is None:
@@ -110,3 +110,16 @@ def list(
         for t in page.data:
             t.request_id = request_id
         return page
+
+    def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Report:
+        """Delete a report"""
+
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        self._sdk.retry_ratelimited_request(
+            method="DELETE",
+            url=f"/v3/reports/{id}",
+            **extra_kwargs,
+        )
diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index ed1b0f7..22d7d2b 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -355,7 +355,7 @@ def create(
         id: str,
         description: str,
         date: str,
-        amount: int,
+        amount: float,
         entry_type: str,
         currency: str,
         account_holder_id: Optional[str] = None,
@@ -410,3 +410,17 @@ def assign(
         return Transaction(
             **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
         )
+
+    def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]"):
+        """Delete a transaction"""
+
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        self._sdk.retry_ratelimited_request(
+            method="DELETE",
+            url=f"/v3/transactions/{id}",
+            payload=None,
+            **extra_kwargs,
+        )
diff --git a/ntropy_sdk/webhooks.py b/ntropy_sdk/webhooks.py
index 70f5afb..b5ce1fc 100644
--- a/ntropy_sdk/webhooks.py
+++ b/ntropy_sdk/webhooks.py
@@ -22,8 +22,7 @@ class _Unset:
     "reports.resolved",
     "reports.rejected",
     "reports.pending",
-    "bank_statements.processing",
-    "bank_statements.processed",
+    "bank_statements.completed",
     "bank_statements.error",
     "batches.completed",
     "batches.error",

From 61f47254251300df5fbe241284f306a3eaad1f4f Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Thu, 31 Oct 2024 06:56:00 +0000
Subject: [PATCH 52/64] Fix batch post

---
 ntropy_sdk/batches.py | 2 +-
 ntropy_sdk/reports.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py
index a9e6e1b..70ec98c 100644
--- a/ntropy_sdk/batches.py
+++ b/ntropy_sdk/batches.py
@@ -138,7 +138,7 @@ def create(
         resp = self._sdk.retry_ratelimited_request(
             method="POST",
             url="/v3/batches",
-            payload_json={
+            payload={
                 "operation": operation,
                 "data": data,
             },
diff --git a/ntropy_sdk/reports.py b/ntropy_sdk/reports.py
index dbc01ef..1bc0ddf 100644
--- a/ntropy_sdk/reports.py
+++ b/ntropy_sdk/reports.py
@@ -111,7 +111,7 @@ def list(
             t.request_id = request_id
         return page
 
-    def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> Report:
+    def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]"):
         """Delete a report"""
 
         request_id = extra_kwargs.get("request_id")

From 55d2d1767cf92f259d160d5168573c4047a6dd2c Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Fri, 1 Nov 2024 16:32:53 +0000
Subject: [PATCH 53/64] Fixes

---
 ntropy_sdk/account_holders.py | 13 +++++++++++++
 ntropy_sdk/reports.py         |  2 +-
 ntropy_sdk/transactions.py    |  2 +-
 3 files changed, 15 insertions(+), 2 deletions(-)

diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py
index 826e1ed..a623ac4 100644
--- a/ntropy_sdk/account_holders.py
+++ b/ntropy_sdk/account_holders.py
@@ -138,3 +138,16 @@ def recurring_groups(
             groups=[RecurrenceGroup(**r) for r in resp.json()],
             request_id=resp.headers.get("x-request-id", request_id),
         )
+
+    def delete(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]"):
+        """Retrieve an account holder"""
+
+        request_id = extra_kwargs.get("request_id")
+        if request_id is None:
+            request_id = uuid.uuid4().hex
+            extra_kwargs["request_id"] = request_id
+        self._sdk.retry_ratelimited_request(
+            method="DELETE",
+            url=f"/v3/account_holders/{id}",
+            **extra_kwargs,
+        )
diff --git a/ntropy_sdk/reports.py b/ntropy_sdk/reports.py
index 1bc0ddf..cd5cd15 100644
--- a/ntropy_sdk/reports.py
+++ b/ntropy_sdk/reports.py
@@ -20,7 +20,7 @@ class Report(BaseModel):
 
     transaction_id: str
     description: str
-    fields: dict[str, str]
+    fields: list[str]
 
 
 class ReportResponse(Report):
diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index 22d7d2b..6a0f4a2 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -402,7 +402,7 @@ def assign(
         resp = self._sdk.retry_ratelimited_request(
             method="POST",
             url=f"/v3/transactions/{transaction_id}/assign",
-            params={
+            payload={
                 "account_holder_id": account_holder_id,
             },
             **extra_kwargs,

From 12fb1dbafb6b07c7763d92b473bb453a5af8fac7 Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Fri, 1 Nov 2024 19:12:21 +0000
Subject: [PATCH 54/64] Allow str for account holder type

---
 ntropy_sdk/account_holders.py |  4 ++--
 ntropy_sdk/categories.py      | 16 +++++++++++-----
 2 files changed, 13 insertions(+), 7 deletions(-)

diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py
index a623ac4..99339d5 100644
--- a/ntropy_sdk/account_holders.py
+++ b/ntropy_sdk/account_holders.py
@@ -1,7 +1,7 @@
 import uuid
 from datetime import datetime
 from enum import Enum
-from typing import Optional, TYPE_CHECKING
+from typing import Optional, TYPE_CHECKING, Union
 
 from pydantic import BaseModel, Field
 
@@ -94,7 +94,7 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolder:
     def create(
         self,
         id: str,
-        type: AccountHolderType,
+        type: Union[AccountHolderType, str],
         name: Optional[str] = None,
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ) -> AccountHolder:
diff --git a/ntropy_sdk/categories.py b/ntropy_sdk/categories.py
index 781b070..71a5b64 100644
--- a/ntropy_sdk/categories.py
+++ b/ntropy_sdk/categories.py
@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Union
 import uuid
 
 from ntropy_sdk.account_holders import AccountHolderType
@@ -15,13 +15,15 @@ def __init__(self, sdk: "SDK"):
 
     def get(
         self,
-        account_holder_type: AccountHolderType,
+        account_holder_type: Union[AccountHolderType, str],
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ) -> dict:
         request_id = extra_kwargs.get("request_id")
         if request_id is None:
             request_id = uuid.uuid4().hex
             extra_kwargs["request_id"] = request_id
+        if not isinstance(account_holder_type, AccountHolderType):
+            account_holder_type = AccountHolderType(account_holder_type)
         resp = self._sdk.retry_ratelimited_request(
             method="GET",
             url=f"/v3/categories/{account_holder_type.value}",
@@ -31,7 +33,7 @@ def get(
 
     def set(
         self,
-        account_holder_type: AccountHolderType,
+        account_holder_type: Union[AccountHolderType, str],
         categories: dict,
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ):
@@ -39,22 +41,26 @@ def set(
         if request_id is None:
             request_id = uuid.uuid4().hex
             extra_kwargs["request_id"] = request_id
+        if not isinstance(account_holder_type, AccountHolderType):
+            account_holder_type = AccountHolderType(account_holder_type)
         self._sdk.retry_ratelimited_request(
             method="POST",
             url=f"/v3/categories/{account_holder_type.value}",
-            json=categories,
+            payload=categories,
             **extra_kwargs,
         )
 
     def reset(
         self,
-        account_holder_type: AccountHolderType,
+        account_holder_type: Union[AccountHolderType, str],
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ):
         request_id = extra_kwargs.get("request_id")
         if request_id is None:
             request_id = uuid.uuid4().hex
             extra_kwargs["request_id"] = request_id
+        if not isinstance(account_holder_type, AccountHolderType):
+            account_holder_type = AccountHolderType(account_holder_type)
         self._sdk.retry_ratelimited_request(
             method="POST",
             url=f"/v3/categories/{account_holder_type.value}/reset",

From 17aff0aeea4660b5a0f2216ad2f0f666ba1ca174 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 4 Nov 2024 16:37:32 +0000
Subject: [PATCH 55/64] ah, txs: add created_at

---
 ntropy_sdk/account_holders.py | 4 ++++
 ntropy_sdk/transactions.py    | 5 +++++
 2 files changed, 9 insertions(+)

diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py
index 99339d5..fcf0a92 100644
--- a/ntropy_sdk/account_holders.py
+++ b/ntropy_sdk/account_holders.py
@@ -32,6 +32,10 @@ class AccountHolder(BaseModel):
         default=None,
         description="The name of the account holder",
     )
+    created_at: datetime = Field(
+        ...,
+        description="Date of creation of the account holder",
+    )
     request_id: Optional[str] = None
 
 
diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index 6a0f4a2..605f3a6 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -265,6 +265,11 @@ class _EnrichedTransactionBase(BaseModel):
     location: Optional[Location] = None
     error: Optional[TransactionError] = None
 
+    created_at: datetime = Field(
+        ...,
+        description="Date of creation of the transaction",
+    )
+
 
 class EnrichedTransaction(_EnrichedTransactionBase):
     id: str = Field(

From 01cd493ac6529bf2b67d9428dfc3312b220d2ab6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 4 Nov 2024 16:37:34 +0000
Subject: [PATCH 56/64] test 404

---
 tests/v3/test_sdk.py | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/tests/v3/test_sdk.py b/tests/v3/test_sdk.py
index c566fbe..4dd2d5b 100644
--- a/tests/v3/test_sdk.py
+++ b/tests/v3/test_sdk.py
@@ -1,11 +1,12 @@
 import os
 from itertools import islice
 
+import pytest
+
 from ntropy_sdk import (
     SDK,
-    TransactionInput,
-    AccountHolder,
     NtropyValueError,
+    NtropyNotFoundError,
 )
 
 
@@ -26,6 +27,11 @@ def test_readme(api_key):
     exec(readme_data, globals())
 
 
+def test_404_ah(sdk):
+    with pytest.raises(NtropyNotFoundError):
+        sdk.account_holders.get("non-existent-id")
+
+
 def test_recurrence_groups(sdk):
     try:
         sdk.account_holders.create(

From a26af5aa934cb691188a00cba6ef834b527a2c08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 4 Nov 2024 16:50:58 +0000
Subject: [PATCH 57/64] typing

---
 ntropy_sdk/batches.py | 2 +-
 ntropy_sdk/reports.py | 6 +++---
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/ntropy_sdk/batches.py b/ntropy_sdk/batches.py
index 70ec98c..60ed868 100644
--- a/ntropy_sdk/batches.py
+++ b/ntropy_sdk/batches.py
@@ -59,7 +59,7 @@ class BatchResult(BaseModel):
         description="The total number of transactions in the batch result."
     )
     status: BatchStatus = Field(description="The current status of the batch job.")
-    results: list[EnrichedTransaction] = Field(
+    results: List[EnrichedTransaction] = Field(
         description="A list of enriched transactions resulting from the enrichment of this batch."
     )
     request_id: Optional[str] = None
diff --git a/ntropy_sdk/reports.py b/ntropy_sdk/reports.py
index cd5cd15..79dc5ea 100644
--- a/ntropy_sdk/reports.py
+++ b/ntropy_sdk/reports.py
@@ -1,6 +1,6 @@
 import uuid
 from datetime import datetime
-from typing import Optional, TYPE_CHECKING
+from typing import Optional, TYPE_CHECKING, List
 
 from pydantic import BaseModel
 
@@ -20,7 +20,7 @@ class Report(BaseModel):
 
     transaction_id: str
     description: str
-    fields: list[str]
+    fields: List[str]
 
 
 class ReportResponse(Report):
@@ -36,7 +36,7 @@ def create(
         *,
         transaction_id: str,
         description: str,
-        fields: list[str],
+        fields: List[str],
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ) -> ReportResponse:
         request_id = extra_kwargs.get("request_id")

From 0419f55648dea9a7080b57325cc08fa3e5eceba0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 4 Nov 2024 19:23:30 +0000
Subject: [PATCH 58/64] fixes

---
 ntropy_sdk/account_holders.py | 23 ++++++++++++++---------
 1 file changed, 14 insertions(+), 9 deletions(-)

diff --git a/ntropy_sdk/account_holders.py b/ntropy_sdk/account_holders.py
index fcf0a92..e145fca 100644
--- a/ntropy_sdk/account_holders.py
+++ b/ntropy_sdk/account_holders.py
@@ -20,7 +20,7 @@ class AccountHolderType(str, Enum):
     business = "business"
 
 
-class AccountHolder(BaseModel):
+class AccountHolderCreate(BaseModel):
     id: str = Field(
         description="The unique ID of the account holder of the transaction",
         min_length=1,
@@ -32,11 +32,14 @@ class AccountHolder(BaseModel):
         default=None,
         description="The name of the account holder",
     )
+    request_id: Optional[str] = None
+
+
+class AccountHolderResponse(AccountHolderCreate):
     created_at: datetime = Field(
         ...,
         description="Date of creation of the account holder",
     )
-    request_id: Optional[str] = None
 
 
 class AccountHoldersResource:
@@ -51,7 +54,7 @@ def list(
         cursor: Optional[str] = None,
         limit: Optional[int] = None,
         **extra_kwargs: "Unpack[ExtraKwargs]",
-    ) -> PagedResponse[AccountHolder]:
+    ) -> PagedResponse[AccountHolderResponse]:
         """List all account holders"""
 
         request_id = extra_kwargs.get("request_id")
@@ -69,7 +72,7 @@ def list(
             },
             **extra_kwargs,
         )
-        page = PagedResponse[AccountHolder](
+        page = PagedResponse[AccountHolderResponse](
             **resp.json(),
             request_id=resp.headers.get("x-request-id", request_id),
             _resource=self,
@@ -79,7 +82,9 @@ def list(
             t.request_id = request_id
         return page
 
-    def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolder:
+    def get(
+        self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]"
+    ) -> AccountHolderResponse:
         """Retrieve an account holder"""
 
         request_id = extra_kwargs.get("request_id")
@@ -91,7 +96,7 @@ def get(self, id: str, **extra_kwargs: "Unpack[ExtraKwargs]") -> AccountHolder:
             url=f"/v3/account_holders/{id}",
             **extra_kwargs,
         )
-        return AccountHolder(
+        return AccountHolderResponse(
             **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
         )
 
@@ -101,7 +106,7 @@ def create(
         type: Union[AccountHolderType, str],
         name: Optional[str] = None,
         **extra_kwargs: "Unpack[ExtraKwargs]",
-    ) -> AccountHolder:
+    ) -> AccountHolderResponse:
         """Create an account holder"""
 
         request_id = extra_kwargs.get("request_id")
@@ -112,7 +117,7 @@ def create(
             method="POST",
             url="/v3/account_holders",
             payload_json_str=pydantic_json(
-                AccountHolder(
+                AccountHolderCreate(
                     id=id,
                     type=type,
                     name=name,
@@ -120,7 +125,7 @@ def create(
             ),
             **extra_kwargs,
         )
-        return AccountHolder(
+        return AccountHolderResponse(
             **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
         )
 

From 657bf5e8d3d69468250972c9713cdcecc1768cc4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Mon, 4 Nov 2024 19:50:55 +0000
Subject: [PATCH 59/64] categories: return results

---
 ntropy_sdk/categories.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/ntropy_sdk/categories.py b/ntropy_sdk/categories.py
index 71a5b64..b0ce7a2 100644
--- a/ntropy_sdk/categories.py
+++ b/ntropy_sdk/categories.py
@@ -43,12 +43,13 @@ def set(
             extra_kwargs["request_id"] = request_id
         if not isinstance(account_holder_type, AccountHolderType):
             account_holder_type = AccountHolderType(account_holder_type)
-        self._sdk.retry_ratelimited_request(
+        resp = self._sdk.retry_ratelimited_request(
             method="POST",
             url=f"/v3/categories/{account_holder_type.value}",
             payload=categories,
             **extra_kwargs,
         )
+        return resp.json()
 
     def reset(
         self,
@@ -61,8 +62,9 @@ def reset(
             extra_kwargs["request_id"] = request_id
         if not isinstance(account_holder_type, AccountHolderType):
             account_holder_type = AccountHolderType(account_holder_type)
-        self._sdk.retry_ratelimited_request(
+        resp = self._sdk.retry_ratelimited_request(
             method="POST",
             url=f"/v3/categories/{account_holder_type.value}/reset",
             **extra_kwargs,
         )
+        return resp.json()

From 7180fa8d4bd648f94f839719d0da593d4e0a0a87 Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Mon, 4 Nov 2024 23:29:30 -0600
Subject: [PATCH 60/64] Update rules resource

---
 ntropy_sdk/rules.py | 38 ++++++++++++++++++++++++++++----------
 1 file changed, 28 insertions(+), 10 deletions(-)

diff --git a/ntropy_sdk/rules.py b/ntropy_sdk/rules.py
index a5940ae..07d4044 100644
--- a/ntropy_sdk/rules.py
+++ b/ntropy_sdk/rules.py
@@ -1,6 +1,8 @@
-from typing import TYPE_CHECKING, Any, Dict, List
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
 import uuid
 
+from pydantic import BaseModel, Field
+
 
 if TYPE_CHECKING:
     from ntropy_sdk import ExtraKwargs
@@ -12,6 +14,16 @@
 Rules = List[Dict[str, Any]]
 
 
+class TopLevelRule(BaseModel):
+    id: str = Field(
+        description="A generated unique identifier for the top level rule",
+    )
+    request_id: Optional[str] = None
+
+    class Config:
+        extra = "allow"
+
+
 class RulesResource:
     def __init__(self, sdk: "SDK"):
         self._sdk = sdk
@@ -20,22 +32,25 @@ def create(
         self,
         rule: Rule,
         **extra_kwargs: "Unpack[ExtraKwargs]",
-    ):
+    ) -> TopLevelRule:
         request_id = extra_kwargs.get("request_id")
         if request_id is None:
             request_id = uuid.uuid4().hex
             extra_kwargs["request_id"] = request_id
-        self._sdk.retry_ratelimited_request(
+        resp = self._sdk.retry_ratelimited_request(
             method="POST",
             url="/v3/rules",
             payload=rule,
             **extra_kwargs,
         )
+        return TopLevelRule(
+            **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
+        )
 
     def get(
         self,
         **extra_kwargs: "Unpack[ExtraKwargs]",
-    ) -> Rules:
+    ) -> List[TopLevelRule]:
         request_id = extra_kwargs.get("request_id")
         if request_id is None:
             request_id = uuid.uuid4().hex
@@ -45,7 +60,7 @@ def get(
             url="/v3/rules",
             **extra_kwargs,
         )
-        return resp.json()
+        return [TopLevelRule(**r) for r in resp.json()]
 
     def replace(
         self,
@@ -65,7 +80,7 @@ def replace(
 
     def patch(
         self,
-        index: int,
+        id: str,
         rule: Rule,
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ):
@@ -73,16 +88,19 @@ def patch(
         if request_id is None:
             request_id = uuid.uuid4().hex
             extra_kwargs["request_id"] = request_id
-        self._sdk.retry_ratelimited_request(
+        resp = self._sdk.retry_ratelimited_request(
             method="PATCH",
-            url=f"/v3/rules/{index}",
+            url=f"/v3/rules/{id}",
             payload=rule,
             **extra_kwargs,
         )
+        return TopLevelRule(
+            **resp.json(), request_id=resp.headers.get("x-request-id", request_id)
+        )
 
     def delete(
         self,
-        index: int,
+        id: str,
         **extra_kwargs: "Unpack[ExtraKwargs]",
     ):
         request_id = extra_kwargs.get("request_id")
@@ -91,6 +109,6 @@ def delete(
             extra_kwargs["request_id"] = request_id
         self._sdk.retry_ratelimited_request(
             method="DELETE",
-            url=f"/v3/rules/{index}",
+            url=f"/v3/rules/{id}",
             **extra_kwargs,
         )

From 333b3776d369702402edb91298f2d2924309d84c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Tue, 5 Nov 2024 11:03:47 +0000
Subject: [PATCH 61/64] sdk: accept session

---
 ntropy_sdk/sdk.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/ntropy_sdk/sdk.py b/ntropy_sdk/sdk.py
index 1f8916d..14dad16 100644
--- a/ntropy_sdk/sdk.py
+++ b/ntropy_sdk/sdk.py
@@ -1,5 +1,7 @@
 from typing import TYPE_CHECKING, Dict, Optional
 
+import requests
+
 from ntropy_sdk.account_holders import AccountHoldersResource
 from ntropy_sdk.bank_statements import BankStatementsResource
 from ntropy_sdk.batches import BatchesResource
@@ -22,10 +24,11 @@ def __init__(
         self,
         api_key: Optional[str] = None,
         region: str = DEFAULT_REGION,
+        session: Optional[requests.Session] = None,
     ):
         self.base_url = ALL_REGIONS[region]
         self.api_key = api_key
-        self.http_client = HttpClient()
+        self.http_client = HttpClient(session=session)
         self.account_holders = AccountHoldersResource(self)
         self.batches = BatchesResource(self)
         self.bank_statements = BankStatementsResource(self)

From 2433e8bda3b9475dec07a28748e8c32d22158d38 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Tue, 5 Nov 2024 11:15:14 +0000
Subject: [PATCH 62/64] sdk: filter out null params

---
 ntropy_sdk/http.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/ntropy_sdk/http.py b/ntropy_sdk/http.py
index faffdeb..50b37dc 100644
--- a/ntropy_sdk/http.py
+++ b/ntropy_sdk/http.py
@@ -86,6 +86,9 @@ def retry_ratelimited_request(
             headers.update(extra_headers)
 
         backoff = 1
+        if params is not None:
+            params = {k: v for k, v in params.items() if v is not None}
+
         for _ in range(retries):
             try:
                 resp = cur_session.request(

From 0f6821b0eb2e615a5bd8e02b8dc917f4be1b3bf3 Mon Sep 17 00:00:00 2001
From: Matthew Tran <0e4ef622@gmail.com>
Date: Tue, 5 Nov 2024 08:18:45 -0600
Subject: [PATCH 63/64] Add BankStatementTransaction.to_transaction_input

---
 ntropy_sdk/bank_statements.py | 24 +++++++++++++++++++++---
 ntropy_sdk/transactions.py    |  2 +-
 2 files changed, 22 insertions(+), 4 deletions(-)

diff --git a/ntropy_sdk/bank_statements.py b/ntropy_sdk/bank_statements.py
index 40ecee8..0681e74 100644
--- a/ntropy_sdk/bank_statements.py
+++ b/ntropy_sdk/bank_statements.py
@@ -7,14 +7,15 @@
 
 from pydantic import BaseModel, Field, NonNegativeFloat
 
+from ntropy_sdk.paging import PagedResponse
+from ntropy_sdk.transactions import LocationInput, TransactionInput
+from ntropy_sdk.utils import EntryType
 from ntropy_sdk.v2.bank_statements import StatementInfo
 from ntropy_sdk.v2.errors import (
+    NtropyBankStatementError,
     NtropyDatasourceError,
     NtropyTimeoutError,
-    NtropyBankStatementError,
 )
-from ntropy_sdk.utils import EntryType
-from ntropy_sdk.paging import PagedResponse
 
 if TYPE_CHECKING:
     from ntropy_sdk import ExtraKwargs
@@ -70,6 +71,23 @@ class BankStatementTransaction(BaseModel):
         description="A generated unique identifier for the transaction", min_length=1
     )
 
+    def to_transaction_input(
+        self,
+        *,
+        account_holder_id: Optional[str] = None,
+        location: Optional[LocationInput] = None,
+    ) -> TransactionInput:
+        return TransactionInput(
+            id=self.id,
+            description=self.description,
+            date=self.date,
+            amount=self.amount,
+            entry_type=self.entry_type,
+            currency=self.currency,
+            account_holder_id=account_holder_id,
+            location=location,
+        )
+
 
 class BankStatementAccount(BaseModel):
     number: Optional[str]
diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py
index 605f3a6..69c79d6 100644
--- a/ntropy_sdk/transactions.py
+++ b/ntropy_sdk/transactions.py
@@ -64,7 +64,7 @@ class _TransactionBase(BaseModel):
 
 class TransactionInput(_TransactionBase):
     account_holder_id: Optional[str] = Field(
-        None,
+        default=None,
         description="The id of the account holder. Unsetting it will disable categorization.",
     )
 

From fc51d7e9574172ac432e81550a9c09556ee7049f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Santos?=
 <17305792+adgsantos@users.noreply.github.com>
Date: Wed, 6 Nov 2024 14:50:14 +0000
Subject: [PATCH 64/64] =?UTF-8?q?Bump=20version:=205.0.0rc2=20=E2=86=92=20?=
 =?UTF-8?q?5.0.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 ntropy_sdk/__init__.py | 2 +-
 setup.cfg              | 3 +--
 setup.py               | 2 +-
 3 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py
index 4aa3add..1c82db6 100644
--- a/ntropy_sdk/__init__.py
+++ b/ntropy_sdk/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "5.0.0rc2"
+__version__ = "5.0.1"
 
 from typing import TYPE_CHECKING, Optional
 import requests
diff --git a/setup.cfg b/setup.cfg
index 3219475..9f73df7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 5.0.0rc2
+current_version = 5.0.1
 commit = True
 tag = True
 parse = (?P\d+)\.(?P\d+)\.(?P\d+)(rc(?P
\d+))?
@@ -22,4 +22,3 @@ universal = 1
 exclude = docs
 
 [aliases]
-
diff --git a/setup.py b/setup.py
index e07f04f..b763862 100644
--- a/setup.py
+++ b/setup.py
@@ -54,6 +54,6 @@
     test_suite="tests",
     tests_require=test_requirements,
     url="https://github.com/ntropy-network/ntropy-sdk",
-    version="5.0.0rc2",
+    version="5.0.1",
     zip_safe=False,
 )