diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..1d4331827 --- /dev/null +++ b/.claude/CLAUDE.md @@ -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. diff --git a/.vscode/settings.json b/.vscode/settings.json index 59e67bec4..3104ff54e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,7 +14,7 @@ "venv": true }, // uses default venv name from Makefile - "python.defaultInterpreterPath": "./venv/bin/python", + "python.defaultInterpreterPath": "", // Formatting "editor.formatOnSave": true, diff --git a/stripe/_api_requestor.py b/stripe/_api_requestor.py index fecb840b2..3e864931f 100644 --- a/stripe/_api_requestor.py +++ b/stripe/_api_requestor.py @@ -1,5 +1,6 @@ from io import BytesIO, IOBase import json +import os import platform from typing import ( Any, @@ -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 ): @@ -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", @@ -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), diff --git a/tests/test_api_requestor.py b/tests/test_api_requestor.py index d56ffc7f5..17173c016 100644 --- a/tests/test_api_requestor.py +++ b/tests/test_api_requestor.py @@ -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( @@ -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 ):