pydantic-claude-code 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/PKG-INFO +39 -8
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/README.md +37 -7
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/examples/basic.py +2 -2
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/pyproject.toml +2 -1
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/__init__.py +4 -2
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/flow.py +2 -2
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/provider.py +6 -6
- pydantic_claude_code-0.2.0/src/pydantic_ai_claude_code/storage.py +135 -0
- pydantic_claude_code-0.2.0/tests/test_keyring_store.py +77 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_storage.py +2 -2
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/storage.py +0 -50
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/.gitignore +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/LICENSE +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/__main__.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/auth.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/config.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/credentials.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/model.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/py.typed +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_callback_server.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_credentials.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_flow.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_integration.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_model.py +0 -0
- {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_provider.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pydantic-claude-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Use your Claude Code subscription from a Pydantic AI Agent, with full tool support
|
|
5
5
|
Project-URL: Repository, https://github.com/mpfaffenberger/pydantic-ai-claude-code
|
|
6
6
|
Project-URL: Homepage, https://pypi.org/project/pydantic-claude-code/
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Requires-Python: <3.15,>=3.11
|
|
17
|
+
Requires-Dist: keyring<26,>=23
|
|
17
18
|
Requires-Dist: pydantic-ai-slim[anthropic]<3,>=2.31.0
|
|
18
19
|
Provides-Extra: dev
|
|
19
20
|
Requires-Dist: pytest-asyncio>=0.23.1; extra == 'dev'
|
|
@@ -36,8 +37,8 @@ The repo is `mpfaffenberger/pydantic-ai-claude-code` and the import is
|
|
|
36
37
|
pydantic-ai gained a Codex OAuth path where `openai-codex:gpt-6-astra` just
|
|
37
38
|
works against a ChatGPT subscription. This wheel brings the same experience to
|
|
38
39
|
Claude: authenticate once, then run pydantic-ai agents against your Claude
|
|
39
|
-
subscription, using `claude-sonnet-
|
|
40
|
-
subscribe to.
|
|
40
|
+
subscription, using `claude-fable-5-1`, `claude-sonnet-5`, `claude-opus-5`, or
|
|
41
|
+
whatever model you subscribe to.
|
|
41
42
|
|
|
42
43
|
Two deliberate design choices distinguish this from a fork of pydantic-ai:
|
|
43
44
|
|
|
@@ -64,7 +65,7 @@ async def main() -> None:
|
|
|
64
65
|
await login()
|
|
65
66
|
|
|
66
67
|
provider = ClaudeCodeProvider() # loads the stored tokens
|
|
67
|
-
agent = Agent(provider.model('claude-
|
|
68
|
+
agent = Agent(provider.model('claude-fable-5-1'))
|
|
68
69
|
|
|
69
70
|
result = await agent.run('Say hi in three words.')
|
|
70
71
|
print(result.data)
|
|
@@ -78,7 +79,7 @@ asyncio.run(main())
|
|
|
78
79
|
from pydantic_ai import Agent
|
|
79
80
|
|
|
80
81
|
provider = ClaudeCodeProvider()
|
|
81
|
-
agent = Agent(provider.model('claude-
|
|
82
|
+
agent = Agent(provider.model('claude-fable-5-1'))
|
|
82
83
|
|
|
83
84
|
@agent.tool_plain
|
|
84
85
|
def add(a: int, b: int) -> int:
|
|
@@ -90,6 +91,32 @@ Tools defined on the agent are passed to the API as standard Anthropic tool
|
|
|
90
91
|
definitions, and structured output works the same way as with the built-in
|
|
91
92
|
`anthropic` provider. That's the whole point of the wheel.
|
|
92
93
|
|
|
94
|
+
### Structured output
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from pydantic import BaseModel
|
|
98
|
+
from pydantic_ai import Agent
|
|
99
|
+
|
|
100
|
+
class Weather(BaseModel):
|
|
101
|
+
city: str
|
|
102
|
+
temperature_c: float
|
|
103
|
+
|
|
104
|
+
agent = Agent(ClaudeCodeProvider().model('claude-fable-5-1'), output_type=Weather)
|
|
105
|
+
result = await agent.run('Weather in Paris right now?')
|
|
106
|
+
assert result.output.city == 'Paris'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Streaming
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from pydantic_ai import Agent
|
|
113
|
+
|
|
114
|
+
agent = Agent(ClaudeCodeProvider().model('claude-fable-5-1'))
|
|
115
|
+
async with agent.run_stream('Count from 1 to 3.') as stream:
|
|
116
|
+
async for chunk in stream.stream_text():
|
|
117
|
+
print(chunk, end='', flush=True)
|
|
118
|
+
```
|
|
119
|
+
|
|
93
120
|
## How auth works
|
|
94
121
|
|
|
95
122
|
The flow uses the same shared OAuth client the Claude Code CLI uses:
|
|
@@ -98,14 +125,18 @@ The flow uses the same shared OAuth client the Claude Code CLI uses:
|
|
|
98
125
|
- Token URL: `https://platform.claude.com/v1/oauth/token`
|
|
99
126
|
- Scopes: `org:create_api_key user:profile user:inference`
|
|
100
127
|
|
|
101
|
-
Tokens are
|
|
128
|
+
Tokens are saved to the OS keyring by default: the Keychain on macOS, Credential
|
|
129
|
+
Manager on Windows, and Secret Service on Linux. On machines without a keychain,
|
|
130
|
+
credentials fall back to a JSON file (overridable via `CLAUDE_CODE_AUTH_FILE`):
|
|
102
131
|
|
|
103
132
|
```
|
|
104
133
|
~/.local/share/pydantic-ai-claude-code/auth.json
|
|
105
134
|
```
|
|
106
135
|
|
|
107
|
-
The file is written with `
|
|
108
|
-
issuer gave us. We never read the CLI's own credential files.
|
|
136
|
+
The fallback file is written with `0644` permissions and only ever contains what
|
|
137
|
+
the issuer gave us. We never read the CLI's own credential files.
|
|
138
|
+
|
|
139
|
+
Force a backend with the `CLAUDE_CODE_CREDENTIALS` env var: `keyring` or `file`.
|
|
109
140
|
|
|
110
141
|
Refreshes happen automatically in the background: the auth shim refreshes
|
|
111
142
|
before expiry and retries once on a 401, exactly like the codex provider does.
|
|
@@ -13,8 +13,8 @@ The repo is `mpfaffenberger/pydantic-ai-claude-code` and the import is
|
|
|
13
13
|
pydantic-ai gained a Codex OAuth path where `openai-codex:gpt-6-astra` just
|
|
14
14
|
works against a ChatGPT subscription. This wheel brings the same experience to
|
|
15
15
|
Claude: authenticate once, then run pydantic-ai agents against your Claude
|
|
16
|
-
subscription, using `claude-sonnet-
|
|
17
|
-
subscribe to.
|
|
16
|
+
subscription, using `claude-fable-5-1`, `claude-sonnet-5`, `claude-opus-5`, or
|
|
17
|
+
whatever model you subscribe to.
|
|
18
18
|
|
|
19
19
|
Two deliberate design choices distinguish this from a fork of pydantic-ai:
|
|
20
20
|
|
|
@@ -41,7 +41,7 @@ async def main() -> None:
|
|
|
41
41
|
await login()
|
|
42
42
|
|
|
43
43
|
provider = ClaudeCodeProvider() # loads the stored tokens
|
|
44
|
-
agent = Agent(provider.model('claude-
|
|
44
|
+
agent = Agent(provider.model('claude-fable-5-1'))
|
|
45
45
|
|
|
46
46
|
result = await agent.run('Say hi in three words.')
|
|
47
47
|
print(result.data)
|
|
@@ -55,7 +55,7 @@ asyncio.run(main())
|
|
|
55
55
|
from pydantic_ai import Agent
|
|
56
56
|
|
|
57
57
|
provider = ClaudeCodeProvider()
|
|
58
|
-
agent = Agent(provider.model('claude-
|
|
58
|
+
agent = Agent(provider.model('claude-fable-5-1'))
|
|
59
59
|
|
|
60
60
|
@agent.tool_plain
|
|
61
61
|
def add(a: int, b: int) -> int:
|
|
@@ -67,6 +67,32 @@ Tools defined on the agent are passed to the API as standard Anthropic tool
|
|
|
67
67
|
definitions, and structured output works the same way as with the built-in
|
|
68
68
|
`anthropic` provider. That's the whole point of the wheel.
|
|
69
69
|
|
|
70
|
+
### Structured output
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from pydantic import BaseModel
|
|
74
|
+
from pydantic_ai import Agent
|
|
75
|
+
|
|
76
|
+
class Weather(BaseModel):
|
|
77
|
+
city: str
|
|
78
|
+
temperature_c: float
|
|
79
|
+
|
|
80
|
+
agent = Agent(ClaudeCodeProvider().model('claude-fable-5-1'), output_type=Weather)
|
|
81
|
+
result = await agent.run('Weather in Paris right now?')
|
|
82
|
+
assert result.output.city == 'Paris'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Streaming
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from pydantic_ai import Agent
|
|
89
|
+
|
|
90
|
+
agent = Agent(ClaudeCodeProvider().model('claude-fable-5-1'))
|
|
91
|
+
async with agent.run_stream('Count from 1 to 3.') as stream:
|
|
92
|
+
async for chunk in stream.stream_text():
|
|
93
|
+
print(chunk, end='', flush=True)
|
|
94
|
+
```
|
|
95
|
+
|
|
70
96
|
## How auth works
|
|
71
97
|
|
|
72
98
|
The flow uses the same shared OAuth client the Claude Code CLI uses:
|
|
@@ -75,14 +101,18 @@ The flow uses the same shared OAuth client the Claude Code CLI uses:
|
|
|
75
101
|
- Token URL: `https://platform.claude.com/v1/oauth/token`
|
|
76
102
|
- Scopes: `org:create_api_key user:profile user:inference`
|
|
77
103
|
|
|
78
|
-
Tokens are
|
|
104
|
+
Tokens are saved to the OS keyring by default: the Keychain on macOS, Credential
|
|
105
|
+
Manager on Windows, and Secret Service on Linux. On machines without a keychain,
|
|
106
|
+
credentials fall back to a JSON file (overridable via `CLAUDE_CODE_AUTH_FILE`):
|
|
79
107
|
|
|
80
108
|
```
|
|
81
109
|
~/.local/share/pydantic-ai-claude-code/auth.json
|
|
82
110
|
```
|
|
83
111
|
|
|
84
|
-
The file is written with `
|
|
85
|
-
issuer gave us. We never read the CLI's own credential files.
|
|
112
|
+
The fallback file is written with `0644` permissions and only ever contains what
|
|
113
|
+
the issuer gave us. We never read the CLI's own credential files.
|
|
114
|
+
|
|
115
|
+
Force a backend with the `CLAUDE_CODE_CREDENTIALS` env var: `keyring` or `file`.
|
|
86
116
|
|
|
87
117
|
Refreshes happen automatically in the background: the auth shim refreshes
|
|
88
118
|
before expiry and retries once on a 401, exactly like the codex provider does.
|
|
@@ -11,12 +11,12 @@ import asyncio
|
|
|
11
11
|
|
|
12
12
|
from pydantic_ai import Agent
|
|
13
13
|
|
|
14
|
-
from pydantic_ai_claude_code import ClaudeCodeProvider,
|
|
14
|
+
from pydantic_ai_claude_code import ClaudeCodeProvider, default_store, login
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
async def main() -> None:
|
|
18
18
|
# Only run the browser flow when no usable credentials are stored yet.
|
|
19
|
-
if
|
|
19
|
+
if default_store().load() is None:
|
|
20
20
|
await login()
|
|
21
21
|
|
|
22
22
|
provider = ClaudeCodeProvider()
|
|
@@ -4,11 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pydantic-claude-code"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Use your Claude Code subscription from a Pydantic AI Agent, with full tool support"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11,<3.15"
|
|
11
11
|
dependencies = [
|
|
12
|
+
"keyring>=23,<26",
|
|
12
13
|
"pydantic-ai-slim[anthropic]>=2.31.0,<3",
|
|
13
14
|
]
|
|
14
15
|
license = { text = "MIT" }
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/__init__.py
RENAMED
|
@@ -6,7 +6,7 @@ Quick start:
|
|
|
6
6
|
from pydantic_ai_claude_code import ClaudeCodeProvider
|
|
7
7
|
|
|
8
8
|
provider = ClaudeCodeProvider()
|
|
9
|
-
agent = Agent(provider.model('claude-
|
|
9
|
+
agent = Agent(provider.model('claude-fable-5-1'))
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
from .credentials import ClaudeCodeCredentials
|
|
@@ -19,7 +19,7 @@ from .flow import (
|
|
|
19
19
|
)
|
|
20
20
|
from .model import ClaudeCodeModel
|
|
21
21
|
from .provider import ClaudeCodeProvider
|
|
22
|
-
from .storage import ClaudeCodeTokenStore, default_auth_path
|
|
22
|
+
from .storage import ClaudeCodeTokenStore, KeyringTokenStore, default_auth_path, default_store
|
|
23
23
|
|
|
24
24
|
__all__ = [
|
|
25
25
|
"ClaudeCodeCredentials",
|
|
@@ -27,7 +27,9 @@ __all__ = [
|
|
|
27
27
|
"ClaudeCodeOAuthFlow",
|
|
28
28
|
"ClaudeCodeProvider",
|
|
29
29
|
"ClaudeCodeTokenStore",
|
|
30
|
+
"KeyringTokenStore",
|
|
30
31
|
"default_auth_path",
|
|
32
|
+
"default_store",
|
|
31
33
|
"exchange_code",
|
|
32
34
|
"login",
|
|
33
35
|
"parse_pasteback",
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/flow.py
RENAMED
|
@@ -184,10 +184,10 @@ async def login(*, store: Any = None) -> ClaudeCodeCredentials:
|
|
|
184
184
|
Returns:
|
|
185
185
|
The credentials that were persisted.
|
|
186
186
|
"""
|
|
187
|
-
from .storage import
|
|
187
|
+
from .storage import default_store
|
|
188
188
|
|
|
189
189
|
if store is None:
|
|
190
|
-
store =
|
|
190
|
+
store = default_store()
|
|
191
191
|
server = start_login_callback_server()
|
|
192
192
|
try:
|
|
193
193
|
redirect_uri = f"{config.REDIRECT_HOST}:{server.server_address[1]}/{config.REDIRECT_PATH}"
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/provider.py
RENAMED
|
@@ -15,7 +15,7 @@ from . import config
|
|
|
15
15
|
from .model import ClaudeCodeModel
|
|
16
16
|
from .auth import _ClaudeCodeAuth
|
|
17
17
|
from .credentials import ClaudeCodeCredentials
|
|
18
|
-
from .storage import
|
|
18
|
+
from .storage import TokenStore, default_store
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class ClaudeCodeProvider(AnthropicProvider):
|
|
@@ -34,7 +34,7 @@ class ClaudeCodeProvider(AnthropicProvider):
|
|
|
34
34
|
self,
|
|
35
35
|
credentials: ClaudeCodeCredentials | None = None,
|
|
36
36
|
*,
|
|
37
|
-
store:
|
|
37
|
+
store: TokenStore | None = None,
|
|
38
38
|
on_credentials_refresh: Any = None,
|
|
39
39
|
http_client: httpx2.AsyncClient | None = None,
|
|
40
40
|
base_url: str | None = None,
|
|
@@ -43,7 +43,7 @@ class ClaudeCodeProvider(AnthropicProvider):
|
|
|
43
43
|
|
|
44
44
|
Args:
|
|
45
45
|
credentials: Credentials to use. When omitted, they are loaded from
|
|
46
|
-
the store (which defaults to the
|
|
46
|
+
the store (which defaults to the OS keyring, falling back to a file).
|
|
47
47
|
store: Token store used to persist refreshed credentials. When
|
|
48
48
|
credentials are loaded from the store, refreshes are persisted
|
|
49
49
|
back to it automatically.
|
|
@@ -54,7 +54,7 @@ class ClaudeCodeProvider(AnthropicProvider):
|
|
|
54
54
|
"""
|
|
55
55
|
if credentials is None:
|
|
56
56
|
if store is None:
|
|
57
|
-
store =
|
|
57
|
+
store = default_store()
|
|
58
58
|
creds = store.load()
|
|
59
59
|
if creds is None:
|
|
60
60
|
raise UserError(
|
|
@@ -98,9 +98,9 @@ class ClaudeCodeProvider(AnthropicProvider):
|
|
|
98
98
|
def from_token_store(
|
|
99
99
|
cls,
|
|
100
100
|
*,
|
|
101
|
-
store:
|
|
101
|
+
store: TokenStore | None = None,
|
|
102
102
|
http_client: httpx2.AsyncClient | None = None,
|
|
103
103
|
base_url: str | None = None,
|
|
104
104
|
) -> "ClaudeCodeProvider":
|
|
105
|
-
"""Build a provider from the
|
|
105
|
+
"""Build a provider from the default store."""
|
|
106
106
|
return cls(store=store, http_client=http_client, base_url=base_url)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Token storage for Claude Code credentials.
|
|
2
|
+
|
|
3
|
+
By default credentials live in the OS keyring: the Keychain on macOS, Credential
|
|
4
|
+
Manager on Windows, Secret Service on Linux. When no keychain is available, a
|
|
5
|
+
JSON file with `0644` permissions is used instead. Force a backend with the
|
|
6
|
+
`CLAUDE_CODE_CREDENTIALS` env var (`keyring` or `file`); the file path honors
|
|
7
|
+
`CLAUDE_CODE_AUTH_FILE`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from pydantic_ai.exceptions import UserError
|
|
18
|
+
|
|
19
|
+
from .credentials import ClaudeCodeCredentials
|
|
20
|
+
|
|
21
|
+
_FILE_PERMISSIONS = 0o644
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TokenStore(Protocol):
|
|
25
|
+
"""Sibling store backends must both implement this shape."""
|
|
26
|
+
|
|
27
|
+
def load(self) -> ClaudeCodeCredentials | None:
|
|
28
|
+
"""Return stored credentials, or `None` when nothing is stored yet."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def save(self, credentials: ClaudeCodeCredentials) -> None:
|
|
32
|
+
"""Persist credentials for the next run."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_auth_path() -> Path:
|
|
37
|
+
"""The fallback token file path, honoring the `CLAUDE_CODE_AUTH_FILE` override."""
|
|
38
|
+
env_path = os.environ.get("CLAUDE_CODE_AUTH_FILE")
|
|
39
|
+
if env_path:
|
|
40
|
+
return Path(env_path).expanduser()
|
|
41
|
+
data_dir = Path(os.environ.get("XDG_DATA_HOME", "") or (Path.home() / ".local" / "share"))
|
|
42
|
+
return data_dir / "pydantic-ai-claude-code" / "auth.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_store() -> TokenStore:
|
|
46
|
+
"""The default store: keyring when available, otherwise the JSON file."""
|
|
47
|
+
backend = os.environ.get("CLAUDE_CODE_CREDENTIALS", "auto").lower()
|
|
48
|
+
if backend not in ("auto", "keyring", "file"):
|
|
49
|
+
raise UserError(f"Unknown CLAUDE_CODE_CREDENTIALS backend {backend!r}; expected 'auto', 'keyring', or 'file'.")
|
|
50
|
+
if backend == "file":
|
|
51
|
+
return ClaudeCodeTokenStore()
|
|
52
|
+
if backend == "keyring" or _keyring_available():
|
|
53
|
+
return KeyringTokenStore()
|
|
54
|
+
return ClaudeCodeTokenStore()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ClaudeCodeTokenStore:
|
|
58
|
+
"""JSON-file store with `0644` permissions, the no-keychain fallback."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
61
|
+
self.path = Path(path) if path is not None else default_auth_path()
|
|
62
|
+
|
|
63
|
+
def load(self) -> ClaudeCodeCredentials | None:
|
|
64
|
+
"""Return stored credentials, or `None` when nothing is stored yet."""
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(self.path.read_text())
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
return None
|
|
69
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
70
|
+
raise UserError(f"Unable to read Claude Code credentials from {str(self.path)!r}.") from exc
|
|
71
|
+
return ClaudeCodeCredentials.from_token_file(data)
|
|
72
|
+
|
|
73
|
+
def save(self, credentials: ClaudeCodeCredentials) -> None:
|
|
74
|
+
"""Persist credentials atomically with `0644` permissions."""
|
|
75
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
tmp = self.path.with_suffix(".tmp")
|
|
77
|
+
tmp.write_text(json.dumps(credentials.to_wire_dict(), indent=2))
|
|
78
|
+
tmp.chmod(_FILE_PERMISSIONS)
|
|
79
|
+
os.replace(tmp, self.path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class KeyringTokenStore:
|
|
83
|
+
"""OS-keyring store backed by the `keyring` package.
|
|
84
|
+
|
|
85
|
+
The service name is `pydantic-ai-claude-code` and credentials are stored as
|
|
86
|
+
a single JSON blob under the `default` user name.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
SERVICE = "pydantic-ai-claude-code"
|
|
90
|
+
USERNAME = "default"
|
|
91
|
+
|
|
92
|
+
def __init__(self, service: str = SERVICE, username: str = USERNAME) -> None:
|
|
93
|
+
self.service = service
|
|
94
|
+
self.username = username
|
|
95
|
+
|
|
96
|
+
def load(self) -> ClaudeCodeCredentials | None:
|
|
97
|
+
"""Return stored credentials from the keyring, or `None` when absent."""
|
|
98
|
+
raw = self._keyring_get()
|
|
99
|
+
if not raw:
|
|
100
|
+
return None
|
|
101
|
+
return ClaudeCodeCredentials.from_token_file(json.loads(raw))
|
|
102
|
+
|
|
103
|
+
def save(self, credentials: ClaudeCodeCredentials) -> None:
|
|
104
|
+
"""Persist credentials to the keyring."""
|
|
105
|
+
self._keyring_require()
|
|
106
|
+
import keyring
|
|
107
|
+
|
|
108
|
+
keyring.set_password(self.service, self.username, json.dumps(credentials.to_wire_dict()))
|
|
109
|
+
|
|
110
|
+
def _keyring_get(self) -> str | None:
|
|
111
|
+
self._keyring_require()
|
|
112
|
+
import keyring
|
|
113
|
+
|
|
114
|
+
return keyring.get_password(self.service, self.username)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _keyring_require() -> None:
|
|
118
|
+
try:
|
|
119
|
+
import keyring
|
|
120
|
+
|
|
121
|
+
keyring.get_keyring()
|
|
122
|
+
except Exception as exc: # noqa: BLE001 - no keychain service on this machine
|
|
123
|
+
raise UserError(
|
|
124
|
+
"No OS keyring is available. Set CLAUDE_CODE_CREDENTIALS=file to use the JSON file store."
|
|
125
|
+
) from exc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _keyring_available() -> bool:
|
|
129
|
+
try:
|
|
130
|
+
import keyring
|
|
131
|
+
|
|
132
|
+
keyring.get_keyring()
|
|
133
|
+
return True
|
|
134
|
+
except Exception: # noqa: BLE001 - probe only; the store validates again on use
|
|
135
|
+
return False
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests for keyring-backed storage and `default_store()` selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import keyring
|
|
8
|
+
import pytest
|
|
9
|
+
from pydantic_ai.exceptions import UserError
|
|
10
|
+
|
|
11
|
+
from pydantic_ai_claude_code.credentials import ClaudeCodeCredentials
|
|
12
|
+
from pydantic_ai_claude_code.storage import (
|
|
13
|
+
ClaudeCodeTokenStore,
|
|
14
|
+
KeyringTokenStore,
|
|
15
|
+
default_store,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def fake_keyring(monkeypatch) -> dict[str, str]:
|
|
21
|
+
store: dict[str, str] = {}
|
|
22
|
+
|
|
23
|
+
def set_password(service: str, username: str, password: str) -> None:
|
|
24
|
+
store[(service, username)] = password
|
|
25
|
+
|
|
26
|
+
def get_password(service: str, username: str) -> str | None:
|
|
27
|
+
return store.get((service, username))
|
|
28
|
+
|
|
29
|
+
def get_keyring() -> object:
|
|
30
|
+
# Pretend a keychain backend exists.
|
|
31
|
+
return object()
|
|
32
|
+
|
|
33
|
+
monkeypatch.setattr(keyring, "set_password", set_password)
|
|
34
|
+
monkeypatch.setattr(keyring, "get_password", get_password)
|
|
35
|
+
monkeypatch.setattr(keyring, "get_keyring", get_keyring)
|
|
36
|
+
return store
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_keyring_roundtrip(fake_keyring) -> None:
|
|
40
|
+
store = KeyringTokenStore()
|
|
41
|
+
creds = ClaudeCodeCredentials(access_token="a", refresh_token="b")
|
|
42
|
+
store.save(creds)
|
|
43
|
+
assert store.load() == creds
|
|
44
|
+
stored = fake_keyring[(KeyringTokenStore.SERVICE, KeyringTokenStore.USERNAME)]
|
|
45
|
+
assert json.loads(stored)["access_token"] == "a"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_default_store_prefers_keyring(fake_keyring, monkeypatch) -> None:
|
|
49
|
+
monkeypatch.delenv("CLAUDE_CODE_CREDENTIALS", raising=False)
|
|
50
|
+
assert isinstance(default_store(), KeyringTokenStore)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_default_store_falls_back_to_file(monkeypatch, tmp_path) -> None:
|
|
54
|
+
monkeypatch.delenv("CLAUDE_CODE_CREDENTIALS", raising=False)
|
|
55
|
+
|
|
56
|
+
def no_keyring() -> object:
|
|
57
|
+
raise RuntimeError("no keychain (headless)")
|
|
58
|
+
|
|
59
|
+
monkeypatch.setattr(keyring, "get_keyring", no_keyring)
|
|
60
|
+
assert isinstance(default_store(), ClaudeCodeTokenStore)
|
|
61
|
+
# The real (working) backend is irrelevant: with keyring out of the picture the
|
|
62
|
+
# file store must be used, and it must respect CLAUDE_CODE_AUTH_FILE.
|
|
63
|
+
monkeypatch.setenv("CLAUDE_CODE_AUTH_FILE", str(tmp_path / "custom.json"))
|
|
64
|
+
assert default_store().path == tmp_path / "custom.json" # type: ignore[attr-defined]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_default_store_env_override(monkeypatch, fake_keyring) -> None:
|
|
68
|
+
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS", "file")
|
|
69
|
+
assert isinstance(default_store(), ClaudeCodeTokenStore)
|
|
70
|
+
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS", "keyring")
|
|
71
|
+
assert isinstance(default_store(), KeyringTokenStore)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_default_store_bad_env_raises(monkeypatch) -> None:
|
|
75
|
+
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS", "bogus")
|
|
76
|
+
with pytest.raises(UserError):
|
|
77
|
+
default_store()
|
|
@@ -18,11 +18,11 @@ def test_save_load_roundtrip(tmp_path) -> None:
|
|
|
18
18
|
assert store.load() == creds
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def
|
|
21
|
+
def test_save_sets_0644_perm(tmp_path) -> None:
|
|
22
22
|
store = ClaudeCodeTokenStore(path=tmp_path / "auth.json")
|
|
23
23
|
store.save(ClaudeCodeCredentials(access_token="a", refresh_token="b"))
|
|
24
24
|
mode = stat.S_IMODE((tmp_path / "auth.json").stat().st_mode)
|
|
25
|
-
assert mode ==
|
|
25
|
+
assert mode == 0o644
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def test_load_missing_returns_none(tmp_path) -> None:
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
"""Persistent token storage for Claude Code credentials.
|
|
2
|
-
|
|
3
|
-
The file lives under the user's data directory, is created with `0o600`
|
|
4
|
-
permissions, and its path can be overridden with the `CLAUDE_CODE_AUTH_FILE`
|
|
5
|
-
environment variable.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import json
|
|
11
|
-
import os
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
from pydantic_ai.exceptions import UserError
|
|
15
|
-
|
|
16
|
-
from .credentials import ClaudeCodeCredentials
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def default_auth_path() -> Path:
|
|
20
|
-
"""The token file path, honoring the `CLAUDE_CODE_AUTH_FILE` override."""
|
|
21
|
-
env_path = os.environ.get("CLAUDE_CODE_AUTH_FILE")
|
|
22
|
-
if env_path:
|
|
23
|
-
return Path(env_path).expanduser()
|
|
24
|
-
data_dir = Path(os.environ.get("XDG_DATA_HOME", "") or (Path.home() / ".local" / "share"))
|
|
25
|
-
return data_dir / "pydantic-ai-claude-code" / "auth.json"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class ClaudeCodeTokenStore:
|
|
29
|
-
"""Read and write `ClaudeCodeCredentials` to a single JSON file."""
|
|
30
|
-
|
|
31
|
-
def __init__(self, path: str | Path | None = None) -> None:
|
|
32
|
-
self.path = Path(path) if path is not None else default_auth_path()
|
|
33
|
-
|
|
34
|
-
def load(self) -> ClaudeCodeCredentials | None:
|
|
35
|
-
"""Return stored credentials, or `None` when nothing is stored yet."""
|
|
36
|
-
try:
|
|
37
|
-
data = json.loads(self.path.read_text())
|
|
38
|
-
except FileNotFoundError:
|
|
39
|
-
return None
|
|
40
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
41
|
-
raise UserError(f"Unable to read Claude Code credentials from {str(self.path)!r}.") from exc
|
|
42
|
-
return ClaudeCodeCredentials.from_token_file(data)
|
|
43
|
-
|
|
44
|
-
def save(self, credentials: ClaudeCodeCredentials) -> None:
|
|
45
|
-
"""Persist credentials atomically with restrictive permissions."""
|
|
46
|
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
-
tmp = self.path.with_suffix(".tmp")
|
|
48
|
-
tmp.write_text(json.dumps(credentials.to_wire_dict(), indent=2))
|
|
49
|
-
tmp.chmod(0o600)
|
|
50
|
-
os.replace(tmp, self.path)
|
|
File without changes
|
|
File without changes
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/__main__.py
RENAMED
|
File without changes
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/auth.py
RENAMED
|
File without changes
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/config.py
RENAMED
|
File without changes
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/credentials.py
RENAMED
|
File without changes
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/model.py
RENAMED
|
File without changes
|
{pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|