pdd-cli 0.0.45__py3-none-any.whl → 0.0.118__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.
- pdd/__init__.py +40 -8
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +497 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +526 -0
- pdd/agentic_common.py +598 -0
- pdd/agentic_crash.py +534 -0
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +426 -0
- pdd/agentic_fix.py +1294 -0
- pdd/agentic_langtest.py +162 -0
- pdd/agentic_update.py +387 -0
- pdd/agentic_verify.py +183 -0
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +71 -51
- pdd/auto_include.py +245 -5
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +196 -23
- pdd/bug_to_unit_test.py +2 -0
- pdd/change_main.py +11 -4
- pdd/cli.py +22 -1181
- pdd/cmd_test_main.py +350 -150
- pdd/code_generator.py +60 -18
- pdd/code_generator_main.py +790 -57
- pdd/commands/__init__.py +48 -0
- pdd/commands/analysis.py +306 -0
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +290 -0
- pdd/commands/fix.py +163 -0
- pdd/commands/generate.py +257 -0
- pdd/commands/maintenance.py +175 -0
- pdd/commands/misc.py +87 -0
- pdd/commands/modify.py +256 -0
- pdd/commands/report.py +144 -0
- pdd/commands/sessions.py +284 -0
- pdd/commands/templates.py +215 -0
- pdd/commands/utility.py +110 -0
- pdd/config_resolution.py +58 -0
- pdd/conflicts_main.py +8 -3
- pdd/construct_paths.py +589 -111
- pdd/context_generator.py +10 -2
- pdd/context_generator_main.py +175 -76
- pdd/continue_generation.py +53 -10
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +527 -0
- pdd/core/cloud.py +237 -0
- pdd/core/dump.py +554 -0
- pdd/core/errors.py +67 -0
- pdd/core/remote_session.py +61 -0
- pdd/core/utils.py +90 -0
- pdd/crash_main.py +262 -33
- pdd/data/language_format.csv +71 -63
- pdd/data/llm_model.csv +20 -18
- pdd/detect_change_main.py +5 -4
- pdd/docs/prompting_guide.md +864 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
- pdd/fix_code_loop.py +523 -95
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +491 -92
- pdd/fix_errors_from_unit_tests.py +4 -3
- pdd/fix_main.py +278 -21
- pdd/fix_verification_errors.py +12 -100
- pdd/fix_verification_errors_loop.py +529 -286
- pdd/fix_verification_main.py +294 -89
- pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
- pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
- pdd/frontend/dist/index.html +376 -0
- pdd/frontend/dist/logo.svg +33 -0
- pdd/generate_output_paths.py +139 -15
- pdd/generate_test.py +218 -146
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +318 -22
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +75 -0
- pdd/get_test_command.py +68 -0
- pdd/git_update.py +70 -19
- pdd/incremental_code_generator.py +2 -2
- pdd/insert_includes.py +13 -4
- pdd/llm_invoke.py +1711 -181
- pdd/load_prompt_template.py +19 -12
- pdd/path_resolution.py +140 -0
- pdd/pdd_completion.fish +25 -2
- pdd/pdd_completion.sh +30 -4
- pdd/pdd_completion.zsh +79 -4
- pdd/postprocess.py +14 -4
- pdd/preprocess.py +293 -24
- pdd/preprocess_main.py +41 -6
- pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
- pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
- pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
- pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
- pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
- pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
- pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
- pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
- pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
- pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
- pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
- pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
- pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
- pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
- pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
- pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
- pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
- pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
- pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
- pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
- pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
- pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
- pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
- pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
- pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
- pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
- pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
- pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
- pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
- pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
- pdd/prompts/agentic_update_LLM.prompt +925 -0
- pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
- pdd/prompts/auto_include_LLM.prompt +122 -905
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +686 -27
- pdd/prompts/example_generator_LLM.prompt +22 -1
- pdd/prompts/extract_code_LLM.prompt +5 -1
- pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
- pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
- pdd/prompts/extract_promptline_LLM.prompt +17 -11
- pdd/prompts/find_verification_errors_LLM.prompt +6 -0
- pdd/prompts/fix_code_module_errors_LLM.prompt +12 -2
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +9 -0
- pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
- pdd/prompts/generate_test_LLM.prompt +41 -7
- pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
- pdd/prompts/increase_tests_LLM.prompt +1 -5
- pdd/prompts/insert_includes_LLM.prompt +316 -186
- pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/prompts/trace_LLM.prompt +25 -22
- pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
- pdd/prompts/update_prompt_LLM.prompt +22 -1
- pdd/pytest_output.py +127 -12
- pdd/remote_session.py +876 -0
- pdd/render_mermaid.py +236 -0
- pdd/server/__init__.py +52 -0
- pdd/server/app.py +335 -0
- pdd/server/click_executor.py +587 -0
- pdd/server/executor.py +338 -0
- pdd/server/jobs.py +661 -0
- pdd/server/models.py +241 -0
- pdd/server/routes/__init__.py +31 -0
- pdd/server/routes/architecture.py +451 -0
- pdd/server/routes/auth.py +364 -0
- pdd/server/routes/commands.py +929 -0
- pdd/server/routes/config.py +42 -0
- pdd/server/routes/files.py +603 -0
- pdd/server/routes/prompts.py +1322 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +209 -0
- pdd/server/token_counter.py +222 -0
- pdd/setup_tool.py +648 -0
- pdd/simple_math.py +2 -0
- pdd/split_main.py +3 -2
- pdd/summarize_directory.py +237 -195
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +839 -112
- pdd/sync_main.py +351 -57
- pdd/sync_orchestration.py +1400 -756
- pdd/sync_tui.py +848 -0
- pdd/template_expander.py +161 -0
- pdd/template_registry.py +264 -0
- pdd/templates/architecture/architecture_json.prompt +237 -0
- pdd/templates/generic/generate_prompt.prompt +174 -0
- pdd/trace.py +168 -12
- pdd/trace_main.py +4 -3
- pdd/track_cost.py +140 -63
- pdd/unfinished_prompt.py +51 -4
- pdd/update_main.py +567 -67
- pdd/update_model_costs.py +2 -2
- pdd/update_prompt.py +19 -4
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +29 -11
- pdd_cli-0.0.118.dist-info/RECORD +227 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +1 -1
- pdd_cli-0.0.45.dist-info/RECORD +0 -116
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/get_jwt_token.py
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
2
7
|
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
3
10
|
from typing import Dict, Optional, Tuple
|
|
4
11
|
|
|
5
12
|
# Cross-platform keyring import with fallback for WSL compatibility
|
|
@@ -39,6 +46,195 @@ class RateLimitError(AuthError):
|
|
|
39
46
|
"""Raised when rate limits are exceeded."""
|
|
40
47
|
pass
|
|
41
48
|
|
|
49
|
+
|
|
50
|
+
# JWT file cache path (Issue #273 - reduces keyring access to avoid password prompts)
|
|
51
|
+
JWT_CACHE_FILE = Path.home() / ".pdd" / "jwt_cache"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decode_jwt_payload(token: str) -> Dict:
|
|
55
|
+
"""
|
|
56
|
+
Decode JWT payload without verification to extract claims.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
token: The JWT token string.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dict containing the JWT payload claims, or empty dict on error.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
# JWT is header.payload.signature
|
|
66
|
+
parts = token.split(".")
|
|
67
|
+
if len(parts) != 3:
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
payload = parts[1]
|
|
71
|
+
# Add padding if needed for base64 decoding
|
|
72
|
+
padding = len(payload) % 4
|
|
73
|
+
if padding:
|
|
74
|
+
payload += "=" * (4 - padding)
|
|
75
|
+
|
|
76
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
77
|
+
return json.loads(decoded)
|
|
78
|
+
except Exception:
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
def _get_expected_jwt_audience() -> Optional[str]:
|
|
82
|
+
"""
|
|
83
|
+
Determine the expected JWT audience based on PDD_ENV.
|
|
84
|
+
|
|
85
|
+
This keeps the JWT cache environment-aware when PDD_ENV is set
|
|
86
|
+
(e.g., staging vs prod) without changing the public API.
|
|
87
|
+
"""
|
|
88
|
+
explicit_aud = os.environ.get("PDD_JWT_EXPECTED_AUD")
|
|
89
|
+
if explicit_aud:
|
|
90
|
+
return explicit_aud
|
|
91
|
+
|
|
92
|
+
env = (os.environ.get("PDD_ENV") or "").lower()
|
|
93
|
+
if not env or env == "local":
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
project_id = os.environ.get("PDD_PROJECT_ID") or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
97
|
+
if project_id:
|
|
98
|
+
return project_id
|
|
99
|
+
|
|
100
|
+
if env in ("prod", "production"):
|
|
101
|
+
return "prompt-driven-development"
|
|
102
|
+
if env == "staging":
|
|
103
|
+
return os.environ.get("STAGING_PROJECT_ID") or "prompt-driven-development-stg"
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _get_jwt_audience(jwt: str) -> Optional[str]:
|
|
108
|
+
"""Extract the aud claim without verifying the signature."""
|
|
109
|
+
try:
|
|
110
|
+
parts = jwt.split(".")
|
|
111
|
+
if len(parts) < 2:
|
|
112
|
+
return None
|
|
113
|
+
payload_part = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
114
|
+
payload_bytes = base64.urlsafe_b64decode(payload_part.encode("utf-8"))
|
|
115
|
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
116
|
+
return payload.get("aud") or payload.get("firebase", {}).get("aud")
|
|
117
|
+
except Exception:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _get_cached_jwt(verbose: bool = False) -> Optional[str]:
|
|
122
|
+
"""
|
|
123
|
+
Get cached JWT if it exists and is not expired.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
verbose: If True, print helpful messages when cache is invalid
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Optional[str]: The cached JWT if valid, None otherwise.
|
|
130
|
+
"""
|
|
131
|
+
if not JWT_CACHE_FILE.exists():
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
with open(JWT_CACHE_FILE, 'r') as f:
|
|
135
|
+
cache = json.load(f)
|
|
136
|
+
# Check expiration with 5 minute buffer
|
|
137
|
+
expires_at = cache.get('expires_at', 0)
|
|
138
|
+
current_time = time.time()
|
|
139
|
+
if expires_at > current_time + 300:
|
|
140
|
+
# Check both 'id_token' (new) and 'jwt' (legacy) keys for backwards compatibility
|
|
141
|
+
jwt = cache.get('id_token') or cache.get('jwt')
|
|
142
|
+
expected_aud = _get_expected_jwt_audience()
|
|
143
|
+
if expected_aud:
|
|
144
|
+
actual_aud = _get_jwt_audience(jwt or "")
|
|
145
|
+
if actual_aud != expected_aud:
|
|
146
|
+
if verbose:
|
|
147
|
+
print(f"JWT cache invalidated: audience mismatch")
|
|
148
|
+
print(f" Expected: {expected_aud}")
|
|
149
|
+
print(f" Got: {actual_aud}")
|
|
150
|
+
print(f" This usually means you switched environments (staging vs prod)")
|
|
151
|
+
print(f" Clearing cache and re-authenticating...")
|
|
152
|
+
try:
|
|
153
|
+
JWT_CACHE_FILE.unlink()
|
|
154
|
+
except OSError:
|
|
155
|
+
pass
|
|
156
|
+
return None
|
|
157
|
+
return jwt
|
|
158
|
+
else:
|
|
159
|
+
if verbose:
|
|
160
|
+
time_remaining = expires_at - current_time
|
|
161
|
+
if time_remaining < 0:
|
|
162
|
+
print(f"JWT cache invalidated: token expired {int(-time_remaining / 60)} minutes ago")
|
|
163
|
+
else:
|
|
164
|
+
print(f"JWT cache invalidated: token expires soon (in {int(time_remaining / 60)} minutes)")
|
|
165
|
+
except (json.JSONDecodeError, IOError, KeyError) as e:
|
|
166
|
+
if verbose:
|
|
167
|
+
print(f"JWT cache invalidated: corrupted cache file ({e})")
|
|
168
|
+
# Cache corrupted, delete it
|
|
169
|
+
try:
|
|
170
|
+
JWT_CACHE_FILE.unlink()
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _cache_jwt(jwt: str, expires_in: int = 3600) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Cache JWT with expiration time.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
jwt: The JWT token to cache.
|
|
182
|
+
expires_in: Fallback time in seconds if exp claim cannot be extracted (default: 3600 = 1 hour).
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
JWT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
|
|
187
|
+
# Try to extract actual expiration from JWT's exp claim
|
|
188
|
+
payload = _decode_jwt_payload(jwt)
|
|
189
|
+
exp_claim = payload.get('exp')
|
|
190
|
+
if exp_claim:
|
|
191
|
+
expires_at = exp_claim
|
|
192
|
+
else:
|
|
193
|
+
# Fallback to calculated expiration if exp claim not found
|
|
194
|
+
expires_at = time.time() + expires_in
|
|
195
|
+
|
|
196
|
+
cache = {
|
|
197
|
+
'id_token': jwt, # Use 'id_token' key to match auth.py format
|
|
198
|
+
'expires_at': expires_at,
|
|
199
|
+
'cached_at': time.time()
|
|
200
|
+
}
|
|
201
|
+
with open(JWT_CACHE_FILE, 'w') as f:
|
|
202
|
+
json.dump(cache, f)
|
|
203
|
+
# Secure the file (user read/write only)
|
|
204
|
+
os.chmod(JWT_CACHE_FILE, 0o600)
|
|
205
|
+
except (IOError, OSError) as e:
|
|
206
|
+
# Cache write failed, continue without caching
|
|
207
|
+
print(f"Warning: Failed to cache JWT: {e}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _macos_force_delete_keychain_item(service_name: str, account_name: str) -> bool:
|
|
211
|
+
"""
|
|
212
|
+
Force delete a keychain item using macOS security command.
|
|
213
|
+
|
|
214
|
+
This is a fallback when keyring.delete_password() fails due to ACL issues
|
|
215
|
+
in subprocess contexts (e.g., pytest-xdist workers).
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
service_name: The keychain service name
|
|
219
|
+
account_name: The keychain account name
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
bool: True if deletion succeeded or item didn't exist, False otherwise
|
|
223
|
+
"""
|
|
224
|
+
if sys.platform != 'darwin':
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
result = subprocess.run(
|
|
229
|
+
['security', 'delete-generic-password', '-s', service_name, '-a', account_name],
|
|
230
|
+
capture_output=True, text=True, timeout=10
|
|
231
|
+
)
|
|
232
|
+
# 0 = success, 44 = item not found (also acceptable)
|
|
233
|
+
return result.returncode in (0, 44)
|
|
234
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
42
238
|
class DeviceFlow:
|
|
43
239
|
"""
|
|
44
240
|
Handles the GitHub Device Flow authentication process.
|
|
@@ -139,15 +335,62 @@ class FirebaseAuthenticator:
|
|
|
139
335
|
self.keyring_service_name = f"firebase-auth-{app_name}"
|
|
140
336
|
self.keyring_user_name = "refresh_token"
|
|
141
337
|
|
|
142
|
-
def _store_refresh_token(self, refresh_token: str):
|
|
143
|
-
"""
|
|
338
|
+
def _store_refresh_token(self, refresh_token: str) -> bool:
|
|
339
|
+
"""
|
|
340
|
+
Stores the Firebase refresh token in the system keyring.
|
|
341
|
+
|
|
342
|
+
Handles the macOS errSecDuplicateItem (-25299) error by attempting
|
|
343
|
+
to force-delete the existing item and retrying.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
refresh_token: The Firebase refresh token to store
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
bool: True if storage succeeded, False otherwise
|
|
350
|
+
"""
|
|
144
351
|
if not KEYRING_AVAILABLE or keyring is None:
|
|
145
352
|
print("Warning: No keyring available, refresh token not stored")
|
|
146
|
-
return
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
max_retries = 2
|
|
356
|
+
|
|
357
|
+
for attempt in range(max_retries):
|
|
358
|
+
try:
|
|
359
|
+
keyring.set_password(
|
|
360
|
+
self.keyring_service_name,
|
|
361
|
+
self.keyring_user_name,
|
|
362
|
+
refresh_token
|
|
363
|
+
)
|
|
364
|
+
return True
|
|
365
|
+
except Exception as e:
|
|
366
|
+
error_str = str(e)
|
|
367
|
+
|
|
368
|
+
# Check for errSecDuplicateItem (-25299) on macOS
|
|
369
|
+
is_duplicate_error = '-25299' in error_str
|
|
370
|
+
|
|
371
|
+
if is_duplicate_error and attempt < max_retries - 1:
|
|
372
|
+
# Try to delete the existing item before retrying
|
|
373
|
+
try:
|
|
374
|
+
keyring.delete_password(
|
|
375
|
+
self.keyring_service_name,
|
|
376
|
+
self.keyring_user_name
|
|
377
|
+
)
|
|
378
|
+
except Exception:
|
|
379
|
+
pass # Ignore delete errors, try force delete
|
|
380
|
+
|
|
381
|
+
# Try macOS-specific force delete
|
|
382
|
+
if sys.platform == 'darwin':
|
|
383
|
+
_macos_force_delete_keychain_item(
|
|
384
|
+
self.keyring_service_name,
|
|
385
|
+
self.keyring_user_name
|
|
386
|
+
)
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
# Non-duplicate error or final retry failed
|
|
390
|
+
print(f"Warning: Failed to store refresh token in keyring: {e}")
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
return False
|
|
151
394
|
|
|
152
395
|
def _get_stored_refresh_token(self) -> Optional[str]:
|
|
153
396
|
"""Retrieves the Firebase refresh token from the system keyring."""
|
|
@@ -159,21 +402,37 @@ class FirebaseAuthenticator:
|
|
|
159
402
|
print(f"Warning: Failed to retrieve refresh token from keyring: {e}")
|
|
160
403
|
return None
|
|
161
404
|
|
|
162
|
-
def _delete_stored_refresh_token(self):
|
|
163
|
-
"""
|
|
405
|
+
def _delete_stored_refresh_token(self) -> bool:
|
|
406
|
+
"""
|
|
407
|
+
Deletes the stored Firebase refresh token from the keyring.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
bool: True if deletion succeeded or token didn't exist, False otherwise
|
|
411
|
+
"""
|
|
164
412
|
if not KEYRING_AVAILABLE or keyring is None:
|
|
165
413
|
print("No keyring available. Token deletion skipped.")
|
|
166
|
-
return
|
|
414
|
+
return True
|
|
415
|
+
|
|
167
416
|
try:
|
|
168
417
|
keyring.delete_password(self.keyring_service_name, self.keyring_user_name)
|
|
418
|
+
return True
|
|
169
419
|
except Exception as e:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
420
|
+
error_str = str(e)
|
|
421
|
+
|
|
422
|
+
# Check if it's a "not found" error (acceptable)
|
|
423
|
+
if 'PasswordDeleteError' in str(type(e)) or 'not found' in error_str.lower():
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
# Try macOS force delete as fallback
|
|
427
|
+
if sys.platform == 'darwin':
|
|
428
|
+
if _macos_force_delete_keychain_item(
|
|
429
|
+
self.keyring_service_name,
|
|
430
|
+
self.keyring_user_name
|
|
431
|
+
):
|
|
432
|
+
return True
|
|
433
|
+
|
|
434
|
+
print(f"Warning: Error deleting token from keyring: {e}")
|
|
435
|
+
return False
|
|
177
436
|
|
|
178
437
|
async def _refresh_firebase_token(self, refresh_token: str) -> str:
|
|
179
438
|
"""
|
|
@@ -248,7 +507,14 @@ class FirebaseAuthenticator:
|
|
|
248
507
|
except requests.exceptions.ConnectionError as e:
|
|
249
508
|
raise NetworkError(f"Failed to connect to Firebase: {e}")
|
|
250
509
|
except requests.exceptions.RequestException as e:
|
|
251
|
-
|
|
510
|
+
# Capture more detail to help diagnose provider configuration or audience mismatches
|
|
511
|
+
extra = ""
|
|
512
|
+
if getattr(e, "response", None) is not None:
|
|
513
|
+
try:
|
|
514
|
+
extra = f" | response: {e.response.text}"
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
raise TokenError(f"Error exchanging GitHub token for Firebase token: {e}{extra}")
|
|
252
518
|
|
|
253
519
|
def verify_firebase_token(self, id_token: str) -> bool:
|
|
254
520
|
"""
|
|
@@ -259,7 +525,12 @@ class FirebaseAuthenticator:
|
|
|
259
525
|
"""
|
|
260
526
|
return bool(id_token)
|
|
261
527
|
|
|
262
|
-
async def get_jwt_token(
|
|
528
|
+
async def get_jwt_token(
|
|
529
|
+
firebase_api_key: str,
|
|
530
|
+
github_client_id: str,
|
|
531
|
+
app_name: str = "my-cli-app",
|
|
532
|
+
no_browser: bool = False
|
|
533
|
+
) -> str:
|
|
263
534
|
"""
|
|
264
535
|
Get a Firebase ID token using GitHub's Device Flow authentication.
|
|
265
536
|
|
|
@@ -267,6 +538,7 @@ async def get_jwt_token(firebase_api_key: str, github_client_id: str, app_name:
|
|
|
267
538
|
firebase_api_key: Firebase Web API key
|
|
268
539
|
github_client_id: OAuth client ID for GitHub app
|
|
269
540
|
app_name: Unique name for your CLI application
|
|
541
|
+
no_browser: If True, skip automatic browser opening (for remote/SSH sessions)
|
|
270
542
|
|
|
271
543
|
Returns:
|
|
272
544
|
str: A valid Firebase ID token
|
|
@@ -276,15 +548,21 @@ async def get_jwt_token(firebase_api_key: str, github_client_id: str, app_name:
|
|
|
276
548
|
NetworkError: If there are connectivity issues
|
|
277
549
|
TokenError: If token exchange fails
|
|
278
550
|
"""
|
|
551
|
+
# Check JWT cache FIRST to avoid keyring access (Issue #273)
|
|
552
|
+
cached_jwt = _get_cached_jwt()
|
|
553
|
+
if cached_jwt:
|
|
554
|
+
return cached_jwt
|
|
555
|
+
|
|
279
556
|
firebase_auth = FirebaseAuthenticator(firebase_api_key, app_name)
|
|
280
557
|
|
|
281
|
-
# Check for existing refresh token
|
|
558
|
+
# Check for existing refresh token in keyring
|
|
282
559
|
refresh_token = firebase_auth._get_stored_refresh_token()
|
|
283
560
|
if refresh_token:
|
|
284
561
|
try:
|
|
285
562
|
# Attempt to refresh the token
|
|
286
563
|
id_token = await firebase_auth._refresh_firebase_token(refresh_token)
|
|
287
564
|
if firebase_auth.verify_firebase_token(id_token):
|
|
565
|
+
_cache_jwt(id_token) # Cache for next time
|
|
288
566
|
return id_token
|
|
289
567
|
else:
|
|
290
568
|
print("Refreshed token is invalid. Attempting re-authentication.")
|
|
@@ -304,7 +582,21 @@ async def get_jwt_token(firebase_api_key: str, github_client_id: str, app_name:
|
|
|
304
582
|
# Display instructions to the user
|
|
305
583
|
print(f"To authenticate, visit: {device_code_response['verification_uri']}")
|
|
306
584
|
print(f"Enter code: {device_code_response['user_code']}")
|
|
585
|
+
sys.stdout.flush() # Ensure visibility in piped contexts
|
|
586
|
+
|
|
587
|
+
# Open browser only if not explicitly disabled
|
|
588
|
+
if not no_browser:
|
|
589
|
+
try:
|
|
590
|
+
webbrowser.open(device_code_response['verification_uri'])
|
|
591
|
+
print("Opening browser for authentication...")
|
|
592
|
+
except Exception as e:
|
|
593
|
+
print(f"Note: Could not open browser: {e}")
|
|
594
|
+
print("Please open the URL manually in your browser.")
|
|
595
|
+
else:
|
|
596
|
+
print("Browser opening disabled. Please open the URL manually in your browser.")
|
|
597
|
+
|
|
307
598
|
print("Waiting for authentication...")
|
|
599
|
+
sys.stdout.flush()
|
|
308
600
|
|
|
309
601
|
# Poll for GitHub token
|
|
310
602
|
github_token = await device_flow.poll_for_token(
|
|
@@ -312,11 +604,15 @@ async def get_jwt_token(firebase_api_key: str, github_client_id: str, app_name:
|
|
|
312
604
|
device_code_response["interval"],
|
|
313
605
|
device_code_response["expires_in"],
|
|
314
606
|
)
|
|
607
|
+
print("Authentication successful!")
|
|
315
608
|
|
|
316
609
|
# Exchange GitHub token for Firebase token
|
|
317
610
|
id_token, refresh_token = await firebase_auth.exchange_github_token_for_firebase_token(github_token)
|
|
318
611
|
|
|
319
|
-
# Store refresh token
|
|
612
|
+
# Store refresh token in keyring
|
|
320
613
|
firebase_auth._store_refresh_token(refresh_token)
|
|
321
614
|
|
|
322
|
-
|
|
615
|
+
# Cache JWT for subsequent calls
|
|
616
|
+
_cache_jwt(id_token)
|
|
617
|
+
|
|
618
|
+
return id_token
|
pdd/get_language.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import csv
|
|
2
|
+
from pdd.path_resolution import get_default_resolver
|
|
3
3
|
|
|
4
4
|
def get_language(extension: str) -> str:
|
|
5
5
|
"""
|
|
@@ -14,10 +14,12 @@ def get_language(extension: str) -> str:
|
|
|
14
14
|
Raises:
|
|
15
15
|
ValueError: If PDD_PATH environment variable is not set.
|
|
16
16
|
"""
|
|
17
|
-
# Step 1:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
# Step 1: Resolve CSV path from PDD_PATH
|
|
18
|
+
resolver = get_default_resolver()
|
|
19
|
+
try:
|
|
20
|
+
csv_path = resolver.resolve_data_file("data/language_format.csv")
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
raise ValueError("PDD_PATH environment variable is not set") from exc
|
|
21
23
|
|
|
22
24
|
# Step 2: Ensure the extension starts with a dot and convert to lowercase
|
|
23
25
|
if not extension.startswith('.'):
|
|
@@ -25,7 +27,6 @@ def get_language(extension: str) -> str:
|
|
|
25
27
|
extension = extension.lower()
|
|
26
28
|
|
|
27
29
|
# Step 3 & 4: Look up the language name and handle exceptions
|
|
28
|
-
csv_path = os.path.join(pdd_path, 'data', 'language_format.csv')
|
|
29
30
|
try:
|
|
30
31
|
with open(csv_path, 'r') as csvfile:
|
|
31
32
|
reader = csv.DictReader(csvfile)
|
|
@@ -38,4 +39,4 @@ def get_language(extension: str) -> str:
|
|
|
38
39
|
except csv.Error as e:
|
|
39
40
|
print(f"Error reading CSV file: {e}")
|
|
40
41
|
|
|
41
|
-
return '' # Return empty string if extension not found or any error occurs
|
|
42
|
+
return '' # Return empty string if extension not found or any error occurs
|
pdd/get_run_command.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Module to retrieve run commands for programming languages."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import csv
|
|
5
|
+
from pdd.path_resolution import get_default_resolver
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_run_command(extension: str) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Retrieves the run command for a given file extension.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
extension: The file extension (e.g., ".py", ".js").
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
The run command template with {file} placeholder (e.g., "python {file}"),
|
|
17
|
+
or an empty string if not found or not executable.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
ValueError: If the PDD_PATH environment variable is not set.
|
|
21
|
+
"""
|
|
22
|
+
# Step 1: Resolve CSV path from PDD_PATH
|
|
23
|
+
resolver = get_default_resolver()
|
|
24
|
+
try:
|
|
25
|
+
csv_path = resolver.resolve_data_file("data/language_format.csv")
|
|
26
|
+
except ValueError as exc:
|
|
27
|
+
raise ValueError("PDD_PATH environment variable is not set") from exc
|
|
28
|
+
|
|
29
|
+
# Step 2: Ensure the extension starts with a dot and convert to lowercase
|
|
30
|
+
if not extension.startswith('.'):
|
|
31
|
+
extension = '.' + extension
|
|
32
|
+
extension = extension.lower()
|
|
33
|
+
|
|
34
|
+
# Step 3: Look up the run command
|
|
35
|
+
try:
|
|
36
|
+
with open(csv_path, 'r') as csvfile:
|
|
37
|
+
reader = csv.DictReader(csvfile)
|
|
38
|
+
for row in reader:
|
|
39
|
+
if row['extension'].lower() == extension:
|
|
40
|
+
run_command = row.get('run_command', '').strip()
|
|
41
|
+
return run_command if run_command else ''
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
print(f"CSV file not found at {csv_path}")
|
|
44
|
+
except csv.Error as e:
|
|
45
|
+
print(f"Error reading CSV file: {e}")
|
|
46
|
+
except KeyError:
|
|
47
|
+
# run_command column doesn't exist
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
return ''
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_run_command_for_file(file_path: str) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Retrieves the run command for a given file, with the {file} placeholder replaced.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
file_path: The path to the file to run.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The complete run command (e.g., "python /path/to/script.py"),
|
|
62
|
+
or an empty string if no run command is available for this file type.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If the PDD_PATH environment variable is not set.
|
|
66
|
+
"""
|
|
67
|
+
_, extension = os.path.splitext(file_path)
|
|
68
|
+
if not extension:
|
|
69
|
+
return ''
|
|
70
|
+
|
|
71
|
+
run_command_template = get_run_command(extension)
|
|
72
|
+
if not run_command_template:
|
|
73
|
+
return ''
|
|
74
|
+
|
|
75
|
+
return run_command_template.replace('{file}', file_path)
|
pdd/get_test_command.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# pdd/get_test_command.py
|
|
2
|
+
"""Get language-appropriate test commands.
|
|
3
|
+
|
|
4
|
+
This module provides functions to resolve the appropriate test command
|
|
5
|
+
for a given test file based on:
|
|
6
|
+
1. CSV run_test_command (if non-empty)
|
|
7
|
+
2. Smart detection via default_verify_cmd_for()
|
|
8
|
+
3. None (triggers agentic fallback)
|
|
9
|
+
"""
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
import csv
|
|
13
|
+
|
|
14
|
+
from .agentic_langtest import default_verify_cmd_for
|
|
15
|
+
from .get_language import get_language
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_language_format() -> dict:
|
|
19
|
+
"""Load language_format.csv into a dict keyed by extension."""
|
|
20
|
+
csv_path = Path(__file__).parent.parent / "data" / "language_format.csv"
|
|
21
|
+
result = {}
|
|
22
|
+
with open(csv_path, 'r') as f:
|
|
23
|
+
reader = csv.DictReader(f)
|
|
24
|
+
for row in reader:
|
|
25
|
+
ext = row.get('extension', '')
|
|
26
|
+
if ext:
|
|
27
|
+
result[ext] = row
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_test_command_for_file(test_file: str, language: Optional[str] = None) -> Optional[str]:
|
|
32
|
+
"""
|
|
33
|
+
Get the appropriate test command for a test file.
|
|
34
|
+
|
|
35
|
+
Resolution order:
|
|
36
|
+
1. CSV run_test_command (if non-empty)
|
|
37
|
+
2. Smart detection via default_verify_cmd_for()
|
|
38
|
+
3. None (triggers agentic fallback)
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
test_file: Path to the test file
|
|
42
|
+
language: Optional language override
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Test command string with {file} placeholder replaced, or None
|
|
46
|
+
"""
|
|
47
|
+
test_path = Path(test_file)
|
|
48
|
+
ext = test_path.suffix
|
|
49
|
+
|
|
50
|
+
resolved_language = language
|
|
51
|
+
if resolved_language is None:
|
|
52
|
+
resolved_language = get_language(ext)
|
|
53
|
+
|
|
54
|
+
# 1. Check CSV for run_test_command
|
|
55
|
+
lang_formats = _load_language_format()
|
|
56
|
+
if ext in lang_formats:
|
|
57
|
+
csv_cmd = lang_formats[ext].get('run_test_command', '').strip()
|
|
58
|
+
if csv_cmd:
|
|
59
|
+
return csv_cmd.replace('{file}', str(test_file))
|
|
60
|
+
|
|
61
|
+
# 2. Smart detection
|
|
62
|
+
if resolved_language:
|
|
63
|
+
smart_cmd = default_verify_cmd_for(resolved_language.lower(), str(test_file))
|
|
64
|
+
if smart_cmd:
|
|
65
|
+
return smart_cmd
|
|
66
|
+
|
|
67
|
+
# 3. No command available
|
|
68
|
+
return None
|