pi-ai-python 1.3.0__py3-none-any.whl
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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
pi_ai/auth/helpers.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Standard auth building blocks for provider definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib
|
|
7
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..abort import AbortSignal
|
|
11
|
+
from .types import (
|
|
12
|
+
ApiKeyAuth,
|
|
13
|
+
AuthContext,
|
|
14
|
+
AuthPrompt,
|
|
15
|
+
AuthResult,
|
|
16
|
+
Credential,
|
|
17
|
+
LoginOptions,
|
|
18
|
+
ModelAuth,
|
|
19
|
+
OAuthAuth,
|
|
20
|
+
ProviderAuthInteraction,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["env_api_key_auth", "keyless_auth", "lazy_oauth", "LazyOAuthAuth"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def env_api_key_auth(name: str, env_vars: Sequence[str]) -> ApiKeyAuth:
|
|
27
|
+
"""Standard API-key auth: a stored credential key wins, otherwise the first set env var.
|
|
28
|
+
|
|
29
|
+
Includes a ``login`` that prompts for the key.
|
|
30
|
+
"""
|
|
31
|
+
env_names = tuple(env_vars)
|
|
32
|
+
|
|
33
|
+
async def login(interaction: ProviderAuthInteraction) -> Credential:
|
|
34
|
+
interaction.signal.throw_if_aborted()
|
|
35
|
+
key = await interaction.prompt(AuthPrompt(type="secret", message=f"Enter {name}"))
|
|
36
|
+
interaction.signal.throw_if_aborted()
|
|
37
|
+
return {"type": "api_key", "key": key}
|
|
38
|
+
|
|
39
|
+
async def resolve(*, ctx: AuthContext, credential: Credential | None, signal: AbortSignal) -> AuthResult | None:
|
|
40
|
+
signal.throw_if_aborted()
|
|
41
|
+
if credential and credential.get("key"):
|
|
42
|
+
return AuthResult(
|
|
43
|
+
ModelAuth(api_key=credential["key"]), env=credential.get("env"), source="stored credential"
|
|
44
|
+
)
|
|
45
|
+
for env_var in env_names:
|
|
46
|
+
value = await ctx.env(env_var)
|
|
47
|
+
signal.throw_if_aborted()
|
|
48
|
+
if value:
|
|
49
|
+
return AuthResult(ModelAuth(api_key=value), source=env_var)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
return ApiKeyAuth(name, resolve, login=login)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def keyless_auth(name: str) -> ApiKeyAuth:
|
|
56
|
+
"""Auth for keyless local servers (Ollama, llama.cpp, vLLM): always configured, no key."""
|
|
57
|
+
|
|
58
|
+
async def resolve(*, ctx: AuthContext, credential: Credential | None, signal: AbortSignal) -> AuthResult | None:
|
|
59
|
+
if credential and credential.get("key"):
|
|
60
|
+
return AuthResult(
|
|
61
|
+
ModelAuth(api_key=credential["key"]), env=credential.get("env"), source="stored credential"
|
|
62
|
+
)
|
|
63
|
+
return AuthResult(ModelAuth())
|
|
64
|
+
|
|
65
|
+
return ApiKeyAuth(name, resolve)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LazyOAuthAuth(OAuthAuth):
|
|
69
|
+
"""An :class:`OAuthAuth` whose implementation loads on first use."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
name: str,
|
|
74
|
+
load: Callable[[], OAuthAuth | Awaitable[OAuthAuth]] | str,
|
|
75
|
+
*,
|
|
76
|
+
is_subscription: bool = False,
|
|
77
|
+
login_label: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.name = name
|
|
80
|
+
self.is_subscription = is_subscription
|
|
81
|
+
self.login_label = login_label
|
|
82
|
+
self._load = load
|
|
83
|
+
self._loaded: OAuthAuth | None = None
|
|
84
|
+
self._lock: asyncio.Lock | None = None
|
|
85
|
+
|
|
86
|
+
async def _get(self) -> OAuthAuth:
|
|
87
|
+
if self._loaded is not None:
|
|
88
|
+
return self._loaded
|
|
89
|
+
load = self._load
|
|
90
|
+
if isinstance(load, str):
|
|
91
|
+
module_name, _, attr = load.partition(":")
|
|
92
|
+
module = importlib.import_module(module_name)
|
|
93
|
+
factory: Any = getattr(module, attr or "create_oauth")
|
|
94
|
+
result = factory()
|
|
95
|
+
else:
|
|
96
|
+
result = load()
|
|
97
|
+
if asyncio.iscoroutine(result) or isinstance(result, Awaitable):
|
|
98
|
+
result = await result
|
|
99
|
+
self._loaded = result
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
async def login(self, interaction: ProviderAuthInteraction, options: LoginOptions | None = None) -> Credential:
|
|
103
|
+
return await (await self._get()).login(interaction, options)
|
|
104
|
+
|
|
105
|
+
async def refresh(self, credential: Credential, signal: AbortSignal) -> Credential:
|
|
106
|
+
return await (await self._get()).refresh(credential, signal)
|
|
107
|
+
|
|
108
|
+
async def to_auth(self, credential: Credential) -> ModelAuth:
|
|
109
|
+
return await (await self._get()).to_auth(credential)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def lazy_oauth(
|
|
113
|
+
name: str,
|
|
114
|
+
load: Callable[[], OAuthAuth | Awaitable[OAuthAuth]] | str,
|
|
115
|
+
*,
|
|
116
|
+
is_subscription: bool = False,
|
|
117
|
+
login_label: str | None = None,
|
|
118
|
+
) -> OAuthAuth:
|
|
119
|
+
"""Advertise OAuth without importing the flow implementation.
|
|
120
|
+
|
|
121
|
+
``load`` is a callable returning the implementation, or ``"module:factory"``.
|
|
122
|
+
"""
|
|
123
|
+
return LazyOAuthAuth(name, load, is_subscription=is_subscription, login_label=login_label)
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Shared plumbing for the built-in OAuth flows: HTTP, clock, URLs and JSON.
|
|
2
|
+
|
|
3
|
+
The flows run offline in tests by replacing :data:`http_client` (an
|
|
4
|
+
``httpx.AsyncClient`` with a mock transport), :func:`now_ms` and :func:`sleep`
|
|
5
|
+
(a virtual clock). Flow modules always call these through the module, never
|
|
6
|
+
through ``from ... import``, so a single patch applies everywhere.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
import ipaddress
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, TypeGuard
|
|
22
|
+
from urllib.parse import parse_qsl, quote_plus, unquote
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from ...abort import AbortError, AbortSignal
|
|
27
|
+
from ...abort import abort_scope as _abort_scope
|
|
28
|
+
from ...abort import sleep as _abortable_sleep
|
|
29
|
+
from ...utils.http import get_http_client
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"OAuthError",
|
|
33
|
+
"http_client",
|
|
34
|
+
"client_for",
|
|
35
|
+
"now_ms",
|
|
36
|
+
"sleep",
|
|
37
|
+
"fetch",
|
|
38
|
+
"login_cancelled",
|
|
39
|
+
"status_text",
|
|
40
|
+
"json_stringify",
|
|
41
|
+
"parse_json",
|
|
42
|
+
"read_json_record",
|
|
43
|
+
"is_number",
|
|
44
|
+
"expires_in_ms",
|
|
45
|
+
"form_encode",
|
|
46
|
+
"parse_search_params",
|
|
47
|
+
"ParsedUrl",
|
|
48
|
+
"parse_url",
|
|
49
|
+
"parse_authorization_input",
|
|
50
|
+
"base64url_encode",
|
|
51
|
+
"random_base64url",
|
|
52
|
+
"random_hex",
|
|
53
|
+
"MAX_SAFE_INTEGER",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: ``Number.MAX_SAFE_INTEGER``: expiry of credentials that never expire.
|
|
57
|
+
MAX_SAFE_INTEGER = 9007199254740991
|
|
58
|
+
_FETCH_TIMEOUT_MESSAGE = "The operation was aborted due to timeout"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OAuthError(Exception):
|
|
62
|
+
"""A failed OAuth login, token exchange or refresh."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
#: Client for every OAuth request instead of the pooled default (tests, custom transports).
|
|
66
|
+
http_client: httpx.AsyncClient | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def client_for(url: str) -> httpx.AsyncClient:
|
|
70
|
+
return http_client if http_client is not None else get_http_client(url)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def now_ms() -> int:
|
|
74
|
+
"""Wall-clock time in milliseconds (``Date.now()``)."""
|
|
75
|
+
return time.time_ns() // 1_000_000
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def sleep(seconds: float, signal: AbortSignal | None = None) -> None:
|
|
79
|
+
"""Abortable sleep; raises :class:`AbortError` with the signal's reason."""
|
|
80
|
+
await _abortable_sleep(seconds, signal)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def login_cancelled() -> AbortError:
|
|
84
|
+
return AbortError("Login cancelled")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def fetch(
|
|
88
|
+
url: str,
|
|
89
|
+
*,
|
|
90
|
+
method: str = "GET",
|
|
91
|
+
headers: Mapping[str, str] | None = None,
|
|
92
|
+
body: str | bytes | None = None,
|
|
93
|
+
signal: AbortSignal | None = None,
|
|
94
|
+
timeout_ms: float | None = None,
|
|
95
|
+
) -> httpx.Response:
|
|
96
|
+
"""Send one request and read the whole body, like ``fetch()`` with an abort signal.
|
|
97
|
+
|
|
98
|
+
``signal`` aborts with :class:`AbortError`. ``timeout_ms`` bounds the whole exchange
|
|
99
|
+
(``AbortSignal.timeout``) and raises :class:`TimeoutError`.
|
|
100
|
+
"""
|
|
101
|
+
client = client_for(url)
|
|
102
|
+
async with _abort_scope(signal):
|
|
103
|
+
try:
|
|
104
|
+
async with asyncio.timeout(timeout_ms / 1000 if timeout_ms is not None else None):
|
|
105
|
+
return await client.request(method, url, headers=dict(headers) if headers else None, content=body)
|
|
106
|
+
except TimeoutError:
|
|
107
|
+
raise TimeoutError(_FETCH_TIMEOUT_MESSAGE) from None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def status_text(response: httpx.Response) -> str:
|
|
111
|
+
"""``response.statusText``."""
|
|
112
|
+
return response.reason_phrase or ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def json_stringify(value: Any) -> str:
|
|
116
|
+
"""``JSON.stringify`` for JSON values (compact separators, ``null`` for ``None``)."""
|
|
117
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_json(response: httpx.Response) -> Any:
|
|
121
|
+
"""``response.json()``: raises ``ValueError`` for an invalid body."""
|
|
122
|
+
return json.loads(response.text)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def read_json_record(response: httpx.Response) -> dict[str, Any] | None:
|
|
126
|
+
"""The JSON body when it is an object, otherwise ``None`` (never raises)."""
|
|
127
|
+
try:
|
|
128
|
+
value = json.loads(response.text)
|
|
129
|
+
except ValueError:
|
|
130
|
+
return None
|
|
131
|
+
return value if isinstance(value, dict) else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_number(value: Any) -> TypeGuard[float]:
|
|
135
|
+
"""``typeof value === "number"`` for decoded JSON (booleans are not numbers)."""
|
|
136
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def expires_in_ms(expires_in: Any, what: str = "expires_in") -> int:
|
|
140
|
+
"""Milliseconds for an ``expires_in`` seconds value."""
|
|
141
|
+
if not is_number(expires_in) or not math.isfinite(expires_in):
|
|
142
|
+
raise OAuthError(f"OAuth token response has invalid {what}")
|
|
143
|
+
return int(expires_in * 1000)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# -- URLSearchParams --------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _form_component(value: str) -> str:
|
|
150
|
+
# application/x-www-form-urlencoded byte serializer: only alphanumerics and *-._ stay as is.
|
|
151
|
+
return quote_plus(value, safe="*").replace("~", "%7E")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def form_encode(fields: Mapping[str, str] | list[tuple[str, str]]) -> str:
|
|
155
|
+
"""``new URLSearchParams(fields).toString()``."""
|
|
156
|
+
items = fields.items() if isinstance(fields, Mapping) else fields
|
|
157
|
+
return "&".join(f"{_form_component(str(key))}={_form_component(str(value))}" for key, value in items)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_search_params(query: str) -> list[tuple[str, str]]:
|
|
161
|
+
"""``new URLSearchParams(query)`` entries (a leading ``?`` is ignored)."""
|
|
162
|
+
if query.startswith("?"):
|
|
163
|
+
query = query[1:]
|
|
164
|
+
return parse_qsl(query, keep_blank_values=True, errors="replace")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _first_param(entries: list[tuple[str, str]], name: str) -> str | None:
|
|
168
|
+
for key, value in entries:
|
|
169
|
+
if key == name:
|
|
170
|
+
return value
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# -- WHATWG URL subset --------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.\-]*):")
|
|
177
|
+
_SPECIAL_PORTS: dict[str, int | None] = {"http": 80, "https": 443, "ws": 80, "wss": 443, "ftp": 21, "file": None}
|
|
178
|
+
_C0_OR_SPACE = "".join(chr(code) for code in range(0x21))
|
|
179
|
+
_FORBIDDEN_DOMAIN = frozenset(" #%/:<>?@[\\]^|\x7f") | frozenset(chr(code) for code in range(0x20))
|
|
180
|
+
_FRAGMENT_SET = frozenset(' "<>`')
|
|
181
|
+
_QUERY_SET = frozenset(' "#<>')
|
|
182
|
+
_SPECIAL_QUERY_SET = _QUERY_SET | {"'"}
|
|
183
|
+
_PATH_SET = _QUERY_SET | frozenset("?`{}")
|
|
184
|
+
_USERINFO_SET = _PATH_SET | frozenset("/:;=@[\\]^|")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _percent_encode(value: str, encode_set: frozenset[str]) -> str:
|
|
188
|
+
out: list[str] = []
|
|
189
|
+
for char in value:
|
|
190
|
+
code = ord(char)
|
|
191
|
+
if code < 0x20 or code > 0x7E or char in encode_set:
|
|
192
|
+
out.extend(f"%{byte:02X}" for byte in char.encode("utf-8", "surrogatepass"))
|
|
193
|
+
else:
|
|
194
|
+
out.append(char)
|
|
195
|
+
return "".join(out)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _percent_decode(value: str) -> str:
|
|
199
|
+
return unquote(value, errors="replace")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_dot(segment: str) -> bool:
|
|
203
|
+
return segment == "." or segment.lower() == "%2e"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_double_dot(segment: str) -> bool:
|
|
207
|
+
return segment.lower() in ("..", ".%2e", "%2e.", "%2e%2e")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _normalize_path(path: str) -> str:
|
|
211
|
+
segments = path.split("/")[1:]
|
|
212
|
+
output: list[str] = []
|
|
213
|
+
for index, segment in enumerate(segments):
|
|
214
|
+
last = index == len(segments) - 1
|
|
215
|
+
if _is_double_dot(segment):
|
|
216
|
+
if output:
|
|
217
|
+
output.pop()
|
|
218
|
+
if last:
|
|
219
|
+
output.append("")
|
|
220
|
+
elif _is_dot(segment):
|
|
221
|
+
if last:
|
|
222
|
+
output.append("")
|
|
223
|
+
else:
|
|
224
|
+
output.append(segment)
|
|
225
|
+
return "/" + "/".join(output)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse_host(raw: str, special: bool) -> str | None:
|
|
229
|
+
if raw.startswith("["):
|
|
230
|
+
if not raw.endswith("]"):
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
return f"[{ipaddress.IPv6Address(raw[1:-1]).compressed}]"
|
|
234
|
+
except ValueError:
|
|
235
|
+
return None
|
|
236
|
+
if not special:
|
|
237
|
+
return _percent_encode(raw, frozenset())
|
|
238
|
+
host = _percent_decode(raw)
|
|
239
|
+
if not host.isascii():
|
|
240
|
+
try:
|
|
241
|
+
host = host.encode("idna").decode("ascii")
|
|
242
|
+
except UnicodeError:
|
|
243
|
+
return None
|
|
244
|
+
host = host.lower()
|
|
245
|
+
if not host or any(char in _FORBIDDEN_DOMAIN for char in host):
|
|
246
|
+
return None
|
|
247
|
+
return host
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(slots=True)
|
|
251
|
+
class ParsedUrl:
|
|
252
|
+
"""The parts of a WHATWG URL that the OAuth flows use (``new URL(...)``)."""
|
|
253
|
+
|
|
254
|
+
scheme: str
|
|
255
|
+
username: str
|
|
256
|
+
password: str
|
|
257
|
+
#: Host without port (lowercased for special schemes; ``None`` without an authority).
|
|
258
|
+
hostname: str | None
|
|
259
|
+
port: int | None
|
|
260
|
+
pathname: str
|
|
261
|
+
#: Query without ``?`` (``None`` when absent).
|
|
262
|
+
query: str | None
|
|
263
|
+
#: Fragment without ``#`` (``None`` when absent).
|
|
264
|
+
fragment: str | None
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def protocol(self) -> str:
|
|
268
|
+
return f"{self.scheme}:"
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def special(self) -> bool:
|
|
272
|
+
return self.scheme in _SPECIAL_PORTS
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def host(self) -> str:
|
|
276
|
+
if self.hostname is None:
|
|
277
|
+
return ""
|
|
278
|
+
return self.hostname if self.port is None else f"{self.hostname}:{self.port}"
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def origin(self) -> str:
|
|
282
|
+
if self.special and self.scheme != "file":
|
|
283
|
+
return f"{self.scheme}://{self.host}"
|
|
284
|
+
return "null"
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def search(self) -> str:
|
|
288
|
+
return f"?{self.query}" if self.query else ""
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def search_params(self) -> list[tuple[str, str]]:
|
|
292
|
+
return parse_search_params(self.query or "")
|
|
293
|
+
|
|
294
|
+
def param(self, name: str) -> str | None:
|
|
295
|
+
"""``url.searchParams.get(name)``."""
|
|
296
|
+
return _first_param(self.search_params, name)
|
|
297
|
+
|
|
298
|
+
def with_search(self, query: str) -> ParsedUrl:
|
|
299
|
+
"""A copy with the query replaced (``url.search = query``)."""
|
|
300
|
+
encoded = _percent_encode(query, _SPECIAL_QUERY_SET if self.special else _QUERY_SET) if query else None
|
|
301
|
+
return ParsedUrl(
|
|
302
|
+
self.scheme,
|
|
303
|
+
self.username,
|
|
304
|
+
self.password,
|
|
305
|
+
self.hostname,
|
|
306
|
+
self.port,
|
|
307
|
+
self.pathname,
|
|
308
|
+
encoded,
|
|
309
|
+
self.fragment,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def href(self) -> str:
|
|
314
|
+
out = [self.scheme, ":"]
|
|
315
|
+
if self.hostname is not None:
|
|
316
|
+
out.append("//")
|
|
317
|
+
if self.username or self.password:
|
|
318
|
+
out.append(self.username)
|
|
319
|
+
if self.password:
|
|
320
|
+
out.append(f":{self.password}")
|
|
321
|
+
out.append("@")
|
|
322
|
+
out.append(self.host)
|
|
323
|
+
out.append(self.pathname)
|
|
324
|
+
if self.query is not None:
|
|
325
|
+
out.append(f"?{self.query}")
|
|
326
|
+
if self.fragment is not None:
|
|
327
|
+
out.append(f"#{self.fragment}")
|
|
328
|
+
return "".join(out)
|
|
329
|
+
|
|
330
|
+
def __str__(self) -> str:
|
|
331
|
+
return self.href
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def parse_url(value: str, base: str | None = None) -> ParsedUrl | None:
|
|
335
|
+
"""Parse an absolute URL like ``new URL(value)``; ``None`` where that throws.
|
|
336
|
+
|
|
337
|
+
With ``base``, a value starting with ``/`` resolves against the base origin.
|
|
338
|
+
Covers the WHATWG behavior the flows rely on (scheme detection, host validation and
|
|
339
|
+
lowercasing, percent-encoding, dot segments, default ports); IPv4 shorthand forms
|
|
340
|
+
are not normalized.
|
|
341
|
+
"""
|
|
342
|
+
value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
|
|
343
|
+
if base is not None and not _SCHEME_RE.match(value):
|
|
344
|
+
parsed_base = parse_url(base)
|
|
345
|
+
if parsed_base is None:
|
|
346
|
+
return None
|
|
347
|
+
if value.startswith("/") and not value.startswith("//"):
|
|
348
|
+
value = f"{parsed_base.origin}{value}"
|
|
349
|
+
else:
|
|
350
|
+
value = f"{parsed_base.origin}/{value}"
|
|
351
|
+
match = _SCHEME_RE.match(value)
|
|
352
|
+
if match is None:
|
|
353
|
+
return None
|
|
354
|
+
scheme = match.group(1).lower()
|
|
355
|
+
rest = value[match.end() :]
|
|
356
|
+
special = scheme in _SPECIAL_PORTS
|
|
357
|
+
|
|
358
|
+
fragment: str | None = None
|
|
359
|
+
hash_index = rest.find("#")
|
|
360
|
+
if hash_index >= 0:
|
|
361
|
+
rest, fragment = rest[:hash_index], _percent_encode(rest[hash_index + 1 :], _FRAGMENT_SET)
|
|
362
|
+
query: str | None = None
|
|
363
|
+
query_index = rest.find("?")
|
|
364
|
+
if query_index >= 0:
|
|
365
|
+
rest, query = rest[:query_index], rest[query_index + 1 :]
|
|
366
|
+
query = _percent_encode(query, _SPECIAL_QUERY_SET if special else _QUERY_SET)
|
|
367
|
+
|
|
368
|
+
authority: str | None
|
|
369
|
+
if special:
|
|
370
|
+
rest = rest.replace("\\", "/")
|
|
371
|
+
if special and scheme != "file":
|
|
372
|
+
authority, slash, path = rest.lstrip("/").partition("/")
|
|
373
|
+
path = f"/{path}" if slash else "/"
|
|
374
|
+
elif rest.startswith("//"):
|
|
375
|
+
authority, slash, path = rest[2:].partition("/")
|
|
376
|
+
path = f"/{path}" if slash else ("/" if special else "")
|
|
377
|
+
else:
|
|
378
|
+
authority, path = None, rest
|
|
379
|
+
|
|
380
|
+
username = password = ""
|
|
381
|
+
hostname: str | None = None
|
|
382
|
+
port: int | None = None
|
|
383
|
+
if authority is not None:
|
|
384
|
+
userinfo, at, hostport = authority.rpartition("@")
|
|
385
|
+
if at:
|
|
386
|
+
raw_user, _, raw_password = userinfo.partition(":")
|
|
387
|
+
username = _percent_encode(raw_user, _USERINFO_SET)
|
|
388
|
+
password = _percent_encode(raw_password, _USERINFO_SET)
|
|
389
|
+
if hostport.startswith("["):
|
|
390
|
+
end = hostport.find("]")
|
|
391
|
+
if end < 0:
|
|
392
|
+
return None
|
|
393
|
+
raw_host, port_text = hostport[: end + 1], hostport[end + 1 :]
|
|
394
|
+
if port_text and not port_text.startswith(":"):
|
|
395
|
+
return None
|
|
396
|
+
port_text = port_text[1:]
|
|
397
|
+
else:
|
|
398
|
+
raw_host, _, port_text = hostport.partition(":")
|
|
399
|
+
if port_text:
|
|
400
|
+
if not port_text.isdigit() or int(port_text) > 65535:
|
|
401
|
+
return None
|
|
402
|
+
port = int(port_text)
|
|
403
|
+
if port == _SPECIAL_PORTS.get(scheme):
|
|
404
|
+
port = None
|
|
405
|
+
if special and scheme != "file" and not raw_host:
|
|
406
|
+
return None
|
|
407
|
+
hostname = _parse_host(raw_host, special) if raw_host else ""
|
|
408
|
+
if hostname is None:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
if authority is not None or special:
|
|
412
|
+
pathname = _normalize_path(_percent_encode(path, _PATH_SET)) if path else path
|
|
413
|
+
else:
|
|
414
|
+
pathname = _percent_encode(path, frozenset())
|
|
415
|
+
return ParsedUrl(scheme, username, password, hostname, port, pathname, query, fragment)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# -- randomness ---------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def base64url_encode(data: bytes) -> str:
|
|
422
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def random_base64url(size: int) -> str:
|
|
426
|
+
"""``randomBytes(size).toString("base64url")``."""
|
|
427
|
+
return base64url_encode(secrets.token_bytes(size))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def random_hex(size: int) -> str:
|
|
431
|
+
"""``randomBytes(size).toString("hex")``."""
|
|
432
|
+
return secrets.token_hex(size)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def parse_authorization_input(value: str) -> tuple[str | None, str | None]:
|
|
436
|
+
"""``(code, state)`` from a pasted redirect URL, ``code#state``, query string, or bare code."""
|
|
437
|
+
value = value.strip()
|
|
438
|
+
if not value:
|
|
439
|
+
return None, None
|
|
440
|
+
|
|
441
|
+
url = parse_url(value)
|
|
442
|
+
if url is not None:
|
|
443
|
+
return url.param("code"), url.param("state")
|
|
444
|
+
|
|
445
|
+
if "#" in value:
|
|
446
|
+
# JavaScript `split("#", 2)`: anything after a second `#` is dropped.
|
|
447
|
+
parts = value.split("#")
|
|
448
|
+
return parts[0], parts[1]
|
|
449
|
+
|
|
450
|
+
if "code=" in value:
|
|
451
|
+
params = parse_search_params(value)
|
|
452
|
+
return _first_param(params, "code"), _first_param(params, "state")
|
|
453
|
+
|
|
454
|
+
return value, None
|