pdd-cli 0.0.90__py3-none-any.whl → 0.0.121__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. pdd/__init__.py +38 -6
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +506 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +537 -0
  6. pdd/agentic_common.py +533 -770
  7. pdd/agentic_crash.py +2 -1
  8. pdd/agentic_e2e_fix.py +319 -0
  9. pdd/agentic_e2e_fix_orchestrator.py +582 -0
  10. pdd/agentic_fix.py +118 -3
  11. pdd/agentic_update.py +27 -9
  12. pdd/agentic_verify.py +3 -2
  13. pdd/architecture_sync.py +565 -0
  14. pdd/auth_service.py +210 -0
  15. pdd/auto_deps_main.py +63 -53
  16. pdd/auto_include.py +236 -3
  17. pdd/auto_update.py +125 -47
  18. pdd/bug_main.py +195 -23
  19. pdd/cmd_test_main.py +345 -197
  20. pdd/code_generator.py +4 -2
  21. pdd/code_generator_main.py +118 -32
  22. pdd/commands/__init__.py +6 -0
  23. pdd/commands/analysis.py +113 -48
  24. pdd/commands/auth.py +309 -0
  25. pdd/commands/connect.py +358 -0
  26. pdd/commands/fix.py +155 -114
  27. pdd/commands/generate.py +5 -0
  28. pdd/commands/maintenance.py +3 -2
  29. pdd/commands/misc.py +8 -0
  30. pdd/commands/modify.py +225 -163
  31. pdd/commands/sessions.py +284 -0
  32. pdd/commands/utility.py +12 -7
  33. pdd/construct_paths.py +334 -32
  34. pdd/context_generator_main.py +167 -170
  35. pdd/continue_generation.py +6 -3
  36. pdd/core/__init__.py +33 -0
  37. pdd/core/cli.py +44 -7
  38. pdd/core/cloud.py +237 -0
  39. pdd/core/dump.py +68 -20
  40. pdd/core/errors.py +4 -0
  41. pdd/core/remote_session.py +61 -0
  42. pdd/crash_main.py +219 -23
  43. pdd/data/llm_model.csv +4 -4
  44. pdd/docs/prompting_guide.md +864 -0
  45. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  46. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  47. pdd/fix_code_loop.py +208 -34
  48. pdd/fix_code_module_errors.py +6 -2
  49. pdd/fix_error_loop.py +291 -38
  50. pdd/fix_main.py +208 -6
  51. pdd/fix_verification_errors_loop.py +235 -26
  52. pdd/fix_verification_main.py +269 -83
  53. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  54. pdd/frontend/dist/assets/index-CUWd8al1.js +450 -0
  55. pdd/frontend/dist/index.html +376 -0
  56. pdd/frontend/dist/logo.svg +33 -0
  57. pdd/generate_output_paths.py +46 -5
  58. pdd/generate_test.py +212 -151
  59. pdd/get_comment.py +19 -44
  60. pdd/get_extension.py +8 -9
  61. pdd/get_jwt_token.py +309 -20
  62. pdd/get_language.py +8 -7
  63. pdd/get_run_command.py +7 -5
  64. pdd/insert_includes.py +2 -1
  65. pdd/llm_invoke.py +531 -97
  66. pdd/load_prompt_template.py +15 -34
  67. pdd/operation_log.py +342 -0
  68. pdd/path_resolution.py +140 -0
  69. pdd/postprocess.py +122 -97
  70. pdd/preprocess.py +68 -12
  71. pdd/preprocess_main.py +33 -1
  72. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  73. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  74. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  75. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  76. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  77. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  78. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  79. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  80. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  81. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  82. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  83. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  84. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +140 -0
  85. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  86. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  87. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  88. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  89. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  90. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  91. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  92. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  93. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  94. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  95. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  96. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  97. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  98. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  99. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  100. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  101. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  102. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  103. pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
  104. pdd/prompts/agentic_update_LLM.prompt +192 -338
  105. pdd/prompts/auto_include_LLM.prompt +22 -0
  106. pdd/prompts/change_LLM.prompt +3093 -1
  107. pdd/prompts/detect_change_LLM.prompt +571 -14
  108. pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
  109. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
  110. pdd/prompts/generate_test_LLM.prompt +19 -1
  111. pdd/prompts/generate_test_from_example_LLM.prompt +366 -0
  112. pdd/prompts/insert_includes_LLM.prompt +262 -252
  113. pdd/prompts/prompt_code_diff_LLM.prompt +123 -0
  114. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  115. pdd/remote_session.py +876 -0
  116. pdd/server/__init__.py +52 -0
  117. pdd/server/app.py +335 -0
  118. pdd/server/click_executor.py +587 -0
  119. pdd/server/executor.py +338 -0
  120. pdd/server/jobs.py +661 -0
  121. pdd/server/models.py +241 -0
  122. pdd/server/routes/__init__.py +31 -0
  123. pdd/server/routes/architecture.py +451 -0
  124. pdd/server/routes/auth.py +364 -0
  125. pdd/server/routes/commands.py +929 -0
  126. pdd/server/routes/config.py +42 -0
  127. pdd/server/routes/files.py +603 -0
  128. pdd/server/routes/prompts.py +1347 -0
  129. pdd/server/routes/websocket.py +473 -0
  130. pdd/server/security.py +243 -0
  131. pdd/server/terminal_spawner.py +217 -0
  132. pdd/server/token_counter.py +222 -0
  133. pdd/summarize_directory.py +236 -237
  134. pdd/sync_animation.py +8 -4
  135. pdd/sync_determine_operation.py +329 -47
  136. pdd/sync_main.py +272 -28
  137. pdd/sync_orchestration.py +289 -211
  138. pdd/sync_order.py +304 -0
  139. pdd/template_expander.py +161 -0
  140. pdd/templates/architecture/architecture_json.prompt +41 -46
  141. pdd/trace.py +1 -1
  142. pdd/track_cost.py +0 -13
  143. pdd/unfinished_prompt.py +2 -1
  144. pdd/update_main.py +68 -26
  145. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/METADATA +15 -10
  146. pdd_cli-0.0.121.dist-info/RECORD +229 -0
  147. pdd_cli-0.0.90.dist-info/RECORD +0 -153
  148. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/WHEEL +0 -0
  149. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/entry_points.txt +0 -0
  150. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/licenses/LICENSE +0 -0
  151. {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,7 @@
1
1
  from pathlib import Path
2
- import os
3
2
  from typing import Optional
4
- import sys
5
3
  from rich import print
4
+ from pdd.path_resolution import get_default_resolver
6
5
 
7
6
  def print_formatted(message: str) -> None:
8
7
  """Print message with raw formatting tags for testing compatibility."""
@@ -23,40 +22,22 @@ def load_prompt_template(prompt_name: str) -> Optional[str]:
23
22
  print_formatted("[red]Unexpected error loading prompt template[/red]")
24
23
  return None
25
24
 
26
- # Step 1: Get project path from environment variable (preferred),
27
- # else fall back to auto-detect based on this module's location or CWD.
28
- project_path_env = os.getenv('PDD_PATH')
29
- candidate_paths = []
30
- if project_path_env:
31
- candidate_paths.append(Path(project_path_env))
25
+ resolver = get_default_resolver()
26
+ prompt_path = resolver.resolve_prompt_template(prompt_name)
32
27
 
33
- # Fallback 1: repository root inferred from this module (pdd/ => repo root)
34
- try:
35
- module_root = Path(__file__).resolve().parent # pdd/
36
- repo_root = module_root.parent # repo root
37
- candidate_paths.append(repo_root)
38
- except Exception:
39
- pass
40
-
41
- # Fallback 2: current working directory
42
- candidate_paths.append(Path.cwd())
43
-
44
- # Build candidate prompt paths to try in order
45
- prompt_candidates = []
46
- for cp in candidate_paths:
47
- # Check both <path>/prompts/ and <path>/pdd/prompts/
48
- # The latter handles installed package case where prompts are in pdd/prompts/
49
- prompt_candidates.append(cp / 'prompts' / f"{prompt_name}.prompt")
50
- prompt_candidates.append(cp / 'pdd' / 'prompts' / f"{prompt_name}.prompt")
28
+ if prompt_path is None:
29
+ candidate_roots = []
30
+ if resolver.pdd_path_env is not None:
31
+ candidate_roots.append(resolver.pdd_path_env)
32
+ if resolver.repo_root is not None:
33
+ candidate_roots.append(resolver.repo_root)
34
+ candidate_roots.append(resolver.cwd)
51
35
 
52
- # Step 2: Load and return the prompt template
53
- prompt_path: Optional[Path] = None
54
- for candidate in prompt_candidates:
55
- if candidate.exists():
56
- prompt_path = candidate
57
- break
36
+ prompt_candidates = []
37
+ for root in candidate_roots:
38
+ prompt_candidates.append(root / 'prompts' / f"{prompt_name}.prompt")
39
+ prompt_candidates.append(root / 'pdd' / 'prompts' / f"{prompt_name}.prompt")
58
40
 
59
- if prompt_path is None:
60
41
  tried = "\n".join(str(c) for c in prompt_candidates)
61
42
  print_formatted(
62
43
  f"[red]Prompt file not found in any candidate locations for '{prompt_name}'. Tried:\n{tried}[/red]"
@@ -82,4 +63,4 @@ if __name__ == "__main__":
82
63
  prompt = load_prompt_template("example_prompt")
83
64
  if prompt:
84
65
  print_formatted("[blue]Loaded prompt template:[/blue]")
85
- print_formatted(prompt)
66
+ print_formatted(prompt)
pdd/operation_log.py ADDED
@@ -0,0 +1,342 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import json
5
+ import os
6
+ import re
7
+
8
+ import time
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
12
+
13
+ from rich.console import Console
14
+
15
+ # We assume standard paths relative to the project root
16
+ PDD_DIR = ".pdd"
17
+ META_DIR = os.path.join(PDD_DIR, "meta")
18
+
19
+
20
+ def ensure_meta_dir() -> None:
21
+ """Ensure the .pdd/meta directory exists."""
22
+ os.makedirs(META_DIR, exist_ok=True)
23
+
24
+
25
+ def get_log_path(basename: str, language: str) -> Path:
26
+ """Get the path to the sync log for a specific module."""
27
+ ensure_meta_dir()
28
+ return Path(META_DIR) / f"{basename}_{language}_sync.log"
29
+
30
+
31
+ def get_fingerprint_path(basename: str, language: str) -> Path:
32
+ """Get the path to the fingerprint JSON file for a specific module."""
33
+ ensure_meta_dir()
34
+ return Path(META_DIR) / f"{basename}_{language}.json"
35
+
36
+
37
+ def get_run_report_path(basename: str, language: str) -> Path:
38
+ """Get the path to the run report file for a specific module."""
39
+ ensure_meta_dir()
40
+ return Path(META_DIR) / f"{basename}_{language}_run.json"
41
+
42
+
43
+ def infer_module_identity(prompt_file_path: Union[str, Path]) -> Tuple[Optional[str], Optional[str]]:
44
+ """
45
+ Infer basename and language from a prompt file path.
46
+
47
+ Expected pattern: prompts/{basename}_{language}.prompt
48
+
49
+ Args:
50
+ prompt_file_path: Path to the prompt file.
51
+
52
+ Returns:
53
+ Tuple of (basename, language) or (None, None) if inference fails.
54
+ """
55
+ path_obj = Path(prompt_file_path)
56
+ filename = path_obj.stem # e.g., "my_module_python" from "my_module_python.prompt"
57
+
58
+ # Try to split by the last underscore to separate language
59
+ # This is a heuristic; strict naming conventions are assumed
60
+ match = re.match(r"^(.*)_([^_]+)$", filename)
61
+ if match:
62
+ basename = match.group(1)
63
+ language = match.group(2)
64
+ return basename, language
65
+
66
+ return None, None
67
+
68
+
69
+ def load_operation_log(basename: str, language: str) -> List[Dict[str, Any]]:
70
+ """
71
+ Load all log entries for a module.
72
+
73
+ Args:
74
+ basename: Module basename.
75
+ language: Module language.
76
+
77
+ Returns:
78
+ List of log entries (dictionaries).
79
+ """
80
+ log_path = get_log_path(basename, language)
81
+ entries = []
82
+
83
+ if log_path.exists():
84
+ try:
85
+ with open(log_path, 'r', encoding='utf-8') as f:
86
+ for line in f:
87
+ if line.strip():
88
+ try:
89
+ entry = json.loads(line)
90
+ # Backwards compatibility: defaulting invocation_mode to "sync"
91
+ if "invocation_mode" not in entry:
92
+ entry["invocation_mode"] = "sync"
93
+ entries.append(entry)
94
+ except json.JSONDecodeError:
95
+ continue
96
+ except Exception:
97
+ # If log is corrupt or unreadable, return empty list rather than crashing
98
+ pass
99
+
100
+ return entries
101
+
102
+
103
+ def append_log_entry(
104
+ basename: str,
105
+ language: str,
106
+ entry: Dict[str, Any]
107
+ ) -> None:
108
+ """
109
+ Append a single entry to the module's sync log.
110
+
111
+ Args:
112
+ basename: Module basename.
113
+ language: Module language.
114
+ entry: Dictionary of data to log.
115
+ """
116
+ log_path = get_log_path(basename, language)
117
+
118
+ # Ensure standard fields exist
119
+ if "timestamp" not in entry:
120
+ entry["timestamp"] = datetime.now().isoformat()
121
+
122
+ try:
123
+ with open(log_path, 'a', encoding='utf-8') as f:
124
+ f.write(json.dumps(entry) + "\n")
125
+ except Exception as e:
126
+ # Fallback console warning if logging fails
127
+ console = Console()
128
+ console.print(f"[yellow]Warning: Failed to write to log file {log_path}: {e}[/yellow]")
129
+
130
+
131
+ def create_log_entry(
132
+ operation: str,
133
+ reason: str,
134
+ invocation_mode: str = "sync",
135
+ estimated_cost: float = 0.0,
136
+ confidence: float = 0.0,
137
+ decision_type: str = "unknown"
138
+ ) -> Dict[str, Any]:
139
+ """
140
+ Create a new log entry dictionary structure.
141
+ """
142
+ return {
143
+ "timestamp": datetime.now().isoformat(),
144
+ "operation": operation,
145
+ "reason": reason,
146
+ "invocation_mode": invocation_mode,
147
+ "estimated_cost": estimated_cost,
148
+ "confidence": confidence,
149
+ "decision_type": decision_type,
150
+ "success": False,
151
+ "duration": 0.0,
152
+ "actual_cost": 0.0,
153
+ "model": "unknown",
154
+ "error": None
155
+ }
156
+
157
+
158
+ def create_manual_log_entry(operation: str) -> Dict[str, Any]:
159
+ """
160
+ Convenience function to create a manual invocation log entry dict.
161
+ """
162
+ return create_log_entry(
163
+ operation=operation,
164
+ reason="Manual invocation via CLI",
165
+ invocation_mode="manual"
166
+ )
167
+
168
+
169
+ def update_log_entry(
170
+ entry: Dict[str, Any],
171
+ success: bool,
172
+ cost: float,
173
+ model: str,
174
+ duration: float,
175
+ error: Optional[str] = None
176
+ ) -> Dict[str, Any]:
177
+ """
178
+ Update a log entry with execution results.
179
+ """
180
+ entry["success"] = success
181
+ entry["actual_cost"] = cost
182
+ entry["model"] = model
183
+ entry["duration"] = duration
184
+ entry["error"] = error
185
+ return entry
186
+
187
+
188
+ def log_event(
189
+ basename: str,
190
+ language: str,
191
+ event_type: str,
192
+ details: Any,
193
+ invocation_mode: str = "manual"
194
+ ) -> None:
195
+ """
196
+ Log a special event to the sync log.
197
+ """
198
+ entry = {
199
+ "timestamp": datetime.now().isoformat(),
200
+ "type": "event",
201
+ "event_type": event_type,
202
+ "details": details,
203
+ "invocation_mode": invocation_mode
204
+ }
205
+ append_log_entry(basename, language, entry)
206
+
207
+
208
+ def save_fingerprint(
209
+ basename: str,
210
+ language: str,
211
+ operation: str,
212
+ paths: Optional[Dict[str, Path]] = None,
213
+ cost: float = 0.0,
214
+ model: str = "unknown"
215
+ ) -> None:
216
+ """
217
+ Save the current fingerprint/state to the state file.
218
+
219
+ Writes the full Fingerprint dataclass format compatible with read_fingerprint()
220
+ in sync_determine_operation.py. This ensures manual commands (generate, example)
221
+ don't break sync's fingerprint tracking.
222
+ """
223
+ from dataclasses import asdict
224
+ from datetime import timezone
225
+ from .sync_determine_operation import calculate_current_hashes, Fingerprint
226
+ from . import __version__
227
+
228
+ path = get_fingerprint_path(basename, language)
229
+
230
+ # Calculate file hashes from paths (if provided)
231
+ current_hashes = calculate_current_hashes(paths) if paths else {}
232
+
233
+ # Create Fingerprint with same format as _save_fingerprint_atomic
234
+ fingerprint = Fingerprint(
235
+ pdd_version=__version__,
236
+ timestamp=datetime.now(timezone.utc).isoformat(),
237
+ command=operation,
238
+ prompt_hash=current_hashes.get('prompt_hash'),
239
+ code_hash=current_hashes.get('code_hash'),
240
+ example_hash=current_hashes.get('example_hash'),
241
+ test_hash=current_hashes.get('test_hash'),
242
+ test_files=current_hashes.get('test_files'),
243
+ )
244
+
245
+ try:
246
+ with open(path, 'w', encoding='utf-8') as f:
247
+ json.dump(asdict(fingerprint), f, indent=2)
248
+ except Exception as e:
249
+ console = Console()
250
+ console.print(f"[yellow]Warning: Failed to save fingerprint to {path}: {e}[/yellow]")
251
+
252
+
253
+ def save_run_report(basename: str, language: str, report_data: Dict[str, Any]) -> None:
254
+ """
255
+ Save a run report (test results) to the state file.
256
+ """
257
+ path = get_run_report_path(basename, language)
258
+ try:
259
+ with open(path, 'w', encoding='utf-8') as f:
260
+ json.dump(report_data, f, indent=2)
261
+ except Exception as e:
262
+ console = Console()
263
+ console.print(f"[yellow]Warning: Failed to save run report to {path}: {e}[/yellow]")
264
+
265
+
266
+ def clear_run_report(basename: str, language: str) -> None:
267
+ """
268
+ Remove an existing run report if it exists.
269
+ """
270
+ path = get_run_report_path(basename, language)
271
+ if path.exists():
272
+ try:
273
+ os.remove(path)
274
+ except Exception:
275
+ pass
276
+
277
+
278
+ def log_operation(
279
+ operation: str,
280
+ updates_fingerprint: bool = False,
281
+ updates_run_report: bool = False,
282
+ clears_run_report: bool = False
283
+ ) -> Callable:
284
+ """
285
+ Decorator for Click commands to automatically log operations and manage state.
286
+ """
287
+ def decorator(func: Callable) -> Callable:
288
+ @functools.wraps(func)
289
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
290
+ # Try to get prompt_file from named kwarg first
291
+ prompt_file = kwargs.get('prompt_file')
292
+
293
+ # If not found, check if there's an 'args' tuple (for commands using nargs=-1)
294
+ # and the first element looks like a prompt file path
295
+ if not prompt_file:
296
+ cli_args = kwargs.get('args')
297
+ if cli_args and len(cli_args) > 0:
298
+ first_arg = str(cli_args[0])
299
+ # Check if it looks like a prompt file (ends with .prompt)
300
+ if first_arg.endswith('.prompt'):
301
+ prompt_file = first_arg
302
+
303
+ basename, language = (None, None)
304
+ if prompt_file:
305
+ basename, language = infer_module_identity(prompt_file)
306
+
307
+ if basename and language and clears_run_report:
308
+ clear_run_report(basename, language)
309
+
310
+ entry = create_manual_log_entry(operation=operation)
311
+ start_time = time.time()
312
+ success = False
313
+ result = None
314
+ error_msg = None
315
+
316
+ try:
317
+ result = func(*args, **kwargs)
318
+ success = True
319
+ return result
320
+ except Exception as e:
321
+ success = False
322
+ error_msg = str(e)
323
+ raise
324
+ finally:
325
+ duration = time.time() - start_time
326
+ cost = 0.0
327
+ model = "unknown"
328
+ if success and result:
329
+ if isinstance(result, tuple) and len(result) >= 3:
330
+ if isinstance(result[1], (int, float)): cost = float(result[1])
331
+ if isinstance(result[2], str): model = str(result[2])
332
+
333
+ update_log_entry(entry, success=success, cost=cost, model=model, duration=duration, error=error_msg)
334
+ if basename and language:
335
+ append_log_entry(basename, language, entry)
336
+ if success:
337
+ if updates_fingerprint:
338
+ save_fingerprint(basename, language, operation=operation, cost=cost, model=model)
339
+ if updates_run_report and isinstance(result, dict):
340
+ save_run_report(basename, language, result)
341
+ return wrapper
342
+ return decorator
pdd/path_resolution.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Literal, Optional
7
+
8
+ IncludeProfile = Literal["cwd_then_package_then_repo"]
9
+ PromptProfile = Literal["pdd_path_then_repo_then_cwd"]
10
+ DataProfile = Literal["pdd_path_only"]
11
+ ProjectRootProfile = Literal["pdd_path_then_marker_then_cwd"]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class PathResolver:
16
+ cwd: Path
17
+ pdd_path_env: Optional[Path]
18
+ package_root: Path
19
+ repo_root: Optional[Path]
20
+
21
+ def resolve_include(self, rel: str, profile: IncludeProfile = "cwd_then_package_then_repo") -> Path:
22
+ if profile != "cwd_then_package_then_repo":
23
+ raise ValueError(f"Unsupported include profile: {profile}")
24
+
25
+ cwd_path = self.cwd / rel
26
+ if cwd_path.exists():
27
+ return cwd_path
28
+
29
+ pkg_path = self.package_root / rel
30
+ if pkg_path.exists():
31
+ return pkg_path
32
+
33
+ if self.repo_root is not None:
34
+ repo_path = self.repo_root / rel
35
+ if repo_path.exists():
36
+ return repo_path
37
+
38
+ return cwd_path
39
+
40
+ def resolve_prompt_template(
41
+ self,
42
+ name: str,
43
+ profile: PromptProfile = "pdd_path_then_repo_then_cwd",
44
+ ) -> Optional[Path]:
45
+ if profile != "pdd_path_then_repo_then_cwd":
46
+ raise ValueError(f"Unsupported prompt profile: {profile}")
47
+
48
+ roots = []
49
+ if self.pdd_path_env is not None:
50
+ roots.append(self.pdd_path_env)
51
+ if self.repo_root is not None:
52
+ roots.append(self.repo_root)
53
+ roots.append(self.cwd)
54
+
55
+ prompt_file = f"{name}.prompt"
56
+ for root in roots:
57
+ candidate = root / "prompts" / prompt_file
58
+ if candidate.exists():
59
+ return candidate
60
+ candidate = root / "pdd" / "prompts" / prompt_file
61
+ if candidate.exists():
62
+ return candidate
63
+
64
+ return None
65
+
66
+ def resolve_data_file(self, rel: str, profile: DataProfile = "pdd_path_only") -> Path:
67
+ if profile != "pdd_path_only":
68
+ raise ValueError(f"Unsupported data profile: {profile}")
69
+ if self.pdd_path_env is None:
70
+ raise ValueError("PDD_PATH environment variable is not set.")
71
+ return self.pdd_path_env / rel
72
+
73
+ def resolve_project_root(
74
+ self,
75
+ profile: ProjectRootProfile = "pdd_path_then_marker_then_cwd",
76
+ max_levels: int = 5,
77
+ ) -> Path:
78
+ if profile != "pdd_path_then_marker_then_cwd":
79
+ raise ValueError(f"Unsupported project root profile: {profile}")
80
+
81
+ if (
82
+ self.pdd_path_env is not None
83
+ and self.pdd_path_env.is_dir()
84
+ and not _is_within(self.pdd_path_env, self.package_root)
85
+ ):
86
+ return self.pdd_path_env
87
+
88
+ current = self.cwd
89
+ for _ in range(max_levels):
90
+ if _has_project_marker(current):
91
+ return current
92
+ parent = current.parent
93
+ if parent == current:
94
+ break
95
+ current = parent
96
+
97
+ return self.cwd
98
+
99
+
100
+ def get_default_resolver() -> PathResolver:
101
+ cwd = Path.cwd().resolve()
102
+
103
+ pdd_path_env = None
104
+ env_value = os.getenv("PDD_PATH")
105
+ if env_value:
106
+ pdd_path_env = Path(env_value).expanduser().resolve()
107
+
108
+ package_root = Path(__file__).resolve().parent
109
+ repo_root = package_root.parent
110
+
111
+ return PathResolver(
112
+ cwd=cwd,
113
+ pdd_path_env=pdd_path_env,
114
+ package_root=package_root,
115
+ repo_root=repo_root,
116
+ )
117
+
118
+
119
+ def _has_project_marker(path: Path) -> bool:
120
+ return (
121
+ (path / ".git").exists()
122
+ or (path / "pyproject.toml").exists()
123
+ or (path / "data").is_dir()
124
+ or (path / ".env").exists()
125
+ )
126
+
127
+
128
+ def _is_within(path: Path, parent: Path) -> bool:
129
+ try:
130
+ resolved_path = path.resolve()
131
+ resolved_parent = parent.resolve()
132
+ except Exception:
133
+ return False
134
+
135
+ if resolved_path == resolved_parent:
136
+ return True
137
+ parent_str = str(resolved_parent)
138
+ if not parent_str.endswith(os.sep):
139
+ parent_str = parent_str + os.sep
140
+ return str(resolved_path).startswith(parent_str)