Skip to content

Commit bec867d

Browse files
authored
Merge pull request #28 from paywithextend/development
Release 2.0.0
2 parents c648e76 + 82ba1e3 commit bec867d

File tree

9 files changed

+197
-48
lines changed

9 files changed

+197
-48
lines changed

CHANGELOG.md

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,96 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [0.1.0] - 2024-03-24
8+
## [2.0.0] - 2025-10-26
99

1010
### Added
11-
- Initial release
12-
- Basic virtual card operations (create, get, update, cancel, close)
13-
- Recurring card support
14-
- Transaction listing and filtering
15-
- Comprehensive test suite (unit and integration tests)
16-
- Jupyter notebook examples
17-
- Type hints and validation
18-
- Async/await support
11+
12+
- `extend.auth` module providing reusable `Authorization` strategies (e.g., `BasicAuth`, `BearerAuth`).
13+
- Support for injecting any `Authorization` implementation into both `APIClient` and `ExtendClient`.
14+
15+
### Changed
16+
17+
- **Breaking:** `APIClient` and `ExtendClient` constructors now require an `Authorization` instance instead of raw
18+
`api_key`/`api_secret` strings. Wrap credentials in `BasicAuth` or supply a different `Authorization` to upgrade.
19+
- Updated documentation, examples, and tests to reflect the new initialization pattern.
20+
21+
## [1.2.2] - 2025-09-30
22+
23+
### Added
24+
25+
- `Transactions.get_transactions` now accepts multiple status values plus a `missing_expense_categories` flag to focus
26+
on
27+
records that still need categorization.
28+
- Expanded unit and integration coverage validating the new filters along with stricter date-range assertions.
29+
30+
### Changed
31+
32+
- Transaction queries now send API-native parameter names (`perPage`, `since`, `until`) and emit `receiptStatus`/
33+
`expenseCategoryStatuses` lists whenever those filters are active.
34+
- Status inputs are validated and normalized to uppercase before requests to avoid accidental API errors when multiple
35+
values are provided.
36+
37+
## [1.2.1] - 2025-04-11
38+
39+
### Added
40+
41+
- A `receipt_missing` flag on transaction listings for fetching only records that still need receipts, plus helper
42+
utilities/tests that assert attachment counts against that filter.
43+
44+
### Changed
45+
46+
- Receipt reminder flows now explicitly request `receipt_missing=True`, and the integration test tolerates HTTP 429
47+
rate limits while still verifying behavior.
48+
- Transaction listing tests share a `get_transactions_from_response` helper to keep assertions consistent.
49+
50+
## [1.2.0] - 2025-04-10
51+
52+
### Added
53+
54+
- `Transactions.send_receipt_reminder` for nudging cardholders, with accompanying unit and integration tests.
55+
- `APIClient.post_multipart` and a shared `_send_request` helper, enabling consistent handling of multipart uploads and
56+
HTTP verbs.
1957

2058
### Changed
59+
60+
- All HTTP methods now funnel through `_send_request`, unifying timeout/error handling and simplifying future
61+
instrumentation.
62+
63+
## [1.1.0] - 2025-04-08
64+
65+
### Added
66+
67+
- New `ReceiptCapture` resource (exposed via `ExtendClient.receipt_capture`) that supports bulk receipt automatching and
68+
status polling.
69+
- Tests covering the new receipt-capture endpoints alongside improvements to receipt attachments and documentation.
70+
71+
## [1.0.0] - 2025-04-04
72+
73+
### Added
74+
75+
- First public release of the async Extend Python SDK, including typed models, validation utilities, and resource
76+
wrappers for credit cards, virtual cards, transactions, expense data, and receipt uploads.
77+
- Developer tooling essentials: packaging metadata, Makefile shortcuts, `.env` template, CI release workflow, and
78+
contribution guidelines.
79+
- Comprehensive automation (unit + integration tests) plus the initial Jupyter notebook for interactive API
80+
exploration.
81+
82+
### Changed
83+
2184
- None (initial release)
2285

2386
### Deprecated
87+
2488
- None (initial release)
2589

2690
### Removed
91+
2792
- None (initial release)
2893

2994
### Fixed
95+
3096
- None (initial release)
3197

3298
### Security
33-
- None (initial release)
99+
100+
- None (initial release)

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@ pip install -e .
4242
```python
4343
import asyncio
4444
from extend import ExtendClient
45+
from extend.auth import BasicAuth
4546

4647

4748
async def main():
4849
# Initialize the client
4950
client = ExtendClient(
50-
api_key="your-api-key",
51-
api_secret="your-api-secret"
51+
auth=BasicAuth(
52+
"your-api-key",
53+
"your-api-secret",
54+
)
5255
)
5356

5457
# Get all virtual cards
@@ -64,6 +67,27 @@ async def main():
6467
asyncio.run(main())
6568
```
6669

70+
### Using Custom Authorization
71+
72+
Both `ExtendClient` and `APIClient` accept reusable authorization strategies defined in `extend.auth`, enabling scenarios like JWT-based access or shared credentials across clients.
73+
74+
```python
75+
from extend import ExtendClient
76+
from extend.auth import BearerAuth
77+
78+
auth = BearerAuth(jwt_token="your-jwt-token")
79+
client = ExtendClient(auth=auth)
80+
```
81+
82+
If you want to work with the lower-level `APIClient` directly, you can pass any `Authorization` implementation:
83+
84+
```python
85+
from extend.auth import BasicAuth
86+
from extend.client import APIClient
87+
88+
api_client = APIClient(auth=BasicAuth("your-api-key", "your-api-secret"))
89+
```
90+
6791
## Environment Variables
6892

6993
The following environment variables are required for integration tests and examples:

extend/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.2.2"
1+
__version__ = "2.0.0"

extend/auth.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import base64
2+
from abc import ABC, abstractmethod
3+
from typing import Dict
4+
5+
from .config import API_VERSION
6+
7+
8+
class Authorization(ABC):
9+
@abstractmethod
10+
def get_auth_headers(self) -> Dict[str, str]:
11+
pass
12+
13+
14+
class BasicAuth(Authorization):
15+
def __init__(self, api_key: str, api_secret: str, api_version: str = API_VERSION):
16+
self.api_key = api_key
17+
self.api_secret = api_secret
18+
self.api_version = api_version
19+
20+
def get_auth_headers(self) -> Dict[str, str]:
21+
return {
22+
"Authorization": f"Basic {base64.b64encode(f"{self.api_key}:{self.api_secret}".encode()).decode()}",
23+
"x-extend-api-key": self.api_key,
24+
"Accept": self.api_version,
25+
}
26+
27+
28+
class BearerAuth(Authorization):
29+
def __init__(self, jwt_token: str, api_version: str = API_VERSION):
30+
self.jwt_token = jwt_token
31+
self.api_version = api_version
32+
33+
def get_auth_headers(self) -> Dict[str, str]:
34+
return {"Authorization": f"Bearer {self.jwt_token}", "Accept": self.api_version}

extend/client.py

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,45 @@
1-
import base64
21
from typing import Optional, Dict, Any
32

43
import httpx
54

6-
from .config import API_HOST, API_VERSION
5+
from .auth import Authorization
6+
from .config import API_HOST
77

88

99
class APIClient:
1010
"""Client for interacting with the Extend API.
1111
1212
Args:
13-
api_key (str): Your Extend API key
14-
api_secret (str): Your Extend API secret
13+
auth (Authorization): Authorization strategy that yields request headers.
1514
1615
Example:
1716
```python
18-
client = ExtendAPI(api_key="your_key", api_secret="your_secret")
17+
from extend.auth import BasicAuth
18+
19+
client = APIClient(auth=BasicAuth("your_key", "your_secret"))
1920
cards = await client.get_virtual_cards()
2021
```
2122
"""
2223

2324
_shared_instance: Optional["APIClient"] = None
2425

25-
def __init__(self, api_key: str, api_secret: str):
26+
def __init__(self, auth: Authorization):
2627
"""Initialize the Extend API client.
27-
28+
2829
Args:
29-
api_key (str): Your Extend API key
30-
api_secret (str): Your Extend API secret
30+
auth (Authorization): Authorization strategy to use for requests.
3131
"""
32-
auth_value = base64.b64encode(f"{api_key}:{api_secret}".encode()).decode()
33-
self.headers = {
34-
"x-extend-api-key": api_key,
35-
"Authorization": f"Basic {auth_value}",
36-
"Accept": API_VERSION
37-
}
32+
headers = dict(auth.get_auth_headers())
33+
34+
self._auth = auth
35+
self.headers = headers
3836

3937
@classmethod
40-
def shared_instance(cls, api_key: Optional[str] = None, api_secret: Optional[str] = None) -> "APIClient":
41-
"""
42-
Returns a singleton instance of APIClient. On first call, you must provide both
43-
api_key and api_secret. Subsequent calls return the same instance.
44-
"""
38+
def shared_instance(cls, auth: Authorization) -> "APIClient":
39+
"""Returns a singleton instance of APIClient using the provided authorization."""
4540
if cls._shared_instance is None:
46-
if api_key is None or api_secret is None:
47-
raise ValueError("API key and API secret must be provided on the first call to global_instance.")
48-
cls._global_instance = cls(api_key, api_secret)
49-
return cls._global_instance
41+
cls._shared_instance = cls(auth=auth)
42+
return cls._shared_instance
5043

5144
# ----------------------------------------
5245
# HTTP Methods

extend/extend.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from extend.resources.virtual_cards import VirtualCards
2+
from .auth import Authorization
23
from .client import APIClient
34
from .resources.credit_cards import CreditCards
45
from .resources.expense_data import ExpenseData
@@ -11,24 +12,24 @@ class ExtendClient:
1112
"""Wrapper around Extend API
1213
1314
Args:
14-
api_key (str): Your Extend API key
15-
api_secret (str): Your Extend API secret
15+
auth (Authorization): Authorization instance shared with the internal API client.
1616
1717
Example:
1818
```python
19-
extend = ExtendClient(api_key="your_key", api_secret="your_secret")
19+
from extend.auth import BasicAuth
20+
21+
extend = ExtendClient(auth=BasicAuth("your_key", "your_secret"))
2022
cards = await extend.get_virtual_cards()
2123
```
2224
"""
2325

24-
def __init__(self, api_key: str, api_secret: str):
26+
def __init__(self, auth: Authorization):
2527
"""Initialize the Extend Client.
2628
2729
Args:
28-
api_key (str): Your Extend API key
29-
api_secret (str): Your Extend API secret
30+
auth (Authorization): Authorization strategy shared with the underlying API client.
3031
"""
31-
self._api_client = APIClient(api_key=api_key, api_secret=api_secret)
32+
self._api_client = APIClient(auth=auth)
3233
self.credit_cards = CreditCards(self._api_client)
3334
self.virtual_cards = VirtualCards(self._api_client)
3435
self.transactions = Transactions(self._api_client)

tests/simple_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
from dotenv import load_dotenv
55

66
from extend import ExtendClient
7+
from extend.auth import BasicAuth
78

89
load_dotenv()
910

1011
# Initialize the client
1112
api_key = os.getenv("EXTEND_API_KEY")
1213
api_secret = os.getenv("EXTEND_API_SECRET")
13-
extend = ExtendClient(api_key, api_secret)
14+
extend = ExtendClient(auth=BasicAuth(api_key, api_secret))
1415

1516

1617
async def test_virtual_cards():

tests/test_client.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import os
23
from datetime import datetime, timedelta
34
from typing import Any, Dict, List
@@ -7,14 +8,41 @@
78
from dotenv import load_dotenv
89

910
from extend import VirtualCard, Transaction, validations, ExtendClient
11+
from extend.auth import BearerAuth, BasicAuth
12+
from extend.client import APIClient
13+
from extend.config import API_VERSION
1014

1115
load_dotenv()
1216

1317

18+
def test_api_client_accepts_auth_instance():
19+
auth = BearerAuth(jwt_token="test-token")
20+
client = APIClient(auth=auth)
21+
22+
assert client.headers["Authorization"] == "Bearer test-token"
23+
assert client.headers["Accept"] == API_VERSION
24+
25+
26+
def test_api_client_basic_auth_from_keys():
27+
auth = BasicAuth("key", "secret")
28+
client = APIClient(auth=auth)
29+
30+
expected_basic = base64.b64encode(b"key:secret").decode()
31+
assert client.headers["x-extend-api-key"] == "key"
32+
assert client.headers["Authorization"] == f"Basic {expected_basic}"
33+
34+
35+
def test_api_client_requires_auth_instance():
36+
with pytest.raises(TypeError):
37+
APIClient()
38+
39+
1440
@pytest.fixture(scope="session")
1541
def extend():
1642
# Initialize the API client
17-
return ExtendClient(os.getenv("EXTEND_API_KEY"), os.getenv("EXTEND_API_SECRET"))
43+
api_key = os.getenv("EXTEND_API_KEY", "test-key")
44+
api_secret = os.getenv("EXTEND_API_SECRET", "test-secret")
45+
return ExtendClient(auth=BasicAuth(api_key, api_secret))
1846

1947

2048
@pytest.fixture
@@ -272,7 +300,7 @@ async def test_get_transactions_receipt_missing_param(extend, mocker, mock_trans
272300
assert mock_get.call_count == 1
273301
_, params = mock_get.call_args[0]
274302
assert params["receiptMissing"] is True
275-
assert params["receiptStatus"] == "Missing"
303+
assert params["receiptStatus"] == ["Missing"]
276304

277305

278306
@pytest.mark.asyncio

tests/test_integration.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from dotenv import load_dotenv
1010

1111
from extend import ExtendClient
12+
from extend.auth import BasicAuth
1213

1314
load_dotenv()
1415

@@ -29,7 +30,7 @@ def extend():
2930
"""Create a real API client for integration testing"""
3031
api_key = os.getenv("EXTEND_API_KEY")
3132
api_secret = os.getenv("EXTEND_API_SECRET")
32-
return ExtendClient(api_key, api_secret)
33+
return ExtendClient(auth=BasicAuth(api_key, api_secret))
3334

3435

3536
@pytest.fixture(scope="session")

0 commit comments

Comments
 (0)