code-puppy 0.0.325__py3-none-any.whl → 0.0.341__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.
- code_puppy/agents/base_agent.py +110 -124
- code_puppy/claude_cache_client.py +208 -2
- code_puppy/cli_runner.py +152 -32
- code_puppy/command_line/add_model_menu.py +4 -0
- code_puppy/command_line/autosave_menu.py +23 -24
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +5 -0
- code_puppy/command_line/config_commands.py +24 -1
- code_puppy/command_line/core_commands.py +85 -0
- code_puppy/command_line/diff_menu.py +5 -0
- code_puppy/command_line/mcp/custom_server_form.py +4 -0
- code_puppy/command_line/mcp/install_menu.py +5 -1
- code_puppy/command_line/model_settings_menu.py +5 -0
- code_puppy/command_line/motd.py +13 -7
- code_puppy/command_line/onboarding_slides.py +180 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/prompt_toolkit_completion.py +118 -0
- code_puppy/config.py +3 -2
- code_puppy/http_utils.py +201 -279
- code_puppy/keymap.py +10 -8
- code_puppy/mcp_/managed_server.py +7 -11
- code_puppy/messaging/messages.py +3 -0
- code_puppy/messaging/rich_renderer.py +114 -22
- code_puppy/model_factory.py +102 -15
- code_puppy/models.json +2 -2
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +668 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +664 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
- code_puppy/plugins/claude_code_oauth/utils.py +126 -7
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/terminal_utils.py +295 -3
- code_puppy/tools/command_runner.py +43 -54
- code_puppy/tools/common.py +3 -9
- code_puppy/uvx_detection.py +242 -0
- {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/METADATA +26 -49
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/RECORD +52 -36
- {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/licenses/LICENSE +0 -0
|
@@ -27,6 +27,10 @@ from code_puppy.command_line.attachments import (
|
|
|
27
27
|
_detect_path_tokens,
|
|
28
28
|
_tokenise,
|
|
29
29
|
)
|
|
30
|
+
from code_puppy.command_line.clipboard import (
|
|
31
|
+
capture_clipboard_image_to_pending,
|
|
32
|
+
has_image_in_clipboard,
|
|
33
|
+
)
|
|
30
34
|
from code_puppy.command_line.command_registry import get_unique_commands
|
|
31
35
|
from code_puppy.command_line.file_path_completion import FilePathCompleter
|
|
32
36
|
from code_puppy.command_line.load_context_completion import LoadContextCompleter
|
|
@@ -644,6 +648,120 @@ async def get_input_with_combined_completion(
|
|
|
644
648
|
else:
|
|
645
649
|
event.current_buffer.validate_and_handle()
|
|
646
650
|
|
|
651
|
+
# Handle bracketed paste (triggered by most terminal Cmd+V / Ctrl+V)
|
|
652
|
+
# This is the PRIMARY paste handler - works with Cmd+V on macOS terminals
|
|
653
|
+
@bindings.add(Keys.BracketedPaste)
|
|
654
|
+
def handle_bracketed_paste(event):
|
|
655
|
+
"""Handle bracketed paste - works with Cmd+V on macOS terminals."""
|
|
656
|
+
# The pasted data is in event.data
|
|
657
|
+
pasted_data = event.data
|
|
658
|
+
|
|
659
|
+
# Check if clipboard has an image (the pasted text might just be empty or a file path)
|
|
660
|
+
try:
|
|
661
|
+
if has_image_in_clipboard():
|
|
662
|
+
placeholder = capture_clipboard_image_to_pending()
|
|
663
|
+
if placeholder:
|
|
664
|
+
event.app.current_buffer.insert_text(placeholder + " ")
|
|
665
|
+
# The placeholder itself is visible feedback - no need for extra output
|
|
666
|
+
# Use bell for audible feedback (works in most terminals)
|
|
667
|
+
event.app.output.bell()
|
|
668
|
+
return # Don't also paste the text data
|
|
669
|
+
except Exception:
|
|
670
|
+
pass
|
|
671
|
+
|
|
672
|
+
# No image - insert the pasted text as normal, sanitizing Windows newlines
|
|
673
|
+
if pasted_data:
|
|
674
|
+
# Normalize Windows line endings to Unix style
|
|
675
|
+
sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
|
|
676
|
+
event.app.current_buffer.insert_text(sanitized_data)
|
|
677
|
+
|
|
678
|
+
# Fallback Ctrl+V for terminals without bracketed paste support
|
|
679
|
+
@bindings.add("c-v", eager=True)
|
|
680
|
+
def handle_smart_paste(event):
|
|
681
|
+
"""Handle Ctrl+V - auto-detect image vs text in clipboard."""
|
|
682
|
+
try:
|
|
683
|
+
# Check for image first
|
|
684
|
+
if has_image_in_clipboard():
|
|
685
|
+
placeholder = capture_clipboard_image_to_pending()
|
|
686
|
+
if placeholder:
|
|
687
|
+
event.app.current_buffer.insert_text(placeholder + " ")
|
|
688
|
+
# The placeholder itself is visible feedback - no need for extra output
|
|
689
|
+
# Use bell for audible feedback (works in most terminals)
|
|
690
|
+
event.app.output.bell()
|
|
691
|
+
return # Don't also paste text
|
|
692
|
+
except Exception:
|
|
693
|
+
pass # Fall through to text paste on any error
|
|
694
|
+
|
|
695
|
+
# No image (or error) - do normal text paste
|
|
696
|
+
# prompt_toolkit doesn't have built-in paste, so we handle it manually
|
|
697
|
+
try:
|
|
698
|
+
import platform
|
|
699
|
+
import subprocess
|
|
700
|
+
|
|
701
|
+
text = None
|
|
702
|
+
system = platform.system()
|
|
703
|
+
|
|
704
|
+
if system == "Darwin": # macOS
|
|
705
|
+
result = subprocess.run(
|
|
706
|
+
["pbpaste"], capture_output=True, text=True, timeout=2
|
|
707
|
+
)
|
|
708
|
+
if result.returncode == 0:
|
|
709
|
+
text = result.stdout
|
|
710
|
+
elif system == "Windows":
|
|
711
|
+
# Windows - use powershell
|
|
712
|
+
result = subprocess.run(
|
|
713
|
+
["powershell", "-command", "Get-Clipboard"],
|
|
714
|
+
capture_output=True,
|
|
715
|
+
text=True,
|
|
716
|
+
timeout=2,
|
|
717
|
+
)
|
|
718
|
+
if result.returncode == 0:
|
|
719
|
+
text = result.stdout
|
|
720
|
+
else: # Linux
|
|
721
|
+
# Try xclip first, then xsel
|
|
722
|
+
for cmd in [
|
|
723
|
+
["xclip", "-selection", "clipboard", "-o"],
|
|
724
|
+
["xsel", "--clipboard", "--output"],
|
|
725
|
+
]:
|
|
726
|
+
try:
|
|
727
|
+
result = subprocess.run(
|
|
728
|
+
cmd, capture_output=True, text=True, timeout=2
|
|
729
|
+
)
|
|
730
|
+
if result.returncode == 0:
|
|
731
|
+
text = result.stdout
|
|
732
|
+
break
|
|
733
|
+
except FileNotFoundError:
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
if text:
|
|
737
|
+
# Normalize Windows line endings to Unix style
|
|
738
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
739
|
+
# Strip trailing newline that clipboard tools often add
|
|
740
|
+
text = text.rstrip("\n")
|
|
741
|
+
event.app.current_buffer.insert_text(text)
|
|
742
|
+
except Exception:
|
|
743
|
+
pass # Silently fail if text paste doesn't work
|
|
744
|
+
|
|
745
|
+
# F3 - dedicated image paste (shows error if no image)
|
|
746
|
+
@bindings.add("f3")
|
|
747
|
+
def handle_image_paste_f3(event):
|
|
748
|
+
"""Handle F3 - paste image from clipboard (image-only, shows error if none)."""
|
|
749
|
+
try:
|
|
750
|
+
if has_image_in_clipboard():
|
|
751
|
+
placeholder = capture_clipboard_image_to_pending()
|
|
752
|
+
if placeholder:
|
|
753
|
+
event.app.current_buffer.insert_text(placeholder + " ")
|
|
754
|
+
# The placeholder itself is visible feedback
|
|
755
|
+
# Use bell for audible feedback (works in most terminals)
|
|
756
|
+
event.app.output.bell()
|
|
757
|
+
else:
|
|
758
|
+
# Insert a transient message that user can delete
|
|
759
|
+
event.app.current_buffer.insert_text("[⚠️ no image in clipboard] ")
|
|
760
|
+
event.app.output.bell()
|
|
761
|
+
except Exception:
|
|
762
|
+
event.app.current_buffer.insert_text("[❌ clipboard error] ")
|
|
763
|
+
event.app.output.bell()
|
|
764
|
+
|
|
647
765
|
session = PromptSession(
|
|
648
766
|
completer=completer,
|
|
649
767
|
history=history,
|
code_puppy/config.py
CHANGED
|
@@ -53,6 +53,7 @@ _DEFAULT_SQLITE_FILE = os.path.join(DATA_DIR, "dbos_store.sqlite")
|
|
|
53
53
|
GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
|
|
54
54
|
CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
|
|
55
55
|
CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
|
|
56
|
+
ANTIGRAVITY_MODELS_FILE = os.path.join(DATA_DIR, "antigravity_models.json")
|
|
56
57
|
|
|
57
58
|
# Cache files (XDG_CACHE_HOME)
|
|
58
59
|
AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
|
|
@@ -1051,11 +1052,11 @@ def set_enable_dbos(enabled: bool) -> None:
|
|
|
1051
1052
|
set_config_value("enable_dbos", "true" if enabled else "false")
|
|
1052
1053
|
|
|
1053
1054
|
|
|
1054
|
-
def get_message_limit(default: int =
|
|
1055
|
+
def get_message_limit(default: int = 1000) -> int:
|
|
1055
1056
|
"""
|
|
1056
1057
|
Returns the user-configured message/request limit for the agent.
|
|
1057
1058
|
This controls how many steps/requests the agent can take.
|
|
1058
|
-
Defaults to
|
|
1059
|
+
Defaults to 1000 if unset or misconfigured.
|
|
1059
1060
|
Configurable by 'message_limit' key.
|
|
1060
1061
|
"""
|
|
1061
1062
|
val = get_value("message_limit")
|
code_puppy/http_utils.py
CHANGED
|
@@ -4,29 +4,81 @@ HTTP utilities module for code-puppy.
|
|
|
4
4
|
This module provides functions for creating properly configured HTTP clients.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import os
|
|
8
9
|
import socket
|
|
9
|
-
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, Optional, Union
|
|
10
13
|
|
|
11
14
|
import httpx
|
|
12
15
|
import requests
|
|
13
|
-
from tenacity import stop_after_attempt, wait_exponential
|
|
14
16
|
|
|
15
17
|
from code_puppy.config import get_http2
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ProxyConfig:
|
|
22
|
+
"""Configuration for proxy and SSL settings."""
|
|
23
|
+
|
|
24
|
+
verify: Union[bool, str, None]
|
|
25
|
+
trust_env: bool
|
|
26
|
+
proxy_url: str | None
|
|
27
|
+
disable_retry: bool
|
|
28
|
+
http2_enabled: bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_proxy_config(verify: Union[bool, str, None] = None) -> ProxyConfig:
|
|
32
|
+
"""Resolve proxy, SSL, and retry settings from environment.
|
|
33
|
+
|
|
34
|
+
This centralizes the logic for detecting proxies, determining SSL verification,
|
|
35
|
+
and checking if retry transport should be disabled.
|
|
36
|
+
"""
|
|
37
|
+
if verify is None:
|
|
38
|
+
verify = get_cert_bundle_path()
|
|
39
|
+
|
|
40
|
+
http2_enabled = get_http2()
|
|
41
|
+
|
|
42
|
+
disable_retry = os.environ.get(
|
|
43
|
+
"CODE_PUPPY_DISABLE_RETRY_TRANSPORT", ""
|
|
44
|
+
).lower() in ("1", "true", "yes")
|
|
45
|
+
|
|
46
|
+
has_proxy = bool(
|
|
47
|
+
os.environ.get("HTTP_PROXY")
|
|
48
|
+
or os.environ.get("HTTPS_PROXY")
|
|
49
|
+
or os.environ.get("http_proxy")
|
|
50
|
+
or os.environ.get("https_proxy")
|
|
23
51
|
)
|
|
24
|
-
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
52
|
+
|
|
53
|
+
# Determine trust_env and verify based on proxy/retry settings
|
|
54
|
+
if disable_retry:
|
|
55
|
+
# Test mode: disable SSL verification for proxy testing
|
|
56
|
+
verify = False
|
|
57
|
+
trust_env = True
|
|
58
|
+
elif has_proxy:
|
|
59
|
+
# Production proxy: keep SSL verification enabled
|
|
60
|
+
trust_env = True
|
|
61
|
+
else:
|
|
62
|
+
trust_env = False
|
|
63
|
+
|
|
64
|
+
# Extract proxy URL
|
|
65
|
+
proxy_url = None
|
|
66
|
+
if has_proxy:
|
|
67
|
+
proxy_url = (
|
|
68
|
+
os.environ.get("HTTPS_PROXY")
|
|
69
|
+
or os.environ.get("https_proxy")
|
|
70
|
+
or os.environ.get("HTTP_PROXY")
|
|
71
|
+
or os.environ.get("http_proxy")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return ProxyConfig(
|
|
75
|
+
verify=verify,
|
|
76
|
+
trust_env=trust_env,
|
|
77
|
+
proxy_url=proxy_url,
|
|
78
|
+
disable_retry=disable_retry,
|
|
79
|
+
http2_enabled=http2_enabled,
|
|
80
|
+
)
|
|
81
|
+
|
|
30
82
|
|
|
31
83
|
try:
|
|
32
84
|
from .reopenable_async_client import ReopenableAsyncClient
|
|
@@ -34,14 +86,104 @@ except ImportError:
|
|
|
34
86
|
ReopenableAsyncClient = None
|
|
35
87
|
|
|
36
88
|
try:
|
|
37
|
-
from .messaging import emit_info
|
|
89
|
+
from .messaging import emit_info, emit_warning
|
|
38
90
|
except ImportError:
|
|
39
91
|
# Fallback if messaging system is not available
|
|
40
92
|
def emit_info(content: str, **metadata):
|
|
41
93
|
pass # No-op if messaging system is not available
|
|
42
94
|
|
|
95
|
+
def emit_warning(content: str, **metadata):
|
|
96
|
+
pass
|
|
43
97
|
|
|
44
|
-
|
|
98
|
+
|
|
99
|
+
class RetryingAsyncClient(httpx.AsyncClient):
|
|
100
|
+
"""AsyncClient with built-in rate limit handling (429) and retries.
|
|
101
|
+
|
|
102
|
+
This replaces the Tenacity transport with a more direct subclass implementation,
|
|
103
|
+
which plays nicer with proxies and custom transports (like Antigravity).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
retry_status_codes: tuple = (429, 502, 503, 504),
|
|
109
|
+
max_retries: int = 5,
|
|
110
|
+
**kwargs,
|
|
111
|
+
):
|
|
112
|
+
super().__init__(**kwargs)
|
|
113
|
+
self.retry_status_codes = retry_status_codes
|
|
114
|
+
self.max_retries = max_retries
|
|
115
|
+
|
|
116
|
+
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
|
|
117
|
+
"""Send request with automatic retries for rate limits and server errors."""
|
|
118
|
+
last_response = None
|
|
119
|
+
last_exception = None
|
|
120
|
+
|
|
121
|
+
for attempt in range(self.max_retries + 1):
|
|
122
|
+
try:
|
|
123
|
+
response = await super().send(request, **kwargs)
|
|
124
|
+
last_response = response
|
|
125
|
+
|
|
126
|
+
# Check for retryable status
|
|
127
|
+
if response.status_code not in self.retry_status_codes:
|
|
128
|
+
return response
|
|
129
|
+
|
|
130
|
+
# Close response if we're going to retry
|
|
131
|
+
await response.aclose()
|
|
132
|
+
|
|
133
|
+
# Determine wait time
|
|
134
|
+
wait_time = 1.0 * (
|
|
135
|
+
2**attempt
|
|
136
|
+
) # Default exponential backoff: 1s, 2s, 4s...
|
|
137
|
+
|
|
138
|
+
# Check Retry-After header
|
|
139
|
+
retry_after = response.headers.get("Retry-After")
|
|
140
|
+
if retry_after:
|
|
141
|
+
try:
|
|
142
|
+
wait_time = float(retry_after)
|
|
143
|
+
except ValueError:
|
|
144
|
+
# Try parsing http-date
|
|
145
|
+
from email.utils import parsedate_to_datetime
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
date = parsedate_to_datetime(retry_after)
|
|
149
|
+
wait_time = date.timestamp() - time.time()
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
# Cap wait time
|
|
154
|
+
wait_time = max(0.5, min(wait_time, 60.0))
|
|
155
|
+
|
|
156
|
+
if attempt < self.max_retries:
|
|
157
|
+
emit_info(
|
|
158
|
+
f"HTTP retry: {response.status_code} received. Waiting {wait_time:.1f}s (attempt {attempt + 1}/{self.max_retries})"
|
|
159
|
+
)
|
|
160
|
+
await asyncio.sleep(wait_time)
|
|
161
|
+
|
|
162
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.PoolTimeout) as e:
|
|
163
|
+
last_exception = e
|
|
164
|
+
wait_time = 1.0 * (2**attempt)
|
|
165
|
+
if attempt < self.max_retries:
|
|
166
|
+
emit_warning(
|
|
167
|
+
f"HTTP connection error: {e}. Retrying in {wait_time}s..."
|
|
168
|
+
)
|
|
169
|
+
await asyncio.sleep(wait_time)
|
|
170
|
+
else:
|
|
171
|
+
raise
|
|
172
|
+
except Exception:
|
|
173
|
+
raise
|
|
174
|
+
|
|
175
|
+
# Return last response (even if it's an error status)
|
|
176
|
+
if last_response:
|
|
177
|
+
return last_response
|
|
178
|
+
|
|
179
|
+
# Should catch this in loop, but just in case
|
|
180
|
+
if last_exception:
|
|
181
|
+
raise last_exception
|
|
182
|
+
|
|
183
|
+
return last_response
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_cert_bundle_path() -> str | None:
|
|
45
187
|
# First check if SSL_CERT_FILE environment variable is set
|
|
46
188
|
ssl_cert_file = os.environ.get("SSL_CERT_FILE")
|
|
47
189
|
if ssl_cert_file and os.path.exists(ssl_cert_file):
|
|
@@ -60,53 +202,15 @@ def create_client(
|
|
|
60
202
|
# Check if HTTP/2 is enabled in config
|
|
61
203
|
http2_enabled = get_http2()
|
|
62
204
|
|
|
63
|
-
# Check if custom retry transport should be disabled (e.g., for integration tests with proxies)
|
|
64
|
-
disable_retry_transport = os.environ.get(
|
|
65
|
-
"CODE_PUPPY_DISABLE_RETRY_TRANSPORT", ""
|
|
66
|
-
).lower() in ("1", "true", "yes")
|
|
67
|
-
|
|
68
205
|
# If retry components are available, create a client with retry transport
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"""Raise exceptions for retryable HTTP status codes."""
|
|
78
|
-
if response.status_code in retry_status_codes:
|
|
79
|
-
emit_info(
|
|
80
|
-
f"HTTP retry: Retrying request due to status code {response.status_code}"
|
|
81
|
-
)
|
|
82
|
-
return True
|
|
83
|
-
|
|
84
|
-
transport = TenacityTransport(
|
|
85
|
-
config=RetryConfig(
|
|
86
|
-
retry=lambda e: isinstance(e, httpx.HTTPStatusError)
|
|
87
|
-
and e.response.status_code in retry_status_codes,
|
|
88
|
-
wait=wait_retry_after(
|
|
89
|
-
fallback_strategy=wait_exponential(multiplier=1, max=60),
|
|
90
|
-
max_wait=300,
|
|
91
|
-
),
|
|
92
|
-
stop=stop_after_attempt(10),
|
|
93
|
-
reraise=True,
|
|
94
|
-
),
|
|
95
|
-
validate_response=should_retry_status,
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
return httpx.Client(
|
|
99
|
-
transport=transport,
|
|
100
|
-
verify=verify,
|
|
101
|
-
headers=headers or {},
|
|
102
|
-
timeout=timeout,
|
|
103
|
-
http2=http2_enabled,
|
|
104
|
-
)
|
|
105
|
-
else:
|
|
106
|
-
# Fallback to regular client if retry components are not available
|
|
107
|
-
return httpx.Client(
|
|
108
|
-
verify=verify, headers=headers or {}, timeout=timeout, http2=http2_enabled
|
|
109
|
-
)
|
|
206
|
+
# Note: TenacityTransport was removed. For now we just return a standard client.
|
|
207
|
+
# Future TODO: Implement RetryingClient(httpx.Client) if needed.
|
|
208
|
+
return httpx.Client(
|
|
209
|
+
verify=verify,
|
|
210
|
+
headers=headers or {},
|
|
211
|
+
timeout=timeout,
|
|
212
|
+
http2=http2_enabled,
|
|
213
|
+
)
|
|
110
214
|
|
|
111
215
|
|
|
112
216
|
def create_async_client(
|
|
@@ -115,109 +219,26 @@ def create_async_client(
|
|
|
115
219
|
headers: Optional[Dict[str, str]] = None,
|
|
116
220
|
retry_status_codes: tuple = (429, 502, 503, 504),
|
|
117
221
|
) -> httpx.AsyncClient:
|
|
118
|
-
|
|
119
|
-
verify = get_cert_bundle_path()
|
|
120
|
-
|
|
121
|
-
# Check if HTTP/2 is enabled in config
|
|
122
|
-
http2_enabled = get_http2()
|
|
222
|
+
config = _resolve_proxy_config(verify)
|
|
123
223
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# Check if proxy environment variables are set
|
|
130
|
-
has_proxy = bool(
|
|
131
|
-
os.environ.get("HTTP_PROXY")
|
|
132
|
-
or os.environ.get("HTTPS_PROXY")
|
|
133
|
-
or os.environ.get("http_proxy")
|
|
134
|
-
or os.environ.get("https_proxy")
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
# When retry transport is disabled (test mode), disable SSL verification
|
|
138
|
-
# for proxy testing. For production proxies, SSL should still be verified!
|
|
139
|
-
if disable_retry_transport:
|
|
140
|
-
verify = False
|
|
141
|
-
trust_env = True
|
|
142
|
-
elif has_proxy:
|
|
143
|
-
# Production proxy detected - keep SSL verification enabled for security
|
|
144
|
-
trust_env = True
|
|
145
|
-
else:
|
|
146
|
-
trust_env = False
|
|
147
|
-
|
|
148
|
-
# If retry components are available, create a client with retry transport
|
|
149
|
-
# BUT: disable retry transport when proxies are detected because custom transports
|
|
150
|
-
# don't play nicely with proxy configuration
|
|
151
|
-
if (
|
|
152
|
-
AsyncTenacityTransport
|
|
153
|
-
and RetryConfig
|
|
154
|
-
and wait_retry_after
|
|
155
|
-
and not disable_retry_transport
|
|
156
|
-
and not has_proxy
|
|
157
|
-
):
|
|
158
|
-
|
|
159
|
-
def should_retry_status(response):
|
|
160
|
-
"""Raise exceptions for retryable HTTP status codes."""
|
|
161
|
-
if response.status_code in retry_status_codes:
|
|
162
|
-
emit_info(
|
|
163
|
-
f"HTTP retry: Retrying request due to status code {response.status_code}"
|
|
164
|
-
)
|
|
165
|
-
return True
|
|
166
|
-
|
|
167
|
-
# Create transport (with or without proxy base)
|
|
168
|
-
if has_proxy:
|
|
169
|
-
# Extract proxy URL from environment
|
|
170
|
-
proxy_url = (
|
|
171
|
-
os.environ.get("HTTPS_PROXY")
|
|
172
|
-
or os.environ.get("https_proxy")
|
|
173
|
-
or os.environ.get("HTTP_PROXY")
|
|
174
|
-
or os.environ.get("http_proxy")
|
|
175
|
-
)
|
|
176
|
-
else:
|
|
177
|
-
proxy_url = None
|
|
178
|
-
|
|
179
|
-
# Create retry transport wrapper
|
|
180
|
-
transport = AsyncTenacityTransport(
|
|
181
|
-
config=RetryConfig(
|
|
182
|
-
retry=lambda e: isinstance(e, httpx.HTTPStatusError)
|
|
183
|
-
and e.response.status_code in retry_status_codes,
|
|
184
|
-
wait=wait_retry_after(10),
|
|
185
|
-
stop=stop_after_attempt(10),
|
|
186
|
-
reraise=True,
|
|
187
|
-
),
|
|
188
|
-
validate_response=should_retry_status,
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
return httpx.AsyncClient(
|
|
192
|
-
transport=transport,
|
|
193
|
-
proxy=proxy_url, # Pass proxy to client, not transport
|
|
194
|
-
verify=verify,
|
|
224
|
+
if not config.disable_retry:
|
|
225
|
+
return RetryingAsyncClient(
|
|
226
|
+
retry_status_codes=retry_status_codes,
|
|
227
|
+
proxy=config.proxy_url,
|
|
228
|
+
verify=config.verify,
|
|
195
229
|
headers=headers or {},
|
|
196
230
|
timeout=timeout,
|
|
197
|
-
http2=http2_enabled,
|
|
198
|
-
trust_env=trust_env,
|
|
231
|
+
http2=config.http2_enabled,
|
|
232
|
+
trust_env=config.trust_env,
|
|
199
233
|
)
|
|
200
234
|
else:
|
|
201
|
-
# Fallback to regular client if retry components are not available,
|
|
202
|
-
# when retry transport is explicitly disabled, or when proxies are detected
|
|
203
|
-
# Extract proxy URL if needed
|
|
204
|
-
if has_proxy:
|
|
205
|
-
proxy_url = (
|
|
206
|
-
os.environ.get("HTTPS_PROXY")
|
|
207
|
-
or os.environ.get("https_proxy")
|
|
208
|
-
or os.environ.get("HTTP_PROXY")
|
|
209
|
-
or os.environ.get("http_proxy")
|
|
210
|
-
)
|
|
211
|
-
else:
|
|
212
|
-
proxy_url = None
|
|
213
|
-
|
|
214
235
|
return httpx.AsyncClient(
|
|
215
|
-
proxy=proxy_url,
|
|
216
|
-
verify=verify,
|
|
236
|
+
proxy=config.proxy_url,
|
|
237
|
+
verify=config.verify,
|
|
217
238
|
headers=headers or {},
|
|
218
239
|
timeout=timeout,
|
|
219
|
-
http2=http2_enabled,
|
|
220
|
-
trust_env=trust_env,
|
|
240
|
+
http2=config.http2_enabled,
|
|
241
|
+
trust_env=config.trust_env,
|
|
221
242
|
)
|
|
222
243
|
|
|
223
244
|
|
|
@@ -267,132 +288,33 @@ def create_reopenable_async_client(
|
|
|
267
288
|
headers: Optional[Dict[str, str]] = None,
|
|
268
289
|
retry_status_codes: tuple = (429, 502, 503, 504),
|
|
269
290
|
) -> Union[ReopenableAsyncClient, httpx.AsyncClient]:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
"
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
or os.environ.get("HTTPS_PROXY")
|
|
285
|
-
or os.environ.get("http_proxy")
|
|
286
|
-
or os.environ.get("https_proxy")
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
# When retry transport is disabled (test mode), disable SSL verification
|
|
290
|
-
if disable_retry_transport:
|
|
291
|
-
verify = False
|
|
292
|
-
trust_env = True
|
|
293
|
-
elif has_proxy:
|
|
294
|
-
trust_env = True
|
|
295
|
-
else:
|
|
296
|
-
trust_env = False
|
|
297
|
-
|
|
298
|
-
# If retry components are available, create a client with retry transport
|
|
299
|
-
# BUT: disable retry transport when proxies are detected because custom transports
|
|
300
|
-
# don't play nicely with proxy configuration
|
|
301
|
-
if (
|
|
302
|
-
AsyncTenacityTransport
|
|
303
|
-
and RetryConfig
|
|
304
|
-
and wait_retry_after
|
|
305
|
-
and not disable_retry_transport
|
|
306
|
-
and not has_proxy
|
|
307
|
-
):
|
|
308
|
-
|
|
309
|
-
def should_retry_status(response):
|
|
310
|
-
"""Raise exceptions for retryable HTTP status codes."""
|
|
311
|
-
if response.status_code in retry_status_codes:
|
|
312
|
-
emit_info(
|
|
313
|
-
f"HTTP retry: Retrying request due to status code {response.status_code}"
|
|
314
|
-
)
|
|
315
|
-
return True
|
|
316
|
-
|
|
317
|
-
transport = AsyncTenacityTransport(
|
|
318
|
-
config=RetryConfig(
|
|
319
|
-
retry=lambda e: isinstance(e, httpx.HTTPStatusError)
|
|
320
|
-
and e.response.status_code in retry_status_codes,
|
|
321
|
-
wait=wait_retry_after(
|
|
322
|
-
fallback_strategy=wait_exponential(multiplier=1, max=60),
|
|
323
|
-
max_wait=300,
|
|
324
|
-
),
|
|
325
|
-
stop=stop_after_attempt(10),
|
|
326
|
-
reraise=True,
|
|
327
|
-
),
|
|
328
|
-
validate_response=should_retry_status,
|
|
291
|
+
config = _resolve_proxy_config(verify)
|
|
292
|
+
|
|
293
|
+
base_kwargs = {
|
|
294
|
+
"proxy": config.proxy_url,
|
|
295
|
+
"verify": config.verify,
|
|
296
|
+
"headers": headers or {},
|
|
297
|
+
"timeout": timeout,
|
|
298
|
+
"http2": config.http2_enabled,
|
|
299
|
+
"trust_env": config.trust_env,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if ReopenableAsyncClient is not None:
|
|
303
|
+
client_class = (
|
|
304
|
+
RetryingAsyncClient if not config.disable_retry else httpx.AsyncClient
|
|
329
305
|
)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
os.environ.get("HTTPS_PROXY")
|
|
335
|
-
or os.environ.get("https_proxy")
|
|
336
|
-
or os.environ.get("HTTP_PROXY")
|
|
337
|
-
or os.environ.get("http_proxy")
|
|
338
|
-
)
|
|
339
|
-
else:
|
|
340
|
-
proxy_url = None
|
|
341
|
-
|
|
342
|
-
if ReopenableAsyncClient is not None:
|
|
343
|
-
return ReopenableAsyncClient(
|
|
344
|
-
transport=transport,
|
|
345
|
-
proxy=proxy_url,
|
|
346
|
-
verify=verify,
|
|
347
|
-
headers=headers or {},
|
|
348
|
-
timeout=timeout,
|
|
349
|
-
http2=http2_enabled,
|
|
350
|
-
trust_env=trust_env,
|
|
351
|
-
)
|
|
352
|
-
else:
|
|
353
|
-
# Fallback to regular AsyncClient if ReopenableAsyncClient is not available
|
|
354
|
-
return httpx.AsyncClient(
|
|
355
|
-
transport=transport,
|
|
356
|
-
proxy=proxy_url,
|
|
357
|
-
verify=verify,
|
|
358
|
-
headers=headers or {},
|
|
359
|
-
timeout=timeout,
|
|
360
|
-
http2=http2_enabled,
|
|
361
|
-
trust_env=trust_env,
|
|
362
|
-
)
|
|
306
|
+
kwargs = {**base_kwargs, "client_class": client_class}
|
|
307
|
+
if not config.disable_retry:
|
|
308
|
+
kwargs["retry_status_codes"] = retry_status_codes
|
|
309
|
+
return ReopenableAsyncClient(**kwargs)
|
|
363
310
|
else:
|
|
364
|
-
# Fallback to
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
proxy_url = (
|
|
369
|
-
os.environ.get("HTTPS_PROXY")
|
|
370
|
-
or os.environ.get("https_proxy")
|
|
371
|
-
or os.environ.get("HTTP_PROXY")
|
|
372
|
-
or os.environ.get("http_proxy")
|
|
311
|
+
# Fallback to RetryingAsyncClient or plain AsyncClient
|
|
312
|
+
if not config.disable_retry:
|
|
313
|
+
return RetryingAsyncClient(
|
|
314
|
+
retry_status_codes=retry_status_codes, **base_kwargs
|
|
373
315
|
)
|
|
374
316
|
else:
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if ReopenableAsyncClient is not None:
|
|
378
|
-
return ReopenableAsyncClient(
|
|
379
|
-
proxy=proxy_url,
|
|
380
|
-
verify=verify,
|
|
381
|
-
headers=headers or {},
|
|
382
|
-
timeout=timeout,
|
|
383
|
-
http2=http2_enabled,
|
|
384
|
-
trust_env=trust_env,
|
|
385
|
-
)
|
|
386
|
-
else:
|
|
387
|
-
# Fallback to regular AsyncClient if ReopenableAsyncClient is not available
|
|
388
|
-
return httpx.AsyncClient(
|
|
389
|
-
proxy=proxy_url,
|
|
390
|
-
verify=verify,
|
|
391
|
-
headers=headers or {},
|
|
392
|
-
timeout=timeout,
|
|
393
|
-
http2=http2_enabled,
|
|
394
|
-
trust_env=trust_env,
|
|
395
|
-
)
|
|
317
|
+
return httpx.AsyncClient(**base_kwargs)
|
|
396
318
|
|
|
397
319
|
|
|
398
320
|
def is_cert_bundle_available() -> bool:
|