Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# stripe-python

## Testing

- Run all tests: `just test`
- Run a specific test by name: `just test-one test_name`
- Run a specific test file: `just test tests/path/to/test_file.py`

## Formatting & Linting

- Format: `just format` (uses ruff)
- Lint: `just lint` (uses flake8)
- Typecheck: `just typecheck` (uses pyright)

## Key Locations

- HTTP client (request execution, retries, headers): `stripe/_http_client.py`
- Main client class: `stripe/_stripe_client.py`
- Client options/config: `stripe/_client_options.py`
- API requestor (request building, auth): `stripe/_api_requestor.py`

## Generated Code

- Files containing `File generated from our OpenAPI spec` at the top are generated; do not edit. Similarly, any code block starting with `The beginning of the section generated from our OpenAPI spec` is generated and should not be edited directly.
- If something in a generated file/range needs to be updated, add a summary of the change to your report but don't attempt to edit it directly.
- Most files under `stripe/` resource subdirectories (e.g. `stripe/_customer.py`, `stripe/params/`, `stripe/resources/`) are generated.
- The HTTP client layer (`_http_client.py`, `_stripe_client.py`, `_api_requestor.py`, `_client_options.py`) is NOT generated.

## Conventions

- Uses `requests` library by default for sync HTTP, `httpx` for async
- Type hints throughout
- Virtual env managed in `.venv/`; `just` recipes handle setup automatically
- Work is not complete until `just test`, `just lint` and `just typecheck` complete successfully.
- All code must run on all supported Python versions (full list in the test section of @.github/workflows/ci.yml)

### Comments

- Comments MUST only be used to:
1. Document a function
2. Explain the WHY of a piece of code
3. Explain a particularly complicated piece of code
- Comments NEVER should be used to:
1. Say what used to be there. That's no longer relevant!
2. Explain the WHAT of a piece of code (unless it's very non-obvious)

It's ok not to put comments on/in a function if their addition wouldn't meaningfully clarify anything.
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"venv": true
},
// uses default venv name from Makefile
"python.defaultInterpreterPath": "./venv/bin/python",
"python.defaultInterpreterPath": "",

// Formatting
"editor.formatOnSave": true,
Expand Down
24 changes: 24 additions & 0 deletions stripe/_api_requestor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from io import BytesIO, IOBase
import json
import os
import platform
from typing import (
Any,
Expand Down Expand Up @@ -505,6 +506,23 @@ def specific_oauth_error(self, rbody, rcode, resp, rheaders, error_code):

return None

AI_AGENTS = [
("ANTIGRAVITY_CLI_ALIAS", "antigravity"),
("CLAUDECODE", "claude_code"),
("CLINE_ACTIVE", "cline"),
("CODEX_SANDBOX", "codex_cli"),
("CURSOR_AGENT", "cursor"),
("GEMINI_CLI", "gemini_cli"),
("OPENCODE", "open_code"),
]

@staticmethod
def _detect_ai_agent(environ: Mapping[str, str]) -> str:
for env_var, agent_name in _APIRequestor.AI_AGENTS:
if environ.get(env_var):
return agent_name
return ""

def request_headers(
self, method: HttpVerb, api_mode: ApiMode, options: RequestOptions
):
Expand All @@ -515,6 +533,10 @@ def request_headers(
if stripe.app_info:
user_agent += " " + self._format_app_info(stripe.app_info)

agent = self._detect_ai_agent(os.environ)
if agent:
user_agent += " AIAgent/" + agent

ua: Dict[str, Union[str, "AppInfo"]] = {
"bindings_version": VERSION,
"lang": "python",
Expand All @@ -533,6 +555,8 @@ def request_headers(
ua[attr] = val
if stripe.app_info:
ua["application"] = stripe.app_info
if agent:
ua["ai_agent"] = agent

headers: Dict[str, str] = {
"X-Stripe-Client-User-Agent": json.dumps(ua),
Expand Down
39 changes: 38 additions & 1 deletion tests/test_api_requestor.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,8 @@ def test_add_beta_version(self):
== "2024-02-26; feature_beta=v4; another_feature_beta=v2"
)

def test_uses_app_info(self, requestor, http_client_mock):
def test_uses_app_info(self, requestor, mocker, http_client_mock):
mocker.patch.object(_APIRequestor, "_detect_ai_agent", return_value="")
try:
old = stripe.app_info
stripe.set_app_info(
Expand Down Expand Up @@ -731,6 +732,42 @@ def test_uses_app_info(self, requestor, http_client_mock):
finally:
stripe.app_info = old

def test_detect_ai_agent(self):
assert (
_APIRequestor._detect_ai_agent({"CLAUDECODE": "1"})
== "claude_code"
)

def test_detect_ai_agent_no_env_vars(self):
assert _APIRequestor._detect_ai_agent({}) == ""

def test_detect_ai_agent_first_match_wins(self):
assert (
_APIRequestor._detect_ai_agent(
{"CURSOR_AGENT": "1", "OPENCODE": "1"}
)
== "cursor"
)

def test_ai_agent_included_in_request_headers(
self, requestor, mocker, http_client_mock
):
mocker.patch.object(
_APIRequestor, "_detect_ai_agent", return_value="cursor"
)
http_client_mock.stub_request(
"get", path=self.v1_path, rbody="{}", rcode=200
)
requestor.request("get", self.v1_path, {}, base_address="api")

last_call = http_client_mock.get_last_call()
ua = last_call.get_raw_header("User-Agent")
assert ua.endswith(" AIAgent/cursor")
client_ua = json.loads(
last_call.get_raw_header("X-Stripe-Client-User-Agent")
)
assert client_ua["ai_agent"] == "cursor"

def test_handles_failed_platform_call(
self, requestor, mocker, http_client_mock
):
Expand Down