codepp 0.0.437__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/__init__.py +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +117 -0
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +638 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +124 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +385 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +169 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_scheduler.py +121 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +2156 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +327 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +453 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +692 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +672 -0
- code_puppy/cli_runner.py +1073 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +532 -0
- code_puppy/command_line/command_handler.py +293 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +867 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +96 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/shell_passthrough.py +145 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1770 -0
- code_puppy/error_logging.py +134 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +21 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +221 -0
- code_puppy/hook_engine/executor.py +296 -0
- code_puppy/hook_engine/matcher.py +156 -0
- code_puppy/hook_engine/models.py +240 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +144 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1158 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +95 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +174 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- 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 +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -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 +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +137 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +640 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +290 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +356 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +501 -0
- code_puppy/tools/agent_tools.py +603 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +26 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +378 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +886 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +244 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- codepp-0.0.437.dist-info/METADATA +766 -0
- codepp-0.0.437.dist-info/RECORD +288 -0
- codepp-0.0.437.dist-info/WHEEL +4 -0
- codepp-0.0.437.dist-info/entry_points.txt +3 -0
- codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Account storage for multi-account Antigravity OAuth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
12
|
+
|
|
13
|
+
from .config import get_accounts_storage_path
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
ModelFamily = Literal["claude", "gemini"]
|
|
18
|
+
HeaderStyle = Literal["antigravity", "gemini-cli"]
|
|
19
|
+
QuotaKey = Literal["claude", "gemini-antigravity", "gemini-cli"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RateLimitState:
|
|
24
|
+
"""Rate limit reset times per quota key."""
|
|
25
|
+
|
|
26
|
+
claude: Optional[float] = None
|
|
27
|
+
gemini_antigravity: Optional[float] = None
|
|
28
|
+
gemini_cli: Optional[float] = None
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> Dict[str, float]:
|
|
31
|
+
"""Convert to dictionary for JSON serialization."""
|
|
32
|
+
result: Dict[str, float] = {}
|
|
33
|
+
if self.claude is not None:
|
|
34
|
+
result["claude"] = self.claude
|
|
35
|
+
if self.gemini_antigravity is not None:
|
|
36
|
+
result["gemini-antigravity"] = self.gemini_antigravity
|
|
37
|
+
if self.gemini_cli is not None:
|
|
38
|
+
result["gemini-cli"] = self.gemini_cli
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "RateLimitState":
|
|
43
|
+
"""Create from dictionary."""
|
|
44
|
+
if not data:
|
|
45
|
+
return cls()
|
|
46
|
+
return cls(
|
|
47
|
+
claude=data.get("claude"),
|
|
48
|
+
gemini_antigravity=data.get("gemini-antigravity"),
|
|
49
|
+
gemini_cli=data.get("gemini-cli"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class AccountMetadata:
|
|
55
|
+
"""Stored metadata for a single account."""
|
|
56
|
+
|
|
57
|
+
refresh_token: str
|
|
58
|
+
email: Optional[str] = None
|
|
59
|
+
project_id: Optional[str] = None
|
|
60
|
+
managed_project_id: Optional[str] = None
|
|
61
|
+
added_at: float = 0
|
|
62
|
+
last_used: float = 0
|
|
63
|
+
last_switch_reason: Optional[Literal["rate-limit", "initial", "rotation"]] = None
|
|
64
|
+
rate_limit_reset_times: RateLimitState = field(default_factory=RateLimitState)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
67
|
+
"""Convert to dictionary for JSON serialization."""
|
|
68
|
+
result: Dict[str, Any] = {
|
|
69
|
+
"refreshToken": self.refresh_token,
|
|
70
|
+
"addedAt": self.added_at,
|
|
71
|
+
"lastUsed": self.last_used,
|
|
72
|
+
}
|
|
73
|
+
if self.email:
|
|
74
|
+
result["email"] = self.email
|
|
75
|
+
if self.project_id:
|
|
76
|
+
result["projectId"] = self.project_id
|
|
77
|
+
if self.managed_project_id:
|
|
78
|
+
result["managedProjectId"] = self.managed_project_id
|
|
79
|
+
if self.last_switch_reason:
|
|
80
|
+
result["lastSwitchReason"] = self.last_switch_reason
|
|
81
|
+
|
|
82
|
+
rate_limits = self.rate_limit_reset_times.to_dict()
|
|
83
|
+
if rate_limits:
|
|
84
|
+
result["rateLimitResetTimes"] = rate_limits
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AccountMetadata":
|
|
90
|
+
"""Create from dictionary."""
|
|
91
|
+
return cls(
|
|
92
|
+
refresh_token=data.get("refreshToken", ""),
|
|
93
|
+
email=data.get("email"),
|
|
94
|
+
project_id=data.get("projectId"),
|
|
95
|
+
managed_project_id=data.get("managedProjectId"),
|
|
96
|
+
added_at=data.get("addedAt", 0),
|
|
97
|
+
last_used=data.get("lastUsed", 0),
|
|
98
|
+
last_switch_reason=data.get("lastSwitchReason"),
|
|
99
|
+
rate_limit_reset_times=RateLimitState.from_dict(
|
|
100
|
+
data.get("rateLimitResetTimes")
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class AccountStorage:
|
|
107
|
+
"""V3 account storage format."""
|
|
108
|
+
|
|
109
|
+
version: int = 3
|
|
110
|
+
accounts: List[AccountMetadata] = field(default_factory=list)
|
|
111
|
+
active_index: int = 0
|
|
112
|
+
active_index_by_family: Dict[str, int] = field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
115
|
+
"""Convert to dictionary for JSON serialization."""
|
|
116
|
+
return {
|
|
117
|
+
"version": self.version,
|
|
118
|
+
"accounts": [acc.to_dict() for acc in self.accounts],
|
|
119
|
+
"activeIndex": self.active_index,
|
|
120
|
+
"activeIndexByFamily": self.active_index_by_family,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AccountStorage":
|
|
125
|
+
"""Create from dictionary."""
|
|
126
|
+
accounts = [AccountMetadata.from_dict(acc) for acc in data.get("accounts", [])]
|
|
127
|
+
return cls(
|
|
128
|
+
version=data.get("version", 3),
|
|
129
|
+
accounts=accounts,
|
|
130
|
+
active_index=data.get("activeIndex", 0),
|
|
131
|
+
active_index_by_family=data.get("activeIndexByFamily", {}),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _migrate_v1_to_v2(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
136
|
+
"""Migrate V1 storage format to V2."""
|
|
137
|
+
now = time.time() * 1000 # V1 used milliseconds
|
|
138
|
+
|
|
139
|
+
accounts = []
|
|
140
|
+
for acc in data.get("accounts", []):
|
|
141
|
+
rate_limits: Dict[str, float] = {}
|
|
142
|
+
if acc.get("isRateLimited") and acc.get("rateLimitResetTime"):
|
|
143
|
+
reset_time = acc["rateLimitResetTime"]
|
|
144
|
+
if reset_time > now:
|
|
145
|
+
rate_limits["claude"] = reset_time
|
|
146
|
+
rate_limits["gemini"] = reset_time
|
|
147
|
+
|
|
148
|
+
accounts.append(
|
|
149
|
+
{
|
|
150
|
+
"email": acc.get("email"),
|
|
151
|
+
"refreshToken": acc.get("refreshToken", ""),
|
|
152
|
+
"projectId": acc.get("projectId"),
|
|
153
|
+
"managedProjectId": acc.get("managedProjectId"),
|
|
154
|
+
"addedAt": acc.get("addedAt", now),
|
|
155
|
+
"lastUsed": acc.get("lastUsed", 0),
|
|
156
|
+
"lastSwitchReason": acc.get("lastSwitchReason"),
|
|
157
|
+
"rateLimitResetTimes": rate_limits if rate_limits else None,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"version": 2,
|
|
163
|
+
"accounts": accounts,
|
|
164
|
+
"activeIndex": data.get("activeIndex", 0),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _migrate_v2_to_v3(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
169
|
+
"""Migrate V2 storage format to V3."""
|
|
170
|
+
now = time.time() * 1000
|
|
171
|
+
|
|
172
|
+
accounts = []
|
|
173
|
+
for acc in data.get("accounts", []):
|
|
174
|
+
rate_limits: Dict[str, float] = {}
|
|
175
|
+
old_limits = acc.get("rateLimitResetTimes", {}) or {}
|
|
176
|
+
|
|
177
|
+
if old_limits.get("claude") and old_limits["claude"] > now:
|
|
178
|
+
rate_limits["claude"] = old_limits["claude"]
|
|
179
|
+
if old_limits.get("gemini") and old_limits["gemini"] > now:
|
|
180
|
+
rate_limits["gemini-antigravity"] = old_limits["gemini"]
|
|
181
|
+
|
|
182
|
+
accounts.append(
|
|
183
|
+
{
|
|
184
|
+
"email": acc.get("email"),
|
|
185
|
+
"refreshToken": acc.get("refreshToken", ""),
|
|
186
|
+
"projectId": acc.get("projectId"),
|
|
187
|
+
"managedProjectId": acc.get("managedProjectId"),
|
|
188
|
+
"addedAt": acc.get("addedAt", 0),
|
|
189
|
+
"lastUsed": acc.get("lastUsed", 0),
|
|
190
|
+
"lastSwitchReason": acc.get("lastSwitchReason"),
|
|
191
|
+
"rateLimitResetTimes": rate_limits if rate_limits else None,
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"version": 3,
|
|
197
|
+
"accounts": accounts,
|
|
198
|
+
"activeIndex": data.get("activeIndex", 0),
|
|
199
|
+
"activeIndexByFamily": {},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_accounts() -> Optional[AccountStorage]:
|
|
204
|
+
"""Load account storage from disk with automatic migration."""
|
|
205
|
+
path = get_accounts_storage_path()
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
if not path.exists():
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
content = path.read_text(encoding="utf-8")
|
|
212
|
+
data = json.loads(content)
|
|
213
|
+
|
|
214
|
+
if not isinstance(data.get("accounts"), list):
|
|
215
|
+
logger.warning("Invalid storage format, ignoring")
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
version = data.get("version", 1)
|
|
219
|
+
|
|
220
|
+
# Migrate if needed
|
|
221
|
+
if version == 1:
|
|
222
|
+
logger.info("Migrating account storage from v1 to v3")
|
|
223
|
+
data = _migrate_v1_to_v2(data)
|
|
224
|
+
data = _migrate_v2_to_v3(data)
|
|
225
|
+
elif version == 2:
|
|
226
|
+
logger.info("Migrating account storage from v2 to v3")
|
|
227
|
+
data = _migrate_v2_to_v3(data)
|
|
228
|
+
|
|
229
|
+
storage = AccountStorage.from_dict(data)
|
|
230
|
+
|
|
231
|
+
# Validate active index
|
|
232
|
+
if storage.accounts:
|
|
233
|
+
storage.active_index = max(
|
|
234
|
+
0, min(storage.active_index, len(storage.accounts) - 1)
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
storage.active_index = 0
|
|
238
|
+
|
|
239
|
+
# Save migrated data if we migrated
|
|
240
|
+
if version < 3:
|
|
241
|
+
try:
|
|
242
|
+
save_accounts(storage)
|
|
243
|
+
logger.info("Migration to v3 complete")
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.warning("Failed to persist migrated storage: %s", e)
|
|
246
|
+
|
|
247
|
+
return storage
|
|
248
|
+
|
|
249
|
+
except FileNotFoundError:
|
|
250
|
+
return None
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logger.error("Failed to load account storage: %s", e)
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def save_accounts(storage: AccountStorage) -> None:
|
|
257
|
+
"""Save account storage to disk atomically."""
|
|
258
|
+
path = get_accounts_storage_path()
|
|
259
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
|
|
261
|
+
content = json.dumps(storage.to_dict(), indent=2)
|
|
262
|
+
|
|
263
|
+
# Atomic write: write to temp file then rename
|
|
264
|
+
fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
265
|
+
try:
|
|
266
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
267
|
+
f.write(content)
|
|
268
|
+
f.flush()
|
|
269
|
+
os.fsync(f.fileno())
|
|
270
|
+
os.replace(tmp_path, str(path)) # atomic on POSIX
|
|
271
|
+
path.chmod(0o600)
|
|
272
|
+
except BaseException:
|
|
273
|
+
# Clean up temp file on failure
|
|
274
|
+
try:
|
|
275
|
+
os.unlink(tmp_path)
|
|
276
|
+
except OSError:
|
|
277
|
+
pass
|
|
278
|
+
raise
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def clear_accounts() -> None:
|
|
282
|
+
"""Clear all stored accounts."""
|
|
283
|
+
path = get_accounts_storage_path()
|
|
284
|
+
try:
|
|
285
|
+
if path.exists():
|
|
286
|
+
path.unlink()
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.error("Failed to clear account storage: %s", e)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Tests for the Antigravity OAuth plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from .accounts import AccountManager
|
|
10
|
+
from .config import ANTIGRAVITY_OAUTH_CONFIG
|
|
11
|
+
from .constants import ANTIGRAVITY_MODELS, ANTIGRAVITY_SCOPES
|
|
12
|
+
from .oauth import (
|
|
13
|
+
_compute_code_challenge,
|
|
14
|
+
_decode_state,
|
|
15
|
+
_encode_state,
|
|
16
|
+
_generate_code_verifier,
|
|
17
|
+
prepare_oauth_context,
|
|
18
|
+
)
|
|
19
|
+
from .storage import (
|
|
20
|
+
_migrate_v1_to_v2,
|
|
21
|
+
_migrate_v2_to_v3,
|
|
22
|
+
)
|
|
23
|
+
from .token import (
|
|
24
|
+
RefreshParts,
|
|
25
|
+
format_refresh_parts,
|
|
26
|
+
is_token_expired,
|
|
27
|
+
parse_refresh_parts,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestPKCE:
|
|
32
|
+
"""Test PKCE code generation and verification."""
|
|
33
|
+
|
|
34
|
+
def test_code_verifier_length(self):
|
|
35
|
+
"""Code verifier should be URL-safe base64 encoded."""
|
|
36
|
+
verifier = _generate_code_verifier()
|
|
37
|
+
assert len(verifier) > 40 # At least 43 chars for 32 bytes
|
|
38
|
+
assert "=" not in verifier # No padding
|
|
39
|
+
assert " " not in verifier
|
|
40
|
+
|
|
41
|
+
def test_code_challenge_is_sha256(self):
|
|
42
|
+
"""Code challenge should be S256 of verifier."""
|
|
43
|
+
verifier = "test_verifier_string"
|
|
44
|
+
challenge = _compute_code_challenge(verifier)
|
|
45
|
+
assert len(challenge) > 20
|
|
46
|
+
assert "=" not in challenge
|
|
47
|
+
|
|
48
|
+
def test_different_verifiers_produce_different_challenges(self):
|
|
49
|
+
"""Each verifier should produce a unique challenge."""
|
|
50
|
+
v1 = _generate_code_verifier()
|
|
51
|
+
v2 = _generate_code_verifier()
|
|
52
|
+
c1 = _compute_code_challenge(v1)
|
|
53
|
+
c2 = _compute_code_challenge(v2)
|
|
54
|
+
assert c1 != c2
|
|
55
|
+
|
|
56
|
+
def test_prepare_oauth_context(self):
|
|
57
|
+
"""OAuth context should have all required fields."""
|
|
58
|
+
ctx = prepare_oauth_context()
|
|
59
|
+
assert ctx.state
|
|
60
|
+
assert ctx.code_verifier
|
|
61
|
+
assert ctx.code_challenge
|
|
62
|
+
assert ctx.redirect_uri is None # Not assigned yet
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestStateEncoding:
|
|
66
|
+
"""Test OAuth state encoding/decoding."""
|
|
67
|
+
|
|
68
|
+
def test_encode_decode_roundtrip(self):
|
|
69
|
+
"""State should survive encode/decode roundtrip."""
|
|
70
|
+
verifier = "test-verifier-123"
|
|
71
|
+
project_id = "my-project"
|
|
72
|
+
|
|
73
|
+
encoded = _encode_state(verifier, project_id)
|
|
74
|
+
decoded_verifier, decoded_project = _decode_state(encoded)
|
|
75
|
+
|
|
76
|
+
assert decoded_verifier == verifier
|
|
77
|
+
assert decoded_project == project_id
|
|
78
|
+
|
|
79
|
+
def test_encode_without_project_id(self):
|
|
80
|
+
"""Should handle empty project ID."""
|
|
81
|
+
verifier = "test-verifier"
|
|
82
|
+
encoded = _encode_state(verifier, "")
|
|
83
|
+
decoded_verifier, decoded_project = _decode_state(encoded)
|
|
84
|
+
|
|
85
|
+
assert decoded_verifier == verifier
|
|
86
|
+
assert decoded_project == ""
|
|
87
|
+
|
|
88
|
+
def test_decode_invalid_state_raises(self):
|
|
89
|
+
"""Invalid state should raise ValueError."""
|
|
90
|
+
with pytest.raises(ValueError):
|
|
91
|
+
_decode_state("not-valid-base64!!!")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestRefreshParts:
|
|
95
|
+
"""Test refresh token parsing and formatting."""
|
|
96
|
+
|
|
97
|
+
def test_parse_simple_token(self):
|
|
98
|
+
"""Parse a token without project IDs."""
|
|
99
|
+
parts = parse_refresh_parts("my-refresh-token")
|
|
100
|
+
assert parts.refresh_token == "my-refresh-token"
|
|
101
|
+
assert parts.project_id is None
|
|
102
|
+
assert parts.managed_project_id is None
|
|
103
|
+
|
|
104
|
+
def test_parse_with_project_id(self):
|
|
105
|
+
"""Parse a token with project ID."""
|
|
106
|
+
parts = parse_refresh_parts("my-token|project-123")
|
|
107
|
+
assert parts.refresh_token == "my-token"
|
|
108
|
+
assert parts.project_id == "project-123"
|
|
109
|
+
assert parts.managed_project_id is None
|
|
110
|
+
|
|
111
|
+
def test_parse_with_managed_project(self):
|
|
112
|
+
"""Parse a token with both project IDs."""
|
|
113
|
+
parts = parse_refresh_parts("token|proj|managed")
|
|
114
|
+
assert parts.refresh_token == "token"
|
|
115
|
+
assert parts.project_id == "proj"
|
|
116
|
+
assert parts.managed_project_id == "managed"
|
|
117
|
+
|
|
118
|
+
def test_parse_empty_string(self):
|
|
119
|
+
"""Empty string should produce empty parts."""
|
|
120
|
+
parts = parse_refresh_parts("")
|
|
121
|
+
assert parts.refresh_token == ""
|
|
122
|
+
assert parts.project_id is None
|
|
123
|
+
|
|
124
|
+
def test_format_roundtrip(self):
|
|
125
|
+
"""Format and parse should be inverse operations."""
|
|
126
|
+
original = RefreshParts(
|
|
127
|
+
refresh_token="token",
|
|
128
|
+
project_id="project",
|
|
129
|
+
managed_project_id="managed",
|
|
130
|
+
)
|
|
131
|
+
formatted = format_refresh_parts(original)
|
|
132
|
+
parsed = parse_refresh_parts(formatted)
|
|
133
|
+
|
|
134
|
+
assert parsed.refresh_token == original.refresh_token
|
|
135
|
+
assert parsed.project_id == original.project_id
|
|
136
|
+
assert parsed.managed_project_id == original.managed_project_id
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestTokenExpiry:
|
|
140
|
+
"""Test token expiry checking."""
|
|
141
|
+
|
|
142
|
+
def test_none_expires_is_expired(self):
|
|
143
|
+
"""None expiry should be treated as expired."""
|
|
144
|
+
assert is_token_expired(None) is True
|
|
145
|
+
|
|
146
|
+
def test_past_time_is_expired(self):
|
|
147
|
+
"""Past time should be expired."""
|
|
148
|
+
past = time.time() - 3600
|
|
149
|
+
assert is_token_expired(past) is True
|
|
150
|
+
|
|
151
|
+
def test_future_time_not_expired(self):
|
|
152
|
+
"""Future time should not be expired."""
|
|
153
|
+
future = time.time() + 3600
|
|
154
|
+
assert is_token_expired(future) is False
|
|
155
|
+
|
|
156
|
+
def test_expiry_buffer(self):
|
|
157
|
+
"""Token expiring soon should be treated as expired (60s buffer)."""
|
|
158
|
+
almost_expired = time.time() + 30 # 30 seconds from now
|
|
159
|
+
assert is_token_expired(almost_expired) is True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestStorageMigration:
|
|
163
|
+
"""Test storage format migrations."""
|
|
164
|
+
|
|
165
|
+
def test_migrate_v1_to_v2(self):
|
|
166
|
+
"""V1 format should migrate to V2."""
|
|
167
|
+
v1_data = {
|
|
168
|
+
"version": 1,
|
|
169
|
+
"accounts": [
|
|
170
|
+
{
|
|
171
|
+
"email": "test@example.com",
|
|
172
|
+
"refreshToken": "token123",
|
|
173
|
+
"addedAt": 1000,
|
|
174
|
+
"lastUsed": 2000,
|
|
175
|
+
"isRateLimited": False,
|
|
176
|
+
}
|
|
177
|
+
],
|
|
178
|
+
"activeIndex": 0,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
v2_data = _migrate_v1_to_v2(v1_data)
|
|
182
|
+
|
|
183
|
+
assert v2_data["version"] == 2
|
|
184
|
+
assert len(v2_data["accounts"]) == 1
|
|
185
|
+
assert v2_data["accounts"][0]["email"] == "test@example.com"
|
|
186
|
+
|
|
187
|
+
def test_migrate_v2_to_v3(self):
|
|
188
|
+
"""V2 format should migrate to V3."""
|
|
189
|
+
v2_data = {
|
|
190
|
+
"version": 2,
|
|
191
|
+
"accounts": [
|
|
192
|
+
{
|
|
193
|
+
"email": "test@example.com",
|
|
194
|
+
"refreshToken": "token123",
|
|
195
|
+
"addedAt": 1000,
|
|
196
|
+
"lastUsed": 2000,
|
|
197
|
+
"rateLimitResetTimes": {"gemini": time.time() * 1000 + 60000},
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
"activeIndex": 0,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
v3_data = _migrate_v2_to_v3(v2_data)
|
|
204
|
+
|
|
205
|
+
assert v3_data["version"] == 3
|
|
206
|
+
assert "activeIndexByFamily" in v3_data
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestAccountManager:
|
|
210
|
+
"""Test multi-account management."""
|
|
211
|
+
|
|
212
|
+
def test_empty_manager(self):
|
|
213
|
+
"""Empty manager should have no accounts."""
|
|
214
|
+
manager = AccountManager()
|
|
215
|
+
assert manager.account_count == 0
|
|
216
|
+
|
|
217
|
+
def test_add_account(self):
|
|
218
|
+
"""Should be able to add accounts."""
|
|
219
|
+
manager = AccountManager()
|
|
220
|
+
acc = manager.add_account("token123", email="test@example.com")
|
|
221
|
+
|
|
222
|
+
assert manager.account_count == 1
|
|
223
|
+
assert acc.email == "test@example.com"
|
|
224
|
+
|
|
225
|
+
def test_get_current_for_family(self):
|
|
226
|
+
"""Should get current account for family."""
|
|
227
|
+
manager = AccountManager()
|
|
228
|
+
manager.add_account("token1", email="user1@example.com")
|
|
229
|
+
manager.add_account("token2", email="user2@example.com")
|
|
230
|
+
|
|
231
|
+
acc = manager.get_current_or_next_for_family("claude")
|
|
232
|
+
assert acc is not None
|
|
233
|
+
assert acc.email in ["user1@example.com", "user2@example.com"]
|
|
234
|
+
|
|
235
|
+
def test_rate_limit_switches_account(self):
|
|
236
|
+
"""Rate limiting should cause account switch."""
|
|
237
|
+
manager = AccountManager()
|
|
238
|
+
acc1 = manager.add_account("token1", email="user1@example.com")
|
|
239
|
+
manager.add_account("token2", email="user2@example.com")
|
|
240
|
+
|
|
241
|
+
# Mark first account as rate limited for Claude
|
|
242
|
+
manager.mark_rate_limited(acc1, 60000, "claude")
|
|
243
|
+
|
|
244
|
+
# Should get the second account
|
|
245
|
+
current = manager.get_current_or_next_for_family("claude")
|
|
246
|
+
assert current is not None
|
|
247
|
+
assert current.email == "user2@example.com"
|
|
248
|
+
|
|
249
|
+
def test_min_wait_time_calculation(self):
|
|
250
|
+
"""Should calculate minimum wait time correctly."""
|
|
251
|
+
manager = AccountManager()
|
|
252
|
+
acc = manager.add_account("token", email="test@example.com")
|
|
253
|
+
|
|
254
|
+
# No rate limits = 0 wait time
|
|
255
|
+
assert manager.get_min_wait_time_for_family("claude") == 0
|
|
256
|
+
|
|
257
|
+
# Add rate limit
|
|
258
|
+
manager.mark_rate_limited(acc, 5000, "claude")
|
|
259
|
+
wait = manager.get_min_wait_time_for_family("claude")
|
|
260
|
+
assert 0 < wait <= 5000
|
|
261
|
+
|
|
262
|
+
def test_gemini_dual_quota(self):
|
|
263
|
+
"""Gemini should try both quota pools."""
|
|
264
|
+
manager = AccountManager()
|
|
265
|
+
acc = manager.add_account("token", email="test@example.com")
|
|
266
|
+
|
|
267
|
+
# Initially, antigravity should be available
|
|
268
|
+
style = manager.get_available_header_style(acc, "gemini")
|
|
269
|
+
assert style == "antigravity"
|
|
270
|
+
|
|
271
|
+
# Rate limit antigravity
|
|
272
|
+
manager.mark_rate_limited(acc, 60000, "gemini", "antigravity")
|
|
273
|
+
|
|
274
|
+
# Now gemini-cli should be available
|
|
275
|
+
style = manager.get_available_header_style(acc, "gemini")
|
|
276
|
+
assert style == "gemini-cli"
|
|
277
|
+
|
|
278
|
+
# Rate limit gemini-cli too
|
|
279
|
+
manager.mark_rate_limited(acc, 60000, "gemini", "gemini-cli")
|
|
280
|
+
|
|
281
|
+
# No style available
|
|
282
|
+
style = manager.get_available_header_style(acc, "gemini")
|
|
283
|
+
assert style is None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TestConstants:
|
|
287
|
+
"""Test plugin constants are properly configured."""
|
|
288
|
+
|
|
289
|
+
def test_models_have_required_fields(self):
|
|
290
|
+
"""All models should have required configuration."""
|
|
291
|
+
for model_id, config in ANTIGRAVITY_MODELS.items():
|
|
292
|
+
assert "name" in config, f"{model_id} missing name"
|
|
293
|
+
assert "family" in config, f"{model_id} missing family"
|
|
294
|
+
assert "context_length" in config, f"{model_id} missing context_length"
|
|
295
|
+
assert "max_output" in config, f"{model_id} missing max_output"
|
|
296
|
+
|
|
297
|
+
def test_thinking_models_have_budget(self):
|
|
298
|
+
"""Thinking models should have thinking_budget."""
|
|
299
|
+
for model_id, config in ANTIGRAVITY_MODELS.items():
|
|
300
|
+
if "thinking" in model_id:
|
|
301
|
+
assert "thinking_budget" in config, (
|
|
302
|
+
f"{model_id} missing thinking_budget"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def test_scopes_are_valid(self):
|
|
306
|
+
"""OAuth scopes should be valid URLs."""
|
|
307
|
+
for scope in ANTIGRAVITY_SCOPES:
|
|
308
|
+
assert scope.startswith("https://"), f"Invalid scope: {scope}"
|
|
309
|
+
|
|
310
|
+
def test_config_has_required_fields(self):
|
|
311
|
+
"""Plugin config should have required fields."""
|
|
312
|
+
assert "auth_url" in ANTIGRAVITY_OAUTH_CONFIG
|
|
313
|
+
assert "token_url" in ANTIGRAVITY_OAUTH_CONFIG
|
|
314
|
+
assert "callback_port_range" in ANTIGRAVITY_OAUTH_CONFIG
|
|
315
|
+
assert "prefix" in ANTIGRAVITY_OAUTH_CONFIG
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
pytest.main([__file__, "-v"])
|