Skip to content

Commit 95d3043

Browse files
author
Vito Meznaric
committed
Accept int transaction ids in invoices.download and close remaining test clients
Transaction.id is an int, so invoices.download now takes int | str instead of forcing callers to stringify (verified against the live API — the query param serializes identically). Also converted the remaining bare client instantiations in test_client.py and test_errors.py to context managers, and added tests for conflicting credentials, explicit timeout=None, and redirect following. Claude-Session: https://claude.ai/code/session_018LB1vKDSPQRHAFPowUmkeR
1 parent 50a176e commit 95d3043

3 files changed

Lines changed: 129 additions & 87 deletions

File tree

‎src/webshare/resources/invoices.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from webshare._requester import AsyncResource, SyncResource
99

1010

11-
def _download_spec(*, subscription_transaction_id: str) -> RequestSpec:
11+
def _download_spec(*, subscription_transaction_id: int | str) -> RequestSpec:
1212
# Note: this path has no trailing slash, unlike most endpoints.
1313
return RequestSpec(
1414
method="GET",
@@ -21,14 +21,18 @@ class Invoices(SyncResource):
2121
def download(
2222
self,
2323
*,
24-
subscription_transaction_id: str,
24+
subscription_transaction_id: int | str,
2525
timeout: float | None = None,
2626
headers: Mapping[str, str] | None = None,
2727
max_retries: int | None = None,
2828
subuser_id: int | str | None = None,
2929
federated_user_id: int | str | None = None,
3030
) -> bytes:
31-
"""Download an invoice as PDF bytes."""
31+
"""Download an invoice as PDF bytes.
32+
33+
``subscription_transaction_id`` accepts the ``Transaction.id`` integer
34+
directly (sent as a string query parameter either way).
35+
"""
3236
return self._client.request_bytes(
3337
_download_spec(subscription_transaction_id=subscription_transaction_id).with_options(
3438
timeout, headers, max_retries, subuser_id, federated_user_id
@@ -40,14 +44,18 @@ class AsyncInvoices(AsyncResource):
4044
async def download(
4145
self,
4246
*,
43-
subscription_transaction_id: str,
47+
subscription_transaction_id: int | str,
4448
timeout: float | None = None,
4549
headers: Mapping[str, str] | None = None,
4650
max_retries: int | None = None,
4751
subuser_id: int | str | None = None,
4852
federated_user_id: int | str | None = None,
4953
) -> bytes:
50-
"""Download an invoice as PDF bytes."""
54+
"""Download an invoice as PDF bytes.
55+
56+
``subscription_transaction_id`` accepts the ``Transaction.id`` integer
57+
directly (sent as a string query parameter either way).
58+
"""
5159
return await self._client.request_bytes(
5260
_download_spec(subscription_transaction_id=subscription_transaction_id).with_options(
5361
timeout, headers, max_retries, subuser_id, federated_user_id

‎tests/test_client.py‎

Lines changed: 82 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ def make_client(server: MockServer, **kwargs: object) -> Webshare:
1818

1919
def test_auth_header_and_defaults(server: MockServer) -> None:
2020
server.enqueue(json_body=PROFILE)
21-
client = make_client(server)
22-
profile = client.profile.get()
21+
with make_client(server) as client:
22+
profile = client.profile.get()
2323
assert profile.id == 1
2424
assert profile.email == "user@webshare.io"
2525
request = server.requests[0]
@@ -33,8 +33,8 @@ def test_auth_header_and_defaults(server: MockServer) -> None:
3333
def test_api_key_from_environment(server: MockServer, monkeypatch: pytest.MonkeyPatch) -> None:
3434
monkeypatch.setenv("WEBSHARE_API_KEY", "env-key")
3535
server.enqueue(json_body=PROFILE)
36-
client = Webshare(base_url=server.base_url)
37-
client.profile.get()
36+
with Webshare(base_url=server.base_url) as client:
37+
client.profile.get()
3838
assert server.requests[0].headers["Authorization"] == "Token env-key"
3939

4040

@@ -46,13 +46,20 @@ def test_missing_credentials_raises(monkeypatch: pytest.MonkeyPatch) -> None:
4646
AsyncWebshare()
4747

4848

49+
def test_conflicting_credentials_raise(server: MockServer) -> None:
50+
with pytest.raises(webshare.WebshareError, match="not both"):
51+
Webshare(base_url=server.base_url, api_key="k", credentials_provider=lambda: "t")
52+
with pytest.raises(webshare.WebshareError, match="not both"):
53+
AsyncWebshare(base_url=server.base_url, api_key="k", credentials_provider=lambda: "t")
54+
55+
4956
def test_credentials_provider_called_per_request(server: MockServer) -> None:
5057
tokens = iter(["token-1", "token-2"])
51-
client = Webshare(base_url=server.base_url, credentials_provider=lambda: next(tokens))
52-
server.enqueue(json_body=PROFILE)
53-
server.enqueue(json_body=PROFILE)
54-
client.profile.get()
55-
client.profile.get()
58+
with Webshare(base_url=server.base_url, credentials_provider=lambda: next(tokens)) as client:
59+
server.enqueue(json_body=PROFILE)
60+
server.enqueue(json_body=PROFILE)
61+
client.profile.get()
62+
client.profile.get()
5663
assert server.requests[0].headers["Authorization"] == "Token token-1"
5764
assert server.requests[1].headers["Authorization"] == "Token token-2"
5865

@@ -61,41 +68,40 @@ async def test_async_credentials_provider(server: MockServer) -> None:
6168
async def provider() -> str:
6269
return "async-token"
6370

64-
client = AsyncWebshare(base_url=server.base_url, credentials_provider=provider)
65-
server.enqueue(json_body=PROFILE)
66-
profile = await client.profile.get()
71+
async with AsyncWebshare(base_url=server.base_url, credentials_provider=provider) as client:
72+
server.enqueue(json_body=PROFILE)
73+
profile = await client.profile.get()
6774
assert profile.email == "user@webshare.io"
6875
assert server.requests[0].headers["Authorization"] == "Token async-token"
69-
await client.close()
7076

7177

7278
def test_default_and_per_request_headers(server: MockServer) -> None:
73-
client = make_client(server, default_headers={"X-Team": "infra"})
74-
server.enqueue(json_body=PROFILE)
75-
client.profile.get(headers={"X-Trace": "abc"})
79+
with make_client(server, default_headers={"X-Team": "infra"}) as client:
80+
server.enqueue(json_body=PROFILE)
81+
client.profile.get(headers={"X-Trace": "abc"})
7682
request = server.requests[0]
7783
assert request.headers["X-Team"] == "infra"
7884
assert request.headers["X-Trace"] == "abc"
7985

8086

8187
def test_subuser_and_federated_headers(server: MockServer) -> None:
82-
client = make_client(server, subuser_id=7, federated_user_id=99)
83-
server.enqueue(json_body={"count": 0, "next": None, "previous": None, "results": []})
84-
server.enqueue(json_body={"count": 0, "next": None, "previous": None, "results": []})
85-
client.proxies.list(mode="direct")
86-
assert server.requests[0].headers["X-Subuser"] == "7"
87-
assert server.requests[0].headers["X-Webshare-Federated-Access"] == "99"
88-
# Per-request values override client-level values.
89-
client.proxies.list(mode="direct", subuser_id=8, federated_user_id=100)
90-
assert server.requests[1].headers["X-Subuser"] == "8"
91-
assert server.requests[1].headers["X-Webshare-Federated-Access"] == "100"
88+
with make_client(server, subuser_id=7, federated_user_id=99) as client:
89+
server.enqueue(json_body={"count": 0, "next": None, "previous": None, "results": []})
90+
server.enqueue(json_body={"count": 0, "next": None, "previous": None, "results": []})
91+
client.proxies.list(mode="direct")
92+
assert server.requests[0].headers["X-Subuser"] == "7"
93+
assert server.requests[0].headers["X-Webshare-Federated-Access"] == "99"
94+
# Per-request values override client-level values.
95+
client.proxies.list(mode="direct", subuser_id=8, federated_user_id=100)
96+
assert server.requests[1].headers["X-Subuser"] == "8"
97+
assert server.requests[1].headers["X-Webshare-Federated-Access"] == "100"
9298

9399

94100
def test_timeout_raises_api_timeout_error(server: MockServer) -> None:
95101
server.enqueue(json_body=PROFILE, delay=1.0)
96-
client = make_client(server, timeout=0.1, max_retries=0)
97-
with pytest.raises(webshare.APITimeoutError):
98-
client.profile.get()
102+
with make_client(server, timeout=0.1, max_retries=0) as client:
103+
with pytest.raises(webshare.APITimeoutError):
104+
client.profile.get()
99105

100106

101107
async def test_async_timeout(server: MockServer) -> None:
@@ -105,6 +111,25 @@ async def test_async_timeout(server: MockServer) -> None:
105111
await client.profile.get(max_retries=0)
106112

107113

114+
def test_timeout_none_is_distinct_from_omitted() -> None:
115+
# Omitted -> the 60s default; explicit None -> no timeout at all.
116+
with Webshare(api_key="k") as default_client:
117+
assert default_client.timeout == 60.0
118+
with Webshare(api_key="k", timeout=None) as no_timeout_client:
119+
assert no_timeout_client.timeout is None
120+
121+
122+
def test_redirects_are_followed(server: MockServer) -> None:
123+
# Owned httpx clients follow redirects, so a 3xx surfaces the real
124+
# response instead of a confusing decode error.
125+
server.enqueue(status=302, headers={"Location": f"{server.base_url}/api/v2/profile/"})
126+
server.enqueue(json_body=PROFILE)
127+
with make_client(server) as client:
128+
assert client.profile.get().id == 1
129+
assert len(server.requests) == 2
130+
assert server.requests[1].path == "/api/v2/profile/"
131+
132+
108133
def test_context_manager_and_base_url_join(server: MockServer) -> None:
109134
server.enqueue(json_body=PROFILE)
110135
with Webshare(base_url=server.base_url + "/", api_key="k") as client:
@@ -126,57 +151,58 @@ def test_empty_api_key_falls_back_to_environment(
126151
) -> None:
127152
monkeypatch.setenv("WEBSHARE_API_KEY", "env-key")
128153
server.enqueue(json_body=PROFILE)
129-
client = Webshare(base_url=server.base_url, api_key="")
130-
client.profile.get()
154+
with Webshare(base_url=server.base_url, api_key="") as client:
155+
client.profile.get()
131156
assert server.requests[0].headers["Authorization"] == "Token env-key"
132157

133158

134159
def test_unauthenticated_operations_never_send_token(server: MockServer) -> None:
135160
# Even on a credentialed client, `security: []` operations do not send
136161
# the Authorization header.
137162
server.enqueue(json_body={"referral_code": "abc", "promo_type": None, "promo_value": None})
138-
client = make_client(server)
139-
info = client.referral.get_code_info(referral_code="abc")
163+
with make_client(server) as client:
164+
info = client.referral.get_code_info(referral_code="abc")
140165
assert info.referral_code == "abc"
141166
assert "Authorization" not in server.requests[0].headers
142167

143168

144169
def test_unauthenticated_client(server: MockServer, monkeypatch: pytest.MonkeyPatch) -> None:
145170
monkeypatch.delenv("WEBSHARE_API_KEY", raising=False)
146-
client = Webshare(base_url=server.base_url, unauthenticated=True)
147-
server.enqueue(json_body={"referral_code": "abc", "promo_type": None, "promo_value": None})
148-
client.referral.get_code_info(referral_code="abc")
149-
assert "Authorization" not in server.requests[0].headers
150-
# Authenticated operations fail client-side with a clear message.
151-
with pytest.raises(webshare.WebshareError, match="unauthenticated=True"):
152-
client.profile.get()
171+
with Webshare(base_url=server.base_url, unauthenticated=True) as client:
172+
server.enqueue(json_body={"referral_code": "abc", "promo_type": None, "promo_value": None})
173+
client.referral.get_code_info(referral_code="abc")
174+
assert "Authorization" not in server.requests[0].headers
175+
# Authenticated operations fail client-side with a clear message.
176+
with pytest.raises(webshare.WebshareError, match="unauthenticated=True"):
177+
client.profile.get()
153178
assert len(server.requests) == 1
154179

155180

156181
def test_source_header_default_format(server: MockServer) -> None:
157182
import re
158183

159184
server.enqueue(json_body=PROFILE)
160-
make_client(server).profile.get()
185+
with make_client(server) as client:
186+
client.profile.get()
161187
source = server.requests[0].headers["X-Webshare-Source"]
162188
assert re.fullmatch(r"WebshareSDK/\d+\.\d+\.\d+ \(Python; \d+\.\d+\.\d+[^)]*\)", source), source
163189

164190

165191
def test_source_header_override(server: MockServer) -> None:
166192
server.enqueue(json_body=PROFILE)
167193
server.enqueue(json_body=PROFILE)
168-
client = make_client(server, source="WebshareCLI/1.2.3")
169-
client.profile.get()
170-
assert server.requests[0].headers["X-Webshare-Source"] == "WebshareCLI/1.2.3"
171-
# Per-request headers still win over everything.
172-
client.profile.get(headers={"X-Webshare-Source": "custom/0"})
173-
assert server.requests[1].headers["X-Webshare-Source"] == "custom/0"
194+
with make_client(server, source="WebshareCLI/1.2.3") as client:
195+
client.profile.get()
196+
assert server.requests[0].headers["X-Webshare-Source"] == "WebshareCLI/1.2.3"
197+
# Per-request headers still win over everything.
198+
client.profile.get(headers={"X-Webshare-Source": "custom/0"})
199+
assert server.requests[1].headers["X-Webshare-Source"] == "custom/0"
174200

175201

176202
def test_default_headers_merge_case_insensitively(server: MockServer) -> None:
177203
server.enqueue(json_body=PROFILE)
178-
client = make_client(server, default_headers={"accept": "text/plain"})
179-
client.profile.get()
204+
with make_client(server, default_headers={"accept": "text/plain"}) as client:
205+
client.profile.get()
180206
request = server.requests[0]
181207
accept_headers = [(k, v) for k, v in request.raw_headers if k.lower() == "accept"]
182208
assert accept_headers == [("accept", "text/plain")]
@@ -187,9 +213,9 @@ def test_injected_http_client_timeout_is_respected(server: MockServer) -> None:
187213

188214
server.enqueue(json_body=PROFILE, delay=1.0)
189215
http_client = httpx.Client(timeout=0.1)
190-
client = Webshare(base_url=server.base_url, api_key="k", http_client=http_client)
191-
with pytest.raises(webshare.APITimeoutError):
192-
client.profile.get(max_retries=0)
216+
with Webshare(base_url=server.base_url, api_key="k", http_client=http_client) as client:
217+
with pytest.raises(webshare.APITimeoutError):
218+
client.profile.get(max_retries=0)
193219
http_client.close()
194220

195221

@@ -198,6 +224,8 @@ def test_explicit_timeout_overrides_injected_http_client(server: MockServer) ->
198224

199225
server.enqueue(json_body=PROFILE, delay=0.3)
200226
http_client = httpx.Client(timeout=0.05)
201-
client = Webshare(base_url=server.base_url, api_key="k", http_client=http_client, timeout=5.0)
202-
assert client.profile.get(max_retries=0).id == 1
227+
with Webshare(
228+
base_url=server.base_url, api_key="k", http_client=http_client, timeout=5.0
229+
) as client:
230+
assert client.profile.get(max_retries=0).id == 1
203231
http_client.close()

0 commit comments

Comments
 (0)