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.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. 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,5 @@
1
+ """Built-in OAuth login flows (loaded lazily by provider factories)."""
2
+
3
+ from ._common import OAuthError
4
+
5
+ __all__ = ["OAuthError"]
@@ -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