certora-prover-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1333 @@
1
+ # SPDX-FileCopyrightText: 2026 Certora Ltd.
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+
5
+ """
6
+ Main ProverOutputAPI class that provides the high-level interface.
7
+ """
8
+
9
+ import io
10
+ import json
11
+ import logging
12
+ import os
13
+ import tarfile
14
+ import threading
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from datetime import datetime, timedelta, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ import requests
21
+
22
+ from ..auth import ProverAuth
23
+ from ..aws_auth import AWSAuth
24
+ from ..breadcrumb import BreadcrumbParser
25
+ from ..exceptions import ProverAPIError
26
+ from ..job_report import JobAnalyzer, JobReport
27
+ from ..models import (
28
+ BreadcrumbInfo,
29
+ CallResolutionInfo,
30
+ CalltraceInfo,
31
+ CheckResult,
32
+ JobInfo,
33
+ JobStatus,
34
+ ParsedAlert,
35
+ SourceFileNode,
36
+ TreeViewData,
37
+ convert_job_status,
38
+ )
39
+ from ..parsers import OutputParser
40
+ from .aws_data_fetcher import AWSDataFetcher
41
+ from .cache import APICache
42
+ from .data_fetcher import DataFetcher, default_api_base_url
43
+ from .local_data_fetcher import LocalDataFetcher
44
+ from .tree_parser import TreeParser
45
+ from .url_utils import extract_job_id, extract_job_identifier
46
+
47
+
48
+ # Per-job locks for thread-safe fetch operations
49
+ _fetch_locks: dict[str, threading.Lock] = {}
50
+ _fetch_locks_lock = threading.Lock()
51
+
52
+
53
+ def _get_fetch_lock(job_identifier: str) -> threading.Lock:
54
+ """Get or create a per-job lock for thread-safe fetching."""
55
+ with _fetch_locks_lock:
56
+ if job_identifier not in _fetch_locks:
57
+ _fetch_locks[job_identifier] = threading.Lock()
58
+ return _fetch_locks[job_identifier]
59
+
60
+
61
+ def _flatten_source_file_tree(nodes: List[SourceFileNode], prefix: str = "") -> List[tuple]:
62
+ """Flatten a SourceFileNode tree into (relative_path, api_path) tuples."""
63
+ result: List[tuple] = []
64
+ for node in nodes:
65
+ path = f"{prefix}{node.name}" if prefix else node.name
66
+ if node.selectable and not node.name.startswith("."):
67
+ result.append((path, node.output))
68
+ if node.children:
69
+ result.extend(_flatten_source_file_tree(node.children, f"{path}/"))
70
+ return result
71
+
72
+
73
+ def _collect_treeview_output_files(tree_status: Dict[str, Any]) -> List[str]:
74
+ """Recursively collect all output filenames from 'output' keys in a treeViewStatus dict."""
75
+ output_files: List[str] = []
76
+
77
+ def traverse(obj: Any) -> None:
78
+ if isinstance(obj, dict):
79
+ for key, value in obj.items():
80
+ if key == "output" and isinstance(value, list):
81
+ output_files.extend(value)
82
+ else:
83
+ traverse(value)
84
+ elif isinstance(obj, list):
85
+ for item in obj:
86
+ traverse(item)
87
+
88
+ traverse(tree_status)
89
+ return list(set(output_files))
90
+
91
+
92
+ class ProverOutputAPI:
93
+ """
94
+ Main API class for fetching and parsing Certora Prover outputs.
95
+
96
+ Authentication is handled automatically via certora_login when accessing remote APIs.
97
+ No CERTORAKEY required.
98
+
99
+ Usage:
100
+ # Remote API (default) - uses certora_login for authentication
101
+ api = ProverOutputAPI()
102
+ violations = api.get_violated_rules("https://prover.certora.com/output/12345/...")
103
+ violations = api.get_violated_rules("12345")
104
+
105
+ # Local emv-* folders (no authentication needed)
106
+ api = ProverOutputAPI(use_local=True)
107
+ violations = api.get_violated_rules("/path/to/emv-1-certora-19-Aug--13-09")
108
+ violations = api.get_violated_rules("./emv-1-certora-19-Aug--13-09")
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ certora_key: Optional[str] = None,
114
+ enable_cache: bool = True,
115
+ use_local: bool = False,
116
+ api_base_url: Optional[str] = None,
117
+ ):
118
+ """
119
+ Initialize the ProverOutputAPI.
120
+
121
+ Args:
122
+ certora_key: Deprecated - authentication is handled automatically via certora_login
123
+ enable_cache: Whether to enable persistent caching of API responses (default: True)
124
+ use_local: Whether to use local emv-* folders instead of remote API (default: False)
125
+ api_base_url: API base URL. When None (the default), resolve from the
126
+ Certora login env via :func:`resolve_login_env` —
127
+ dev → data-api-dev.certora.com, stg → data-api-stg.certora.com,
128
+ prod → data-api.certora.com. Pass an explicit URL (e.g.
129
+ ``DataFetcher.STAGING_API_BASE_URL``) to override.
130
+ """
131
+ self.logger = logging.getLogger(__name__)
132
+ self.parser = OutputParser()
133
+ self.breadcrumb_parser = BreadcrumbParser()
134
+ self.tree_parser = TreeParser()
135
+ self.use_local = use_local
136
+ # Resolve at instance-construction time so the same process can serve
137
+ # multiple envs by re-instantiating; bound-default would freeze on import.
138
+ self.api_base_url = api_base_url if api_base_url is not None else default_api_base_url()
139
+
140
+ # Initialize cache
141
+ self.cache = APICache() if enable_cache else None
142
+
143
+ # Initialize appropriate data fetcher
144
+ if use_local:
145
+ self.data_fetcher = LocalDataFetcher()
146
+ elif os.getenv("CI") and self._is_aws_available():
147
+ # In CI with AWS credentials, use AWS Lambda authentication
148
+ self.logger.info("Using AWS Lambda authentication in CI")
149
+ aws_auth = AWSAuth()
150
+ aws_session = aws_auth.get_session()
151
+ self.data_fetcher = AWSDataFetcher(aws_session)
152
+ # No session needed for AWS auth
153
+ self.session = None
154
+ else:
155
+ # Regular cookie-based authentication
156
+ self.auth = ProverAuth()
157
+ self._setup_session()
158
+
159
+ def _setup_session(self, force_relogin: bool = False):
160
+ """Setup session with authentication cookies."""
161
+ self.session = requests.Session()
162
+ # Set up authentication
163
+ self.session.cookies = self.auth.get_auth_cookies(force_relogin=force_relogin)
164
+ self.data_fetcher = DataFetcher(self.session, api_instance=self, api_base_url=self.api_base_url)
165
+
166
+ def _is_aws_available(self) -> bool:
167
+ """Check if AWS authentication is available."""
168
+ try:
169
+ aws_auth = AWSAuth()
170
+ return aws_auth.is_available()
171
+ except Exception:
172
+ return False
173
+
174
+ def _extract_job_identifier(self, job_input: str) -> str:
175
+ """
176
+ Extract job identifier based on the fetcher type.
177
+
178
+ Args:
179
+ job_input: Job URL, job ID, or local emv-* path
180
+
181
+ Returns:
182
+ Job identifier (job_id for remote, emv_path for local)
183
+ """
184
+ if self.use_local:
185
+ # For local, validate it's an emv-* path and return the path
186
+ job_identifier, input_type = extract_job_identifier(job_input)
187
+ if input_type != "local":
188
+ raise ProverAPIError(
189
+ f"ProverOutputAPI configured for local use but got remote input: {job_input}"
190
+ )
191
+ return job_identifier
192
+ else:
193
+ # For remote, extract job ID from URL or use direct job ID
194
+ return extract_job_id(job_input)
195
+
196
+
197
+ def _set_default_contract(self, job_identifier: str) -> None:
198
+ """Set the tree parser's default contract from the job's API response."""
199
+ try:
200
+ raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
201
+ self.tree_parser.default_contract = raw_output.get("contract")
202
+ except Exception:
203
+ pass # best-effort
204
+
205
+ def get_treeview_status(self, job_input: str) -> Dict[str, Any]:
206
+ """
207
+ Get tree view status data with caching for completed jobs.
208
+
209
+ This is a public API method that retrieves the treeViewStatus.json file
210
+ for a job. The data is cached for completed jobs to improve performance.
211
+
212
+ Args:
213
+ job_input: Job URL, job ID, or local emv-* path
214
+
215
+ Returns:
216
+ Tree view status data as a dictionary
217
+
218
+ Example:
219
+ ```python
220
+ api = ProverOutputAPI()
221
+ tree_status = api.get_treeview_status("12345678")
222
+ print(f"Tree status: {tree_status}")
223
+ ```
224
+ """
225
+ job_identifier = self._extract_job_identifier(job_input)
226
+ return self._get_tree_view_status(job_identifier)
227
+
228
+ def _get_tree_view_status(self, job_identifier: str) -> Dict[str, Any]:
229
+ """
230
+ Get tree view status data with caching for completed jobs.
231
+
232
+ Internal method - use get_treeview_status() for the public API.
233
+ Only caches treeViewStatus.json if the job is not running.
234
+
235
+ Args:
236
+ job_identifier: Job ID or local emv-* path
237
+
238
+ Returns:
239
+ Tree view status data
240
+ """
241
+ # Check cache first (only for remote jobs)
242
+ cache_key = "treeViewStatus.json"
243
+ if self.cache and not self.use_local:
244
+ cached_data = self.cache.get("tree_view_status", job_identifier, cache_key)
245
+ if cached_data:
246
+ self.logger.debug(f"Using cached treeViewStatus for job {job_identifier}")
247
+ return cached_data
248
+
249
+ # Fetch the data
250
+ tree_data = self.data_fetcher.fetch_tree_view_data(
251
+ job_identifier, path="treeViewStatus.json"
252
+ )
253
+
254
+ # Cache if job is not running (only for remote jobs)
255
+ if self.cache and not self.use_local:
256
+ # Check if job is running by looking for finish_time in the tree data
257
+ # or by calling is_job_running
258
+ try:
259
+ # Get job info to check if it's running
260
+ raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
261
+ is_running = raw_output.get("finish_time") is None
262
+
263
+ if not is_running:
264
+ self.logger.debug(f"Caching treeViewStatus for completed job {job_identifier}")
265
+ self.cache.set("tree_view_status", tree_data, job_identifier, cache_key)
266
+ else:
267
+ self.logger.debug(
268
+ f"Not caching treeViewStatus for running job {job_identifier}"
269
+ )
270
+ except Exception as e:
271
+ # If we can't determine job status, don't cache
272
+ self.logger.debug(f"Could not determine job status for caching: {e}")
273
+
274
+ return tree_data
275
+
276
+ def fetch_output(self, job_input: str) -> Dict[str, Any]:
277
+ """
278
+ Fetch and parse prover output from either a job URL, job ID, or local emv-* path.
279
+
280
+ This method is kept for backward compatibility.
281
+
282
+ Args:
283
+ job_input: Job URL, job ID, or local emv-* path
284
+
285
+ Returns:
286
+ Parsed output data as a dictionary
287
+
288
+ Raises:
289
+ ProverAPIError: If API call fails
290
+ """
291
+ job_identifier = self._extract_job_identifier(job_input)
292
+ self.logger.info(f"Fetching output for job: {job_identifier}")
293
+
294
+ try:
295
+ raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
296
+ parsed_output = self.parser.parse(raw_output)
297
+ return parsed_output
298
+
299
+ except requests.exceptions.RequestException as e:
300
+ raise ProverAPIError(f"Failed to fetch output for job {job_identifier}: {e}")
301
+
302
+ def get_violated_rules(self, job_input: str) -> List[CheckResult]:
303
+ """
304
+ Get all violated rules for a job with assert messages.
305
+
306
+ Args:
307
+ job_input: Job URL, job ID, or local emv-* path
308
+
309
+ Returns:
310
+ List of ViolationInfo objects for violated rules
311
+ """
312
+ job_identifier = self._extract_job_identifier(job_input)
313
+
314
+ try:
315
+ self._set_default_contract(job_identifier)
316
+ tree_data = self._get_tree_view_status(job_identifier)
317
+ violations = self.tree_parser.parse_violations(tree_data)
318
+ return violations
319
+
320
+ except Exception as e:
321
+ raise ProverAPIError(f"Failed to get violated rules for job {job_identifier}: {e}")
322
+
323
+ def get_all_checks(self, job_input: str, include_rule_not_vacuous: bool = False) -> List[CheckResult]:
324
+ """
325
+ Get all checks (both successful and failed) for a job.
326
+
327
+ Args:
328
+ job_input: Job URL, job ID, or local emv-* path
329
+ include_rule_not_vacuous: If True, include rule_not_vacuous nodes and their descendants.
330
+
331
+ Returns:
332
+ List of ViolationInfo objects for all checks
333
+ """
334
+ job_identifier = self._extract_job_identifier(job_input)
335
+
336
+ try:
337
+ self._set_default_contract(job_identifier)
338
+ tree_data = self._get_tree_view_status(job_identifier)
339
+ all_checks = self.tree_parser.parse_all_checks(tree_data, include_rule_not_vacuous=include_rule_not_vacuous)
340
+ return all_checks
341
+
342
+ except Exception as e:
343
+ raise ProverAPIError(f"Failed to get all checks for job {job_identifier}: {e}")
344
+
345
+ def get_leaf_checks(self, job_input: str) -> List[CheckResult]:
346
+ """
347
+ Get only leaf checks (actual assertions without intermediate tree nodes) for a job.
348
+
349
+ Args:
350
+ job_input: Job URL, job ID, or local emv-* path
351
+
352
+ Returns:
353
+ List of CheckResult objects for leaf checks only
354
+ """
355
+ job_identifier = self._extract_job_identifier(job_input)
356
+
357
+ try:
358
+ self._set_default_contract(job_identifier)
359
+ tree_data = self._get_tree_view_status(job_identifier)
360
+ leaf_checks = self.tree_parser.parse_leaf_checks(tree_data)
361
+ return leaf_checks
362
+
363
+ except Exception as e:
364
+ raise ProverAPIError(f"Failed to get leaf checks for job {job_identifier}: {e}")
365
+
366
+ def fetch_treeview_output_by_filename(self, job_input: str, filename: str) -> Dict[str, Any]:
367
+ """
368
+ Fetch a specific tree view output file by filename.
369
+
370
+ This is a public API method that retrieves individual output files from
371
+ the tree-view data, such as rule outputs, calltraces, or other JSON files.
372
+ The data is cached for completed jobs to improve performance.
373
+
374
+ Args:
375
+ job_input: Job URL, job ID, or local emv-* path
376
+ filename: Filename within tree-view (e.g., 'rule_output_58.json', 'dap_calltrace_56-certora-dap.json')
377
+
378
+ Returns:
379
+ Tree view output data as a dictionary
380
+
381
+ Example:
382
+ ```python
383
+ api = ProverOutputAPI()
384
+
385
+ # Fetch a rule output file
386
+ rule_output = api.fetch_treeview_output_by_filename("12345678", "rule_output_58.json")
387
+ print(f"Rule data: {rule_output}")
388
+
389
+ # Fetch a calltrace/DAP file
390
+ dap_data = api.fetch_treeview_output_by_filename("12345678", "dap_calltrace_56-certora-dap.json")
391
+ print(f"DAP data: {dap_data}")
392
+ ```
393
+ """
394
+ job_identifier = self._extract_job_identifier(job_input)
395
+ return self._fetch_tree_view_data_with_cache(job_identifier, filename)
396
+
397
+ def _fetch_tree_view_data_with_cache(self, job_identifier: str, path: str) -> Dict[str, Any]:
398
+ """
399
+ Fetch tree view data with caching support.
400
+
401
+ Internal method - use fetch_treeview_output_by_filename() for the public API.
402
+ Handles caching logic for tree view data fetches.
403
+ Checks bulk cache first, then individual cache, then API.
404
+
405
+ Args:
406
+ job_identifier: Job ID or local emv-* path
407
+ path: Path within tree-view (e.g., 'rule_output_58.json')
408
+
409
+ Returns:
410
+ Tree view data
411
+ """
412
+ # Check bulk cache first (disk-based)
413
+ if not self.use_local:
414
+ bulk_cache_dir = self._get_bulk_cache_dir(job_identifier)
415
+ bulk_file_path = bulk_cache_dir / path
416
+ if bulk_file_path.exists():
417
+ try:
418
+ with open(bulk_file_path, 'r') as f:
419
+ data = json.load(f)
420
+ self.logger.debug(f"Using bulk cache for {path} in job {job_identifier}")
421
+ return data
422
+ except Exception as e:
423
+ self.logger.warning(f"Failed to read bulk cache file {bulk_file_path}: {e}")
424
+
425
+ # Check individual hash-based cache
426
+ cache_key = f"tree_view:{path}"
427
+
428
+ if self.cache and not self.use_local:
429
+ cached_data = self.cache.get("tree_view_data", job_identifier, cache_key)
430
+ if cached_data:
431
+ self.logger.debug(f"Using cached tree view data for {path} in job {job_identifier}")
432
+ return cached_data
433
+
434
+ # Fetch from API if not cached
435
+ data = self.data_fetcher.fetch_tree_view_data(job_identifier, path=path)
436
+
437
+ # Cache for completed jobs (rule outputs and calltraces are immutable once job completes)
438
+ if self.cache and not self.use_local and data:
439
+ try:
440
+ # Check if job is completed before caching
441
+ raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
442
+ is_running = raw_output.get("finish_time") is None
443
+
444
+ if not is_running:
445
+ self.logger.debug(
446
+ f"Caching tree view data for {path} in completed job {job_identifier}"
447
+ )
448
+ self.cache.set("tree_view_data", data, job_identifier, cache_key)
449
+ except Exception:
450
+ # If we can't determine job status, cache anyway for rule outputs
451
+ # (they're immutable and won't change)
452
+ if path.startswith("rule_output") or path.endswith(".json"):
453
+ self.logger.debug(f"Caching tree view data for {path}")
454
+ self.cache.set("tree_view_data", data, job_identifier, cache_key)
455
+
456
+ return data
457
+
458
+ def get_calltrace(self, job_input: str, output_file: str) -> CalltraceInfo:
459
+ """
460
+ Get calltrace data for a specific rule using its output file path.
461
+
462
+ Args:
463
+ job_input: Job URL, job ID, or local emv-* path
464
+ output_file: Output file path from the violation's output field
465
+
466
+ Returns:
467
+ CalltraceInfo object with wrapped calltrace data
468
+ """
469
+ job_identifier = self._extract_job_identifier(job_input)
470
+
471
+ try:
472
+ calltrace_data = self._fetch_tree_view_data_with_cache(job_identifier, output_file)
473
+ return CalltraceInfo(
474
+ job_id=job_identifier,
475
+ output_file=output_file,
476
+ trace_data=calltrace_data,
477
+ )
478
+
479
+ except Exception as e:
480
+ raise ProverAPIError(
481
+ f"Failed to get calltrace for output file {output_file} in job {job_identifier}: {e}"
482
+ )
483
+
484
+ def get_calltrace_for_violation(self, job_input: str, violation: CheckResult) -> CalltraceInfo:
485
+ """
486
+ Get calltrace data for a specific violation using its output files.
487
+
488
+ Args:
489
+ job_input: Job URL, job ID, or local emv-* path
490
+ violation: ViolationInfo object
491
+
492
+ Returns:
493
+ CalltraceInfo object, or raises error if no output files
494
+ """
495
+ if not violation.output_files:
496
+ raise ProverAPIError("No output files available for this violation")
497
+
498
+ # Use the first output file (there might be multiple)
499
+ output_file = violation.output_files[0]
500
+ calltrace = self.get_calltrace(job_input, output_file)
501
+ calltrace.rule_name = violation.rule_name
502
+ return calltrace
503
+
504
+ def get_breadcrumbs(self, job_input: str, dap_file: str) -> BreadcrumbInfo:
505
+ """
506
+ Get breadcrumb trace summary from a DAP file with caching.
507
+
508
+ Args:
509
+ job_input: Job URL, job ID, or local emv-* path
510
+ dap_file: DAP file path (e.g., 'dap_calltrace_56-certora-dap.json')
511
+
512
+ Returns:
513
+ BreadcrumbInfo object with execution trace
514
+ """
515
+ job_identifier = self._extract_job_identifier(job_input)
516
+
517
+ # Check cache first
518
+ if self.cache:
519
+ cached_data = self.cache.get("get_breadcrumbs", job_identifier, dap_file)
520
+ if cached_data:
521
+ self.logger.debug(
522
+ f"Using cached breadcrumbs for {dap_file} in job {job_identifier}"
523
+ )
524
+ return BreadcrumbInfo.from_dict(cached_data, dap_file=dap_file)
525
+
526
+ try:
527
+ dap_data = self.data_fetcher.fetch_tree_view_data(job_identifier, path=dap_file)
528
+ breadcrumbs_data = self.breadcrumb_parser.parse_dap_file(dap_data)
529
+
530
+ # Cache the result
531
+ if self.cache:
532
+ self.cache.set("get_breadcrumbs", breadcrumbs_data, job_identifier, dap_file)
533
+
534
+ return BreadcrumbInfo.from_dict(breadcrumbs_data, dap_file=dap_file)
535
+
536
+ except Exception as e:
537
+ raise ProverAPIError(
538
+ f"Failed to get breadcrumbs from DAP file {dap_file} in job {job_identifier}: {e}"
539
+ )
540
+
541
+ def get_breadcrumbs_for_violation(
542
+ self, job_input: str, violation: CheckResult
543
+ ) -> BreadcrumbInfo:
544
+ """
545
+ Get breadcrumb trace summary for a specific violation using its debug trace file.
546
+
547
+ Args:
548
+ job_input: Job URL, job ID, or local emv-* path
549
+ violation: ViolationInfo object
550
+
551
+ Returns:
552
+ BreadcrumbInfo object with execution trace
553
+ """
554
+ if not violation.debug_trace_file:
555
+ raise ProverAPIError("No debug trace file available for this violation")
556
+
557
+ return self.get_breadcrumbs(job_input, violation.debug_trace_file)
558
+
559
+ def get_call_resolutions(self, job_input: str) -> List[CallResolutionInfo]:
560
+ """
561
+ Get call resolution information from the globalCallResolution field.
562
+
563
+ Args:
564
+ job_input: Job URL, job ID, or local emv-* path
565
+
566
+ Returns:
567
+ List of CallResolutionInfo objects
568
+ """
569
+ job_identifier = self._extract_job_identifier(job_input)
570
+
571
+ try:
572
+ tree_data = self._get_tree_view_status(job_identifier)
573
+ call_resolutions = self.tree_parser.parse_call_resolutions(tree_data)
574
+ return call_resolutions
575
+
576
+ except Exception as e:
577
+ raise ProverAPIError(f"Failed to get unresolved calls for job {job_identifier}: {e}")
578
+
579
+ def get_tree_view_data(self, job_input: str) -> TreeViewData:
580
+ """
581
+ Get wrapped tree-view data for a job.
582
+
583
+ Args:
584
+ job_input: Job URL, job ID, or local emv-* path
585
+
586
+ Returns:
587
+ TreeViewData object
588
+ """
589
+ job_identifier = self._extract_job_identifier(job_input)
590
+ raw_data = self._get_tree_view_status(job_identifier)
591
+ return TreeViewData.from_dict(raw_data, job_identifier)
592
+
593
+ def get_job_status(self, job_input: str) -> JobStatus:
594
+ """
595
+ Get the status of a job without fetching full output.
596
+
597
+ Args:
598
+ job_input: Job URL, job ID, or local emv-* path
599
+
600
+ Returns:
601
+ JobStatus enum value
602
+ """
603
+ job_identifier = self._extract_job_identifier(job_input)
604
+
605
+ try:
606
+ raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
607
+ status_str = raw_output.get("job_status", "unknown")
608
+ return convert_job_status(status_str)
609
+ except Exception as e:
610
+ raise ProverAPIError(f"Failed to get status for job {job_identifier}: {e}")
611
+
612
+ def get_job_info(self, job_input: str) -> JobInfo:
613
+ """
614
+ Get comprehensive job information.
615
+
616
+ Args:
617
+ job_input: Job URL, job ID, or local emv-* path
618
+
619
+ Returns:
620
+ JobInfo object with job details
621
+ """
622
+ job_identifier = self._extract_job_identifier(job_input)
623
+
624
+ try:
625
+ raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
626
+ status_str = raw_output.get("job_status", "unknown")
627
+ return JobInfo(
628
+ job_id=job_identifier,
629
+ status=convert_job_status(status_str),
630
+ start_time=raw_output.get("start_time"),
631
+ finish_time=raw_output.get("finish_time"),
632
+ user_id=raw_output.get("user_id"),
633
+ project=raw_output.get("project"),
634
+ contract=raw_output.get("contract"),
635
+ raw_data=raw_output,
636
+ )
637
+ except Exception as e:
638
+ raise ProverAPIError(f"Failed to get job info for {job_identifier}: {e}")
639
+
640
+ def is_job_running(self, job_input: str) -> bool:
641
+ """
642
+ Check if a job is still running by checking if finish_time is null.
643
+
644
+ Args:
645
+ job_input: Job URL, job ID, or local emv-* path
646
+
647
+ Returns:
648
+ True if job is still running, False if completed
649
+ """
650
+ job_info = self.get_job_info(job_input)
651
+ return job_info.is_running
652
+
653
+ def list_recent_jobs(
654
+ self,
655
+ days_back: int = 7,
656
+ limit: int = 100,
657
+ all_users: bool = True,
658
+ ) -> List[JobInfo]:
659
+ """
660
+ List recent jobs from the data API.
661
+
662
+ Args:
663
+ days_back: How many days back to look (default: 7).
664
+ limit: Max jobs to return (default: 100, the API caps page size at 100).
665
+ all_users: Include all users' jobs (default: True). False = my jobs only.
666
+
667
+ Returns:
668
+ List of JobInfo objects.
669
+
670
+ Raises:
671
+ ProverAPIError: If configured for local use or the API call fails.
672
+ """
673
+ if self.use_local:
674
+ raise ProverAPIError(
675
+ "list_recent_jobs is not supported when using local prover outputs"
676
+ )
677
+
678
+ created_after = (
679
+ datetime.now(timezone.utc) - timedelta(days=days_back)
680
+ ).isoformat()
681
+ raw_jobs = self.data_fetcher.list_recent_jobs(
682
+ created_after=created_after,
683
+ limit=limit,
684
+ all_users=all_users,
685
+ )
686
+ return [JobInfo.from_dict(job_data) for job_data in raw_jobs]
687
+
688
+ def cancel_jobs(self, job_inputs: List[str]) -> Dict[str, Any]:
689
+ """
690
+ Cancel multiple jobs by their URLs or IDs.
691
+
692
+ Args:
693
+ job_inputs: List of job URLs or job IDs to cancel
694
+
695
+ Returns:
696
+ API response data with cancellation results
697
+
698
+ Raises:
699
+ ProverAPIError: If cancellation fails or configured for local use
700
+ """
701
+ if self.use_local:
702
+ raise ProverAPIError("cancel_jobs is not supported when using local prover outputs")
703
+
704
+ # Extract job IDs from URLs or use IDs directly
705
+ job_ids = []
706
+ for job_input in job_inputs:
707
+ try:
708
+ job_id = extract_job_id(job_input)
709
+ job_ids.append(job_id)
710
+ except Exception as e:
711
+ self.logger.warning(f"Failed to extract job ID from {job_input}: {e}")
712
+ # Skip invalid inputs rather than failing completely
713
+ continue
714
+
715
+ if not job_ids:
716
+ raise ProverAPIError("No valid job IDs found to cancel")
717
+
718
+ self.logger.info(f"Cancelling {len(job_ids)} jobs: {job_ids}")
719
+ return self.data_fetcher.cancel_jobs(job_ids)
720
+
721
+ def cancel_job(self, job_input: str) -> Dict[str, Any]:
722
+ """
723
+ Cancel a single job by its URL or ID.
724
+
725
+ Args:
726
+ job_input: Job URL or job ID to cancel
727
+
728
+ Returns:
729
+ API response data with cancellation results
730
+
731
+ Raises:
732
+ ProverAPIError: If cancellation fails or configured for local use
733
+ """
734
+ return self.cancel_jobs([job_input])
735
+
736
+ def get_statsdata(self, job_input: str) -> Dict[str, Any]:
737
+ """
738
+ Get statsdata.json for a job.
739
+
740
+ Args:
741
+ job_input: Job URL, job ID, or local emv-* path
742
+
743
+ Returns:
744
+ Stats data from statsdata.json
745
+
746
+ Raises:
747
+ ProverAPIError: If fetching fails
748
+ """
749
+ job_identifier = self._extract_job_identifier(job_input)
750
+
751
+ try:
752
+ return self.data_fetcher.fetch_statsdata(job_identifier)
753
+ except Exception as e:
754
+ raise ProverAPIError(f"Failed to get statsdata for job {job_identifier}: {e}")
755
+
756
+ def get_console_logs(self, job_input: str) -> str:
757
+ """
758
+ Get console logs for a job. This contains the full console output
759
+ including error messages like filtering errors.
760
+
761
+ Args:
762
+ job_input: Job URL, job ID, or local emv-* path
763
+
764
+ Returns:
765
+ Console logs as a string
766
+
767
+ Raises:
768
+ AuthenticationError: If authentication fails
769
+ JobNotFoundError: If job is not found or logs are not available
770
+ ProverAPIError: If API call fails or configured for local use
771
+ """
772
+ if self.use_local:
773
+ raise ProverAPIError("get_console_logs is not supported when using local prover outputs")
774
+
775
+ job_identifier = self._extract_job_identifier(job_input)
776
+
777
+ try:
778
+ return self.data_fetcher.fetch_console_logs(job_identifier)
779
+ except Exception as e:
780
+ raise ProverAPIError(f"Failed to get console logs for job {job_identifier}: {e}")
781
+
782
+ def get_alert_report(self, job_input: str) -> List[Dict[str, Any]]:
783
+ """
784
+ Get raw alert report for a job. This contains prover alerts and messages.
785
+
786
+ For typed alert objects, use get_alerts() instead.
787
+
788
+ Args:
789
+ job_input: Job URL, job ID, or local emv-* path
790
+
791
+ Returns:
792
+ Alert report data as a list of raw alert dictionaries
793
+
794
+ Raises:
795
+ JobNotFoundError: If job is not found or alert report is not available
796
+ ProverAPIError: If API call fails
797
+ """
798
+ job_identifier = self._extract_job_identifier(job_input)
799
+
800
+ try:
801
+ return self.data_fetcher.fetch_alert_report(job_identifier)
802
+ except Exception as e:
803
+ raise ProverAPIError(f"Failed to get alert report for job {job_identifier}: {e}")
804
+
805
+ def get_alerts(self, job_input: str) -> List[ParsedAlert]:
806
+ """
807
+ Get parsed alerts for a job with typed alert information.
808
+
809
+ Args:
810
+ job_input: Job URL, job ID, or local emv-* path
811
+
812
+ Returns:
813
+ List of ParsedAlert objects with typed alert information
814
+
815
+ Raises:
816
+ JobNotFoundError: If job is not found or alert report is not available
817
+ ProverAPIError: If API call fails
818
+ """
819
+ raw_alerts = self.get_alert_report(job_input)
820
+ return [ParsedAlert.from_dict(alert) for alert in raw_alerts]
821
+
822
+ def get_job_report(self, job_input: str) -> JobReport:
823
+ """Analyze a job and return a structured report of its results.
824
+
825
+ Covers call resolution, rule results, and alerts.
826
+ """
827
+ return JobAnalyzer(self).analyze(job_input)
828
+
829
+ def who_am_i(self) -> Dict[str, Any]:
830
+ """
831
+ Get information about the currently authenticated user.
832
+
833
+ Returns:
834
+ User information including email, user ID, and other details
835
+
836
+ Raises:
837
+ AuthenticationError: If authentication fails
838
+ ProverAPIError: If API call fails or configured for local use
839
+ """
840
+ if self.use_local:
841
+ raise ProverAPIError("who_am_i is not supported when using local prover outputs")
842
+
843
+ try:
844
+ return self.data_fetcher.who_am_i()
845
+ except Exception as e:
846
+ raise ProverAPIError(f"Failed to get user information: {e}")
847
+
848
+ def parse_tree_view_path(self, tree_view_path: str) -> List[str]:
849
+ """
850
+ Parse a treeViewPath field to extract the rule hierarchy.
851
+
852
+ The treeViewPath is split by '-' to create a list representing
853
+ the hierarchical structure of the rule.
854
+
855
+ Args:
856
+ tree_view_path: The treeViewPath string from rule_output files
857
+
858
+ Returns:
859
+ List of hierarchy levels extracted from the path
860
+
861
+ Example:
862
+ Input: "sanity-previewUnwindExercise(bytes32,uint256)-Satisfy_sanity_check_failed_(sanity_spec_11_5)-sanity check failed"
863
+ Output: ["sanity", "previewUnwindExercise(bytes32,uint256)", "Satisfy_sanity_check_failed_(sanity_spec_11_5)", "sanity check failed"]
864
+ """
865
+ if not tree_view_path:
866
+ return []
867
+
868
+ # Split by '-' to get hierarchy levels
869
+ hierarchy = tree_view_path.split("-")
870
+
871
+ # Clean up any extra whitespace
872
+ hierarchy = [level.strip() for level in hierarchy if level.strip()]
873
+
874
+ return hierarchy
875
+
876
+ def get_rule_hierarchy(self, job_input: str, output_file: str) -> List[str]:
877
+ """
878
+ Get the rule hierarchy from a rule output file's treeViewPath.
879
+
880
+ Args:
881
+ job_input: Job URL, job ID, or local emv-* path
882
+ output_file: Output file path (e.g., 'rule_output_58.json')
883
+
884
+ Returns:
885
+ List of hierarchy levels extracted from the treeViewPath
886
+
887
+ Example:
888
+ Returns ["sanity", "previewUnwindExercise(bytes32,uint256)",
889
+ "Satisfy_sanity_check_failed_(sanity_spec_11_5)", "sanity check failed"]
890
+ """
891
+ job_identifier = self._extract_job_identifier(job_input)
892
+
893
+ try:
894
+ # Use the cached fetch method
895
+ rule_data = self._fetch_tree_view_data_with_cache(job_identifier, output_file)
896
+
897
+ # Extract and parse the treeViewPath
898
+ if isinstance(rule_data, dict) and "treeViewPath" in rule_data:
899
+ return self.parse_tree_view_path(rule_data["treeViewPath"])
900
+
901
+ return []
902
+
903
+ except Exception as e:
904
+ raise ProverAPIError(
905
+ f"Failed to get rule hierarchy for {output_file} in job {job_identifier}: {e}"
906
+ )
907
+
908
+ # Backward compatibility methods that return raw dicts
909
+ def _get_violated_rules_dict(self, job_input: str) -> List[Dict[str, Any]]:
910
+ """Get violated rules as raw dictionaries (backward compatibility)."""
911
+ violations = self.get_violated_rules(job_input)
912
+ return [v.to_dict() for v in violations]
913
+
914
+ def _get_all_checks_dict(self, job_input: str) -> List[Dict[str, Any]]:
915
+ """Get all checks as raw dictionaries (backward compatibility)."""
916
+ checks = self.get_all_checks(job_input)
917
+ return [c.to_dict() for c in checks]
918
+
919
+ def _get_leaf_checks_dict(self, job_input: str) -> List[Dict[str, Any]]:
920
+ """Get leaf checks as raw dictionaries (backward compatibility)."""
921
+ leaf_checks = self.get_leaf_checks(job_input)
922
+ return [c.to_dict() for c in leaf_checks]
923
+
924
+ # Compatibility methods for tests that use internal parsing methods
925
+ def _parse_tree_view_for_all_checks(self, tree_data: Dict[str, Any]) -> List[Dict[str, Any]]:
926
+ """Parse tree-view data for all checks (backward compatibility for tests)."""
927
+ # Create parser if not exists (for tests that bypass __init__)
928
+ if not hasattr(self, "tree_parser"):
929
+ from .tree_parser import TreeParser
930
+
931
+ self.tree_parser = TreeParser()
932
+ checks = self.tree_parser.parse_all_checks(tree_data)
933
+ return [c.to_dict() for c in checks]
934
+
935
+ def _parse_tree_view_for_violations(self, tree_data: Dict[str, Any]) -> List[Dict[str, Any]]:
936
+ """
937
+ Parse tree-view data for VIOLATED_ASSERT nodes (backward compatibility for tests).
938
+
939
+ Note: This returns only VIOLATED_ASSERT nodes, not all nodes with VIOLATED status.
940
+ For all violated nodes, use parse_violations() directly.
941
+ """
942
+ # Create parser if not exists (for tests that bypass __init__)
943
+ if not hasattr(self, "tree_parser"):
944
+ from .tree_parser import TreeParser
945
+
946
+ self.tree_parser = TreeParser()
947
+ violations = self.tree_parser.parse_violated_asserts(tree_data)
948
+ return [v.to_dict() for v in violations]
949
+
950
+ def get_source_files(self, job_input: str) -> List[SourceFileNode]:
951
+ """
952
+ Get the source file tree for a job.
953
+
954
+ Args:
955
+ job_input: Job URL, job ID, or local emv-* path
956
+
957
+ Returns:
958
+ List of SourceFileNode objects representing the file tree
959
+
960
+ Raises:
961
+ ProverAPIError: If API call fails
962
+ """
963
+ job_identifier = self._extract_job_identifier(job_input)
964
+ self.logger.info(f"Fetching source files for job: {job_identifier}")
965
+ raw_data = self.data_fetcher.fetch_source_files_list(job_identifier)
966
+ return SourceFileNode.from_list(raw_data)
967
+
968
+ def get_source_file_content(self, job_input: str, path: str) -> str:
969
+ """
970
+ Get the content of a specific source file for a job.
971
+
972
+ Args:
973
+ job_input: Job URL, job ID, or local emv-* path
974
+ path: Path to the source file (e.g. ".certora_sources/src/Contract.sol")
975
+
976
+ Returns:
977
+ Raw file content as a string
978
+
979
+ Raises:
980
+ ProverAPIError: If API call fails
981
+ """
982
+ job_identifier = self._extract_job_identifier(job_input)
983
+ self.logger.info(f"Fetching source file {path} for job: {job_identifier}")
984
+ return self.data_fetcher.fetch_source_file_content(job_identifier, path)
985
+
986
+ def fetch_custom_endpoint(
987
+ self, endpoint: str, params: Optional[Dict[str, Any]] = None, method: str = "GET"
988
+ ) -> Dict[str, Any]:
989
+ """
990
+ Fetch data from an arbitrary user-provided endpoint.
991
+
992
+ Args:
993
+ endpoint: The full URL endpoint to fetch from
994
+ params: Optional query parameters (for GET/DELETE) or request body (for POST/PUT)
995
+ method: HTTP method (GET, POST, PUT, DELETE)
996
+
997
+ Returns:
998
+ API response data
999
+
1000
+ Raises:
1001
+ ProverAPIError: If configured for local use or if API call fails
1002
+ AuthenticationError: If authentication fails
1003
+ """
1004
+ if self.use_local:
1005
+ raise ProverAPIError(
1006
+ "fetch_custom_endpoint is not supported when using local prover outputs"
1007
+ )
1008
+
1009
+ try:
1010
+ return self.data_fetcher.fetch_custom_endpoint(endpoint, params, method)
1011
+ except Exception as e:
1012
+ raise ProverAPIError(f"Failed to fetch from custom endpoint {endpoint}: {e}")
1013
+
1014
+ def download_job_outputs(self, job_input: str) -> Dict[str, Any]:
1015
+ """
1016
+ Download and extract all output files for a job using bulk tar.gz download.
1017
+
1018
+ This method downloads the entire job output archive and extracts individual
1019
+ JSON files to disk cache. Subsequent calls to get_rule_hierarchy, get_calltrace,
1020
+ etc. will read from local disk instead of making individual API calls.
1021
+
1022
+ Args:
1023
+ job_input: Job URL, job ID, or local emv-* path
1024
+
1025
+ Returns:
1026
+ Dictionary with download statistics:
1027
+ {
1028
+ 'files_extracted': 123,
1029
+ 'download_size_mb': 30.4,
1030
+ 'download_time_s': 4.22,
1031
+ 'extraction_time_s': 1.15,
1032
+ 'cache_dir': '/path/to/cache'
1033
+ }
1034
+
1035
+ Raises:
1036
+ ProverAPIError: If configured for local use or if download fails
1037
+ """
1038
+ if self.use_local:
1039
+ raise ProverAPIError(
1040
+ "download_job_outputs is not supported when using local prover outputs"
1041
+ )
1042
+
1043
+ job_identifier = self._extract_job_identifier(job_input)
1044
+
1045
+ # Check if already downloaded
1046
+ cache_dir = self._get_bulk_cache_dir(job_identifier)
1047
+ if cache_dir.exists():
1048
+ existing_files = list(cache_dir.glob("*.json"))
1049
+ if existing_files:
1050
+ self.logger.info(f"Job outputs already cached for {job_identifier} ({len(existing_files)} files)")
1051
+ return {
1052
+ 'files_extracted': len(existing_files),
1053
+ 'download_size_mb': 0,
1054
+ 'download_time_s': 0,
1055
+ 'extraction_time_s': 0,
1056
+ 'cache_hit': True,
1057
+ 'cache_dir': str(cache_dir)
1058
+ }
1059
+
1060
+ self.logger.info(f"Downloading bulk outputs for job {job_identifier}")
1061
+
1062
+ try:
1063
+ import time
1064
+
1065
+ # Download tar.gz using data_fetcher
1066
+ start_download = time.time()
1067
+ tar_content = self.data_fetcher.fetch_outputs(job_identifier)
1068
+ download_time = time.time() - start_download
1069
+ download_size_mb = len(tar_content) / (1024 * 1024)
1070
+
1071
+ # Create cache directory
1072
+ cache_dir = self._get_bulk_cache_dir(job_identifier)
1073
+ cache_dir.mkdir(parents=True, exist_ok=True)
1074
+
1075
+ # Extract tar.gz directly to disk
1076
+ start_extraction = time.time()
1077
+ files_extracted = 0
1078
+
1079
+ with tarfile.open(fileobj=io.BytesIO(tar_content), mode='r:gz') as tar:
1080
+ for member in tar.getmembers():
1081
+ if member.isfile() and member.name.endswith('.json'):
1082
+ try:
1083
+ # Extract file content
1084
+ file_obj = tar.extractfile(member)
1085
+ if file_obj:
1086
+ content = file_obj.read().decode('utf-8')
1087
+ # Validate JSON
1088
+ json_data = json.loads(content)
1089
+
1090
+ # Save to disk with just filename
1091
+ filename = os.path.basename(member.name)
1092
+ file_path = cache_dir / filename
1093
+
1094
+ with open(file_path, 'w') as f:
1095
+ json.dump(json_data, f, indent=2, default=str)
1096
+
1097
+ files_extracted += 1
1098
+
1099
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
1100
+ self.logger.warning(f"Skipping invalid file {member.name}: {e}")
1101
+ continue
1102
+
1103
+ extraction_time = time.time() - start_extraction
1104
+
1105
+ self.logger.info(
1106
+ f"Downloaded {files_extracted} files for job {job_identifier} "
1107
+ f"({download_size_mb:.1f}MB in {download_time:.2f}s + {extraction_time:.2f}s extraction)"
1108
+ )
1109
+
1110
+ return {
1111
+ 'files_extracted': files_extracted,
1112
+ 'download_size_mb': download_size_mb,
1113
+ 'download_time_s': download_time,
1114
+ 'extraction_time_s': extraction_time,
1115
+ 'cache_hit': False,
1116
+ 'cache_dir': str(cache_dir)
1117
+ }
1118
+
1119
+ except Exception as e:
1120
+ raise ProverAPIError(f"Failed to download job outputs for {job_identifier}: {e}")
1121
+
1122
+ def _fetch_outputs_cached(self, job_identifier: str) -> bytes:
1123
+ """Fetch the job outputs tar with disk-level caching.
1124
+
1125
+ Caches the raw tar bytes in bulk_cache/job_{id}/outputs.tar.gz so subsequent
1126
+ calls for the same job skip the network fetch.
1127
+ """
1128
+ cache_dir = self._get_bulk_cache_dir(job_identifier)
1129
+ tar_path = cache_dir / "outputs.tar.gz"
1130
+ if tar_path.exists():
1131
+ return tar_path.read_bytes()
1132
+ tar_content = self.data_fetcher.fetch_outputs(job_identifier)
1133
+ cache_dir.mkdir(parents=True, exist_ok=True)
1134
+ tar_path.write_bytes(tar_content)
1135
+ return tar_content
1136
+
1137
+ def extract_unsat_core_files(self, job_input: str, dest_dir: Path) -> List[Path]:
1138
+ """Extract UnsatCoreTAC*.txt files from the job output tar into dest_dir.
1139
+
1140
+ No filtering is applied — the caller is responsible for any filtering.
1141
+
1142
+ Returns list of extracted file paths.
1143
+ """
1144
+ job_identifier = self._extract_job_identifier(job_input)
1145
+ dest_dir.mkdir(parents=True, exist_ok=True)
1146
+ tar_content = self._fetch_outputs_cached(job_identifier)
1147
+ extracted: List[Path] = []
1148
+ with tarfile.open(fileobj=io.BytesIO(tar_content), mode="r:gz") as tar:
1149
+ for member in tar.getmembers():
1150
+ if not member.isfile():
1151
+ continue
1152
+ parts = Path(member.name).parts
1153
+ name = parts[-1]
1154
+ if "Reports" in parts and name.startswith("UnsatCoreTAC") and name.endswith(".txt"):
1155
+ file_dest = dest_dir / name
1156
+ file_obj = tar.extractfile(member)
1157
+ if file_obj:
1158
+ file_dest.write_bytes(file_obj.read())
1159
+ extracted.append(file_dest)
1160
+ return extracted
1161
+
1162
+ def extract_certora_sources(self, job_input: str, dest_dir: Path) -> None:
1163
+ """Extract the .certora_sources tree from the job output tar into dest_dir.
1164
+
1165
+ The tree structure rooted at .certora_sources is preserved under dest_dir.
1166
+ """
1167
+ job_identifier = self._extract_job_identifier(job_input)
1168
+ tar_content = self._fetch_outputs_cached(job_identifier)
1169
+ with tarfile.open(fileobj=io.BytesIO(tar_content), mode="r:gz") as tar:
1170
+ for member in tar.getmembers():
1171
+ if not member.isfile():
1172
+ continue
1173
+ parts = Path(member.name).parts
1174
+ if ".certora_sources" in parts:
1175
+ src_idx = parts.index(".certora_sources")
1176
+ rel = Path(*parts[src_idx:])
1177
+ file_dest = dest_dir / rel
1178
+ file_dest.parent.mkdir(parents=True, exist_ok=True)
1179
+ file_obj = tar.extractfile(member)
1180
+ if file_obj:
1181
+ file_dest.write_bytes(file_obj.read())
1182
+
1183
+ # ---------------------------------------------------------------------------
1184
+ # High-level fetch methods (no tarball download)
1185
+ # ---------------------------------------------------------------------------
1186
+
1187
+ def fetch_job_sources(self, job_input: str, dest_dir: Path, max_workers: int = 8) -> Path:
1188
+ """Download all source files for a job into dest_dir/inputs/.certora_sources/.
1189
+
1190
+ Uses the source-files API to fetch individual files in parallel, avoiding
1191
+ the need to download the full job output tarball.
1192
+
1193
+ Files are only downloaded once — if the marker file .source_fetch_complete
1194
+ exists, the download is skipped.
1195
+
1196
+ Args:
1197
+ job_input: Job URL, job ID, or local emv-* path
1198
+ dest_dir: Base directory for the report layout
1199
+ max_workers: Number of parallel download threads (default: 8)
1200
+
1201
+ Returns:
1202
+ dest_dir (unchanged, for chaining)
1203
+ """
1204
+ job_identifier = self._extract_job_identifier(job_input)
1205
+ sources_dir = dest_dir / "inputs" / ".certora_sources"
1206
+ marker = sources_dir / ".source_fetch_complete"
1207
+
1208
+ # Per-job lock prevents concurrent threads from writing the same files
1209
+ with _get_fetch_lock(f"sources_{job_identifier}"):
1210
+ if marker.exists():
1211
+ self.logger.info(f"Source files already fetched for {dest_dir}")
1212
+ return dest_dir
1213
+
1214
+ sources_dir.mkdir(parents=True, exist_ok=True)
1215
+
1216
+ file_tree = self.get_source_files(job_input)
1217
+ all_files = _flatten_source_file_tree(file_tree)
1218
+
1219
+ certora_prefix = ".certora_sources/"
1220
+ source_files = [
1221
+ (api_path[len(certora_prefix):], api_path)
1222
+ for _, api_path in all_files
1223
+ if api_path.startswith(certora_prefix)
1224
+ ]
1225
+
1226
+ if not source_files:
1227
+ self.logger.warning(f"No source files found for job {job_identifier}")
1228
+ marker.touch()
1229
+ return dest_dir
1230
+
1231
+ def _fetch_one(item: tuple) -> None:
1232
+ rel_path, api_path = item
1233
+ content = self.get_source_file_content(job_input, api_path)
1234
+ file_path = sources_dir / rel_path
1235
+ file_path.parent.mkdir(parents=True, exist_ok=True)
1236
+ file_path.write_text(content, encoding="utf-8")
1237
+
1238
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
1239
+ list(pool.map(_fetch_one, source_files))
1240
+
1241
+ marker.touch()
1242
+ self.logger.info(f"Fetched {len(source_files)} source files to {sources_dir}")
1243
+ return dest_dir
1244
+
1245
+ def fetch_job_treeview(self, job_input: str, dest_dir: Path, max_workers: int = 8) -> Path:
1246
+ """Download treeview status and all output files into dest_dir/Reports/treeView/.
1247
+
1248
+ Uses the tree-view API to fetch the treeViewStatus and individual output files
1249
+ in parallel, avoiding the need to download the full job output tarball.
1250
+
1251
+ Files are only downloaded once — if treeViewStatus_0.json exists, the download
1252
+ is skipped.
1253
+
1254
+ Args:
1255
+ job_input: Job URL, job ID, or local emv-* path
1256
+ dest_dir: Base directory for the report layout
1257
+ max_workers: Number of parallel download threads (default: 8)
1258
+
1259
+ Returns:
1260
+ dest_dir (unchanged, for chaining)
1261
+ """
1262
+ job_identifier = self._extract_job_identifier(job_input)
1263
+ treeview_dir = dest_dir / "Reports" / "treeView"
1264
+ status_file = treeview_dir / "treeViewStatus_0.json"
1265
+
1266
+ # Per-job lock prevents concurrent threads from writing the same files
1267
+ with _get_fetch_lock(f"treeview_{job_identifier}"):
1268
+ if status_file.exists():
1269
+ self.logger.info(f"Treeview data already fetched for {dest_dir}")
1270
+ return dest_dir
1271
+
1272
+ treeview_dir.mkdir(parents=True, exist_ok=True)
1273
+
1274
+ tree_status = self.get_treeview_status(job_input)
1275
+
1276
+ output_files = _collect_treeview_output_files(tree_status)
1277
+
1278
+ def _fetch_one(filename: str) -> Optional[str]:
1279
+ try:
1280
+ file_data = self.fetch_treeview_output_by_filename(job_input, filename)
1281
+ output_path = treeview_dir / filename
1282
+ with open(output_path, "w") as f:
1283
+ json.dump(file_data, f, indent=2)
1284
+ return None
1285
+ except Exception as e:
1286
+ return f"Warning: Failed to fetch {filename}: {e}"
1287
+
1288
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
1289
+ for error in pool.map(_fetch_one, output_files):
1290
+ if error:
1291
+ self.logger.warning(error)
1292
+
1293
+ # Write status file last — acts as the completion marker
1294
+ with open(status_file, "w") as f:
1295
+ json.dump(tree_status, f, indent=2)
1296
+
1297
+ self.logger.info(f"Fetched treeview + {len(output_files)} output files to {treeview_dir}")
1298
+ return dest_dir
1299
+
1300
+ def fetch_sources_and_treeview_files(self, job_input: str, dest_dir: Path, max_workers: int = 8) -> Path:
1301
+ """Download source files and treeview data into a report directory layout.
1302
+
1303
+ Combines fetch_job_sources() and fetch_job_treeview() into a single call.
1304
+ The resulting directory layout matches what tarball extraction produces::
1305
+
1306
+ dest_dir/
1307
+ inputs/.certora_sources/ <- contract source files
1308
+ Reports/treeView/ <- treeview status + output JSONs
1309
+
1310
+ This is a drop-in replacement for downloading and extracting the full job
1311
+ output tarball when only source files and treeview data are needed.
1312
+
1313
+ Args:
1314
+ job_input: Job URL, job ID, or local emv-* path
1315
+ dest_dir: Base directory for the report layout
1316
+ max_workers: Number of parallel download threads (default: 8)
1317
+
1318
+ Returns:
1319
+ dest_dir (for use as the ``folder`` argument to AIComposer analysis)
1320
+ """
1321
+ self.fetch_job_sources(job_input, dest_dir, max_workers=max_workers)
1322
+ self.fetch_job_treeview(job_input, dest_dir, max_workers=max_workers)
1323
+ return dest_dir
1324
+
1325
+ def _get_bulk_cache_dir(self, job_identifier: str) -> Path:
1326
+ """Get the bulk cache directory for a specific job."""
1327
+ if self.cache:
1328
+ base_dir = self.cache.cache_dir.parent / "bulk_cache"
1329
+ else:
1330
+ # Fallback if no regular cache enabled
1331
+ base_dir = Path.cwd() / ".certora_internal" / "bulk_cache"
1332
+
1333
+ return base_dir / f"job_{job_identifier}"