oauth-cli-kit 0.1.1__tar.gz → 0.1.5__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.
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyd
5
+
6
+ # Test / coverage
7
+ .pytest_cache/
8
+ .coverage
9
+ htmlcov/
10
+
11
+ # Build artifacts
12
+ dist/
13
+ build/
14
+ *.egg-info/
15
+
16
+ # Envs
17
+ .venv/
18
+ .env
19
+
20
+ uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oauth-cli-kit
3
- Version: 0.1.1
3
+ Version: 0.1.5
4
4
  Summary: Reusable OAuth 2.0 + PKCE helpers for CLI applications
5
5
  Author: nanobot contributors
6
6
  License: MIT
@@ -12,5 +12,7 @@ Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Requires-Python: >=3.11
15
- Requires-Dist: httpx>=0.25.0
15
+ Requires-Dist: httpx>=0.26.0
16
16
  Requires-Dist: platformdirs>=4.0.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
@@ -1,8 +1,8 @@
1
1
  """oauth-cli-kit public API."""
2
2
 
3
- from oauth_cli_kit.constants import OPENAI_CODEX_PROVIDER
4
3
  from oauth_cli_kit.flow import get_token, login_oauth_interactive
5
4
  from oauth_cli_kit.models import OAuthProviderConfig, OAuthToken
5
+ from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
6
6
 
7
7
  __all__ = [
8
8
  "OPENAI_CODEX_PROVIDER",
@@ -0,0 +1,25 @@
1
+ """Default provider settings and constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from oauth_cli_kit.providers.openai_codex import OPENAI_CODEX_PROVIDER
6
+
7
+ SUCCESS_HTML = (
8
+ "<!doctype html>"
9
+ "<html lang=\"en\">"
10
+ "<head>"
11
+ "<meta charset=\"utf-8\" />"
12
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />"
13
+ "<title>Authentication successful</title>"
14
+ "</head>"
15
+ "<body>"
16
+ "<p>Authentication successful. Return to your terminal to continue.</p>"
17
+ "</body>"
18
+ "</html>"
19
+ )
20
+
21
+ # 兼容性:历史上从 oauth_cli_kit.constants 导入 provider。
22
+ __all__ = [
23
+ "SUCCESS_HTML",
24
+ "OPENAI_CODEX_PROVIDER",
25
+ ]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import os
6
7
  import sys
7
8
  import threading
8
9
  import time
@@ -12,8 +13,8 @@ from typing import Callable
12
13
 
13
14
  import httpx
14
15
 
15
- from oauth_cli_kit.constants import OPENAI_CODEX_PROVIDER
16
16
  from oauth_cli_kit.models import OAuthProviderConfig, OAuthToken
17
+ from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
17
18
  from oauth_cli_kit.pkce import (
18
19
  _create_state,
19
20
  _decode_account_id,
@@ -25,10 +26,21 @@ from oauth_cli_kit.server import _start_local_server
25
26
  from oauth_cli_kit.storage import FileTokenStorage, TokenStorage, _FileLock
26
27
 
27
28
 
29
+ def _httpx_client_kwargs(proxy: str | None) -> dict[str, object]:
30
+ return {"timeout": 30.0, "proxy": proxy, "trust_env": False} if proxy else {"timeout": 30.0}
31
+
32
+
33
+ def _should_open_browser() -> bool:
34
+ if sys.platform.startswith("linux"):
35
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
36
+ return True
37
+
38
+
28
39
  def _exchange_code_for_token_async(
29
40
  code: str,
30
41
  verifier: str,
31
42
  provider: OAuthProviderConfig,
43
+ proxy: str | None = None,
32
44
  ) -> Callable[[], OAuthToken]:
33
45
  async def _run() -> OAuthToken:
34
46
  data = {
@@ -38,7 +50,7 @@ def _exchange_code_for_token_async(
38
50
  "code_verifier": verifier,
39
51
  "redirect_uri": provider.redirect_uri,
40
52
  }
41
- async with httpx.AsyncClient(timeout=30.0) as client:
53
+ async with httpx.AsyncClient(**_httpx_client_kwargs(proxy)) as client:
42
54
  response = await client.post(
43
55
  provider.token_url,
44
56
  data=data,
@@ -61,13 +73,13 @@ def _exchange_code_for_token_async(
61
73
  return _run
62
74
 
63
75
 
64
- def _refresh_token(refresh_token: str, provider: OAuthProviderConfig) -> OAuthToken:
76
+ def _refresh_token(refresh_token: str, provider: OAuthProviderConfig, proxy: str | None = None) -> OAuthToken:
65
77
  data = {
66
78
  "grant_type": "refresh_token",
67
79
  "refresh_token": refresh_token,
68
80
  "client_id": provider.client_id,
69
81
  }
70
- with httpx.Client(timeout=30.0) as client:
82
+ with httpx.Client(**_httpx_client_kwargs(proxy)) as client:
71
83
  response = client.post(
72
84
  provider.token_url,
73
85
  data=data,
@@ -92,6 +104,7 @@ def get_token(
92
104
  provider: OAuthProviderConfig = OPENAI_CODEX_PROVIDER,
93
105
  storage: TokenStorage | None = None,
94
106
  min_ttl_seconds: int = 60,
107
+ proxy: str | None = None,
95
108
  ) -> OAuthToken:
96
109
  """Get an available token (refresh if needed)."""
97
110
  storage = storage or FileTokenStorage(token_filename=provider.token_filename)
@@ -112,7 +125,7 @@ def get_token(
112
125
  if token.expires - now_ms > min_ttl_seconds * 1000:
113
126
  return token
114
127
  try:
115
- refreshed = _refresh_token(token.refresh, provider)
128
+ refreshed = _refresh_token(token.refresh, provider, proxy)
116
129
  storage.save(refreshed)
117
130
  return refreshed
118
131
  except Exception:
@@ -123,43 +136,14 @@ def get_token(
123
136
  raise
124
137
 
125
138
 
126
- async def _read_stdin_line() -> str:
127
- loop = asyncio.get_running_loop()
128
- if hasattr(loop, "add_reader") and sys.stdin:
129
- future: asyncio.Future[str] = loop.create_future()
130
-
131
- def _on_readable() -> None:
132
- line = sys.stdin.readline()
133
- if not future.done():
134
- future.set_result(line)
135
-
136
- try:
137
- loop.add_reader(sys.stdin, _on_readable)
138
- except Exception:
139
- return await loop.run_in_executor(None, sys.stdin.readline)
140
-
141
- try:
142
- return await future
143
- finally:
144
- try:
145
- loop.remove_reader(sys.stdin)
146
- except Exception:
147
- pass
148
-
149
- return await loop.run_in_executor(None, sys.stdin.readline)
150
-
151
-
152
- async def _await_manual_input(print_fn: Callable[[str], None]) -> str:
153
- print_fn("[cyan]Paste the authorization code (or full redirect URL), or wait for the browser callback:[/cyan]")
154
- return await _read_stdin_line()
155
-
156
-
157
139
  def login_oauth_interactive(
158
140
  print_fn: Callable[[str], None],
159
141
  prompt_fn: Callable[[str], str],
160
142
  provider: OAuthProviderConfig = OPENAI_CODEX_PROVIDER,
161
143
  originator: str | None = None,
162
144
  storage: TokenStorage | None = None,
145
+ proxy: str | None = None,
146
+ open_browser: bool | None = None,
163
147
  ) -> OAuthToken:
164
148
  """Interactive login flow."""
165
149
 
@@ -190,12 +174,17 @@ def login_oauth_interactive(
190
174
  loop.call_soon_threadsafe(code_future.set_result, code_value)
191
175
 
192
176
  server, server_error = _start_local_server(state, on_code=_notify)
193
- print_fn("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]")
177
+ should_open_browser = _should_open_browser() if open_browser is None else open_browser
178
+ if should_open_browser:
179
+ print_fn("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]")
180
+ else:
181
+ print_fn("[yellow]No graphical browser detected. Open this URL in a browser:[/yellow]")
194
182
  print_fn(url)
195
- try:
196
- webbrowser.open(url)
197
- except Exception:
198
- pass
183
+ if should_open_browser:
184
+ try:
185
+ webbrowser.open(url)
186
+ except Exception:
187
+ pass
199
188
 
200
189
  if not server and server_error:
201
190
  print_fn(
@@ -207,38 +196,25 @@ def login_oauth_interactive(
207
196
 
208
197
  code: str | None = None
209
198
  try:
210
- if server:
199
+ if server and should_open_browser:
211
200
  print_fn("[dim]Waiting for browser callback...[/dim]")
212
-
213
- tasks: list[asyncio.Task[object]] = []
214
- callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120))
215
- tasks.append(callback_task)
216
- manual_task = asyncio.create_task(_await_manual_input(print_fn))
217
- tasks.append(manual_task)
218
-
219
- done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
220
- for task in pending:
221
- task.cancel()
222
-
223
- for task in done:
224
- try:
225
- result = task.result()
226
- except asyncio.TimeoutError:
227
- result = None
228
- if not result:
229
- continue
230
- if task is manual_task:
231
- parsed_code, parsed_state = _parse_authorization_input(result)
232
- if parsed_state and parsed_state != state:
233
- raise RuntimeError("State validation failed.")
234
- code = parsed_code
235
- else:
236
- code = result
237
- if code:
238
- break
201
+ try:
202
+ code = await asyncio.wait_for(code_future, timeout=120)
203
+ except asyncio.TimeoutError:
204
+ server.shutdown()
205
+ server.server_close()
206
+ server = None
207
+ elif server:
208
+ server.shutdown()
209
+ server.server_close()
210
+ server = None
239
211
 
240
212
  if not code:
241
- prompt = "Please paste the callback URL or authorization code:"
213
+ prompt = (
214
+ "Please open the URL above in a browser, then paste the full redirect URL:"
215
+ if not should_open_browser
216
+ else "Please paste the callback URL or authorization code:"
217
+ )
242
218
  raw = await loop.run_in_executor(None, prompt_fn, prompt)
243
219
  parsed_code, parsed_state = _parse_authorization_input(raw)
244
220
  if parsed_state and parsed_state != state:
@@ -249,7 +225,7 @@ def login_oauth_interactive(
249
225
  raise RuntimeError("Authorization code not found.")
250
226
 
251
227
  print_fn("[dim]Exchanging authorization code for tokens...[/dim]")
252
- token = await _exchange_code_for_token_async(code, verifier, provider)()
228
+ token = await _exchange_code_for_token_async(code, verifier, provider, proxy)()
253
229
  (storage or FileTokenStorage(token_filename=provider.token_filename)).save(token)
254
230
  return token
255
231
  finally:
@@ -0,0 +1,14 @@
1
+ """OAuth Provider 配置集合。
2
+
3
+ 设计目标:
4
+ 1) 让 codex / Claude Code 等多个 CLI 的 OAuth 配置以“providers 子包”统一管理;
5
+ 2) 保持现有对外 API(例如 oauth_cli_kit.constants.OPENAI_CODEX_PROVIDER)不变;
6
+ 3) 后续新增 provider 时,只需在本目录新增一个模块并在此处导出即可。
7
+ """
8
+
9
+ from oauth_cli_kit.providers.openai_codex import OPENAI_CODEX_PROVIDER
10
+
11
+ __all__ = [
12
+ "OPENAI_CODEX_PROVIDER",
13
+ ]
14
+
@@ -1,23 +1,11 @@
1
- """Default provider settings and constants."""
1
+ """OpenAI Codex CLI 的默认 OAuth Provider 配置。"""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  from oauth_cli_kit.models import OAuthProviderConfig
6
6
 
7
- SUCCESS_HTML = (
8
- "<!doctype html>"
9
- "<html lang=\"en\">"
10
- "<head>"
11
- "<meta charset=\"utf-8\" />"
12
- "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />"
13
- "<title>Authentication successful</title>"
14
- "</head>"
15
- "<body>"
16
- "<p>Authentication successful. Return to your terminal to continue.</p>"
17
- "</body>"
18
- "</html>"
19
- )
20
7
 
8
+ # 注意:这里仅组织配置位置(从 constants.py 挪到 providers/),不改变任何字段/逻辑。
21
9
  OPENAI_CODEX_PROVIDER = OAuthProviderConfig(
22
10
  client_id="app_EMoamEEZ73f0CkXaXp7hrann",
23
11
  authorize_url="https://auth.openai.com/oauth/authorize",
@@ -28,4 +16,5 @@ OPENAI_CODEX_PROVIDER = OAuthProviderConfig(
28
16
  account_id_claim="chatgpt_account_id",
29
17
  default_originator="nanobot",
30
18
  token_filename="codex.json",
31
- )
19
+ )
20
+
@@ -15,10 +15,16 @@ classifiers = [
15
15
  ]
16
16
 
17
17
  dependencies = [
18
- "httpx>=0.25.0",
18
+ "httpx>=0.26.0",
19
19
  "platformdirs>=4.0.0",
20
20
  ]
21
21
 
22
+ [project.optional-dependencies]
23
+ # 开发/CI 使用的依赖,不影响库的运行时依赖与现有逻辑。
24
+ dev = [
25
+ "pytest>=8.0.0",
26
+ ]
27
+
22
28
  [build-system]
23
29
  requires = ["hatchling", "hatch-vcs"]
24
30
  build-backend = "hatchling.build"
@@ -35,3 +41,7 @@ include = [
35
41
 
36
42
  [tool.hatch.version]
37
43
  source = "vcs"
44
+
45
+ [tool.pytest.ini_options]
46
+ addopts = "-q"
47
+ testpaths = ["tests"]
File without changes
File without changes