argus-cloud-optimizer 0.2.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.
Files changed (62) hide show
  1. adapters/__init__.py +0 -0
  2. adapters/aws/__init__.py +0 -0
  3. adapters/aws/adapter.py +85 -0
  4. adapters/aws/auth.py +57 -0
  5. adapters/aws/cloudtrail.py +83 -0
  6. adapters/aws/cloudwatch.py +732 -0
  7. adapters/aws/config.py +9 -0
  8. adapters/aws/cost_explorer.py +116 -0
  9. adapters/aws/resource_explorer.py +186 -0
  10. adapters/aws/retry.py +55 -0
  11. adapters/azure/__init__.py +0 -0
  12. adapters/azure/activity_log.py +159 -0
  13. adapters/azure/adapter.py +117 -0
  14. adapters/azure/cost_management.py +125 -0
  15. adapters/azure/monitor.py +311 -0
  16. adapters/azure/resource_graph.py +113 -0
  17. adapters/azure/retry.py +57 -0
  18. adapters/base.py +105 -0
  19. adapters/gcp/__init__.py +0 -0
  20. adapters/gcp/adapter.py +86 -0
  21. adapters/gcp/asset_inventory.py +116 -0
  22. adapters/gcp/billing.py +118 -0
  23. adapters/gcp/cloud_logging.py +93 -0
  24. adapters/gcp/cloud_monitoring.py +276 -0
  25. adapters/gcp/retry.py +46 -0
  26. ai/__init__.py +0 -0
  27. ai/anthropic.py +174 -0
  28. ai/azure_openai.py +241 -0
  29. ai/base.py +78 -0
  30. ai/bedrock.py +169 -0
  31. ai/vertexai.py +234 -0
  32. argus_cloud_optimizer-0.2.0.dist-info/METADATA +433 -0
  33. argus_cloud_optimizer-0.2.0.dist-info/RECORD +62 -0
  34. argus_cloud_optimizer-0.2.0.dist-info/WHEEL +5 -0
  35. argus_cloud_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
  36. argus_cloud_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
  37. argus_cloud_optimizer-0.2.0.dist-info/top_level.txt +4 -0
  38. core/__init__.py +0 -0
  39. core/__version__.py +1 -0
  40. core/agent/__init__.py +0 -0
  41. core/agent/loop.py +390 -0
  42. core/agent/prompts.py +317 -0
  43. core/config.py +235 -0
  44. core/log.py +69 -0
  45. core/models/__init__.py +0 -0
  46. core/models/finding.py +76 -0
  47. core/py.typed +0 -0
  48. core/reports/__init__.py +0 -0
  49. core/reports/comparison.py +49 -0
  50. core/reports/delivery.py +323 -0
  51. core/reports/export.py +111 -0
  52. core/reports/generator.py +168 -0
  53. core/reports/html.py +286 -0
  54. core/reports/multi_cloud.py +162 -0
  55. core/secrets.py +145 -0
  56. core/token_tracker.py +97 -0
  57. core/validation.py +214 -0
  58. entrypoints/__init__.py +0 -0
  59. entrypoints/aws_lambda.py +299 -0
  60. entrypoints/azure_function.py +257 -0
  61. entrypoints/cli.py +156 -0
  62. entrypoints/gcp_cloudrun.py +209 -0
core/models/finding.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any, Literal
6
+
7
+ Cloud = Literal["aws", "gcp", "azure"]
8
+ Priority = Literal["high", "medium", "low"]
9
+ FindingStatus = Literal["new", "recurring", "resolved"]
10
+
11
+
12
+ @dataclass
13
+ class ResourceFinding:
14
+ """
15
+ Universal representation of a single idle/wasteful cloud resource.
16
+ Produced by the agent loop, consumed by the report generator.
17
+ No cloud SDK imports — pure Python.
18
+ """
19
+
20
+ resource_id: str
21
+ resource_type: str # e.g. "AWS::EC2::Instance", "AWS::RDS::DBInstance"
22
+ cloud: Cloud # "aws" | "gcp" | "azure"
23
+ region: str
24
+ estimated_monthly_cost: float # USD
25
+ waste_reason: str # AI-written: why this resource is idle/wasteful
26
+ recommendation: str # AI-written: specific action to take
27
+ priority: Priority # AI-assigned based on cost + confidence
28
+ metrics_summary: dict[str, Any] # key signals used to reach this conclusion
29
+ tags: dict[str, str]
30
+ scan_time: datetime
31
+ name: str | None = None
32
+ last_activity: datetime | None = None
33
+ status: FindingStatus = "new"
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "resource_id": self.resource_id,
38
+ "resource_type": self.resource_type,
39
+ "cloud": self.cloud,
40
+ "region": self.region,
41
+ "name": self.name,
42
+ "estimated_monthly_cost": self.estimated_monthly_cost,
43
+ "waste_reason": self.waste_reason,
44
+ "recommendation": self.recommendation,
45
+ "priority": self.priority,
46
+ "metrics_summary": self.metrics_summary,
47
+ "tags": self.tags,
48
+ "last_activity": (
49
+ self.last_activity.isoformat() if self.last_activity else None
50
+ ),
51
+ "scan_time": self.scan_time.isoformat(),
52
+ "status": self.status,
53
+ }
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict[str, Any], scan_time: datetime) -> ResourceFinding:
57
+ last_activity = None
58
+ if data.get("last_activity"):
59
+ last_activity = datetime.fromisoformat(data["last_activity"])
60
+
61
+ return cls(
62
+ resource_id=data["resource_id"],
63
+ resource_type=data["resource_type"],
64
+ cloud=data["cloud"],
65
+ region=data["region"],
66
+ name=data.get("name"),
67
+ estimated_monthly_cost=float(data["estimated_monthly_cost"]),
68
+ waste_reason=data["waste_reason"],
69
+ recommendation=data["recommendation"],
70
+ priority=data["priority"],
71
+ metrics_summary=data.get("metrics_summary", {}),
72
+ tags=data.get("tags", {}),
73
+ last_activity=last_activity,
74
+ scan_time=scan_time,
75
+ status=data.get("status", "new"),
76
+ )
core/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from core.models.finding import ResourceFinding
6
+
7
+
8
+ def compare_scans(
9
+ current_findings: list[ResourceFinding],
10
+ previous_report: dict[str, Any] | None,
11
+ ) -> tuple[list[ResourceFinding], dict[str, Any]]:
12
+ """
13
+ Compare current findings against a previous report and label each finding's status.
14
+
15
+ Returns:
16
+ (labelled_findings, diff_summary)
17
+ labelled_findings: current findings with status set to "new" or "recurring"
18
+ diff_summary: dict with new/recurring/resolved counts
19
+ and resolved_resource_ids
20
+ """
21
+ if not previous_report:
22
+ for f in current_findings:
23
+ f.status = "new"
24
+ return current_findings, {
25
+ "previous_scan_id": None,
26
+ "new_findings": len(current_findings),
27
+ "recurring_findings": 0,
28
+ "resolved_findings": 0,
29
+ "resolved_resource_ids": [],
30
+ }
31
+
32
+ prev_resource_ids = {f["resource_id"] for f in previous_report.get("findings", [])}
33
+ current_resource_ids = {f.resource_id for f in current_findings}
34
+
35
+ for f in current_findings:
36
+ f.status = "recurring" if f.resource_id in prev_resource_ids else "new"
37
+
38
+ resolved_ids = sorted(prev_resource_ids - current_resource_ids)
39
+
40
+ new_count = sum(1 for f in current_findings if f.status == "new")
41
+ recurring_count = sum(1 for f in current_findings if f.status == "recurring")
42
+
43
+ return current_findings, {
44
+ "previous_scan_id": previous_report.get("scan_id"),
45
+ "new_findings": new_count,
46
+ "recurring_findings": recurring_count,
47
+ "resolved_findings": len(resolved_ids),
48
+ "resolved_resource_ids": resolved_ids,
49
+ }
@@ -0,0 +1,323 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import urllib.request
6
+ from abc import ABC, abstractmethod
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import structlog
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Exceptions
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ class NotificationDeliveryError(Exception):
22
+ """Raised when a notification provider fails to deliver."""
23
+
24
+
25
+ class SlackDeliveryError(NotificationDeliveryError):
26
+ """Raised when Slack rejects the webhook payload."""
27
+
28
+
29
+ class TeamsDeliveryError(NotificationDeliveryError):
30
+ """Raised when Teams rejects the webhook payload."""
31
+
32
+
33
+ class WebhookDeliveryError(NotificationDeliveryError):
34
+ """Raised when a generic webhook call fails."""
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # NotificationProvider ABC
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ class NotificationProvider(ABC):
43
+ @abstractmethod
44
+ def notify(self, payload: dict[str, Any]) -> None: ...
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Slack
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class SlackNotificationProvider(NotificationProvider):
53
+ def __init__(self, webhook_url: str | None = None) -> None:
54
+ from core.config import get_settings
55
+
56
+ self._url = (
57
+ webhook_url
58
+ if webhook_url is not None
59
+ else get_settings().report.slack_webhook_url
60
+ )
61
+
62
+ def notify(self, payload: dict[str, Any]) -> None:
63
+ if not self._url:
64
+ raise EnvironmentError(
65
+ "SLACK_WEBHOOK_URL is not set. "
66
+ "Export it or pass webhook_url= explicitly."
67
+ )
68
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
69
+ req = urllib.request.Request(
70
+ self._url,
71
+ data=body,
72
+ headers={"Content-Type": "application/json"},
73
+ method="POST",
74
+ )
75
+ try:
76
+ with urllib.request.urlopen(req, timeout=10) as resp:
77
+ response_text = resp.read().decode("utf-8")
78
+ except urllib.error.HTTPError as exc:
79
+ raise SlackDeliveryError(
80
+ f"Slack webhook returned HTTP {exc.code}: {exc.reason}"
81
+ ) from exc
82
+ except urllib.error.URLError as exc:
83
+ raise SlackDeliveryError(
84
+ f"Failed to reach Slack webhook: {exc.reason}"
85
+ ) from exc
86
+
87
+ if response_text.strip() != "ok":
88
+ raise SlackDeliveryError(f"Unexpected Slack response: {response_text!r}")
89
+
90
+ logger.info("slack_report_delivered")
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Microsoft Teams (Office 365 Incoming Webhook)
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ class TeamsNotificationProvider(NotificationProvider):
99
+ def __init__(self, webhook_url: str | None = None) -> None:
100
+ from core.config import get_settings
101
+
102
+ self._url = (
103
+ webhook_url
104
+ if webhook_url is not None
105
+ else get_settings().report.teams_webhook_url
106
+ )
107
+
108
+ def notify(self, payload: dict[str, Any]) -> None:
109
+ if not self._url:
110
+ raise EnvironmentError(
111
+ "TEAMS_WEBHOOK_URL is not set. "
112
+ "Export it or pass webhook_url= explicitly."
113
+ )
114
+ teams_payload = self._to_teams_card(payload)
115
+ body = json.dumps(teams_payload, ensure_ascii=False).encode("utf-8")
116
+ req = urllib.request.Request(
117
+ self._url,
118
+ data=body,
119
+ headers={"Content-Type": "application/json"},
120
+ method="POST",
121
+ )
122
+ try:
123
+ with urllib.request.urlopen(req, timeout=10) as resp:
124
+ resp.read()
125
+ except urllib.error.HTTPError as exc:
126
+ raise TeamsDeliveryError(
127
+ f"Teams webhook returned HTTP {exc.code}: {exc.reason}"
128
+ ) from exc
129
+ except urllib.error.URLError as exc:
130
+ raise TeamsDeliveryError(
131
+ f"Failed to reach Teams webhook: {exc.reason}"
132
+ ) from exc
133
+
134
+ logger.info("teams_report_delivered")
135
+
136
+ def _to_teams_card(self, slack_payload: dict[str, Any]) -> dict[str, Any]:
137
+ text_parts: list[str] = []
138
+ for block in slack_payload.get("blocks", []):
139
+ if block.get("type") == "section":
140
+ text_obj = block.get("text", {})
141
+ text_parts.append(text_obj.get("text", ""))
142
+ elif block.get("type") == "header":
143
+ text_obj = block.get("text", {})
144
+ text_parts.append(f"**{text_obj.get('text', '')}**")
145
+
146
+ return {
147
+ "@type": "MessageCard",
148
+ "@context": "http://schema.org/extensions",
149
+ "summary": "Argus Cost Report",
150
+ "themeColor": "0076D7",
151
+ "title": "Argus Cost Optimization Report",
152
+ "text": "\n\n".join(text_parts),
153
+ }
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Generic Webhook (HTTP POST with raw JSON)
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ class WebhookNotificationProvider(NotificationProvider):
162
+ def __init__(self, webhook_url: str | None = None) -> None:
163
+ from core.config import get_settings
164
+
165
+ self._url = (
166
+ webhook_url
167
+ if webhook_url is not None
168
+ else get_settings().report.webhook_url
169
+ )
170
+
171
+ def notify(self, payload: dict[str, Any]) -> None:
172
+ if not self._url:
173
+ raise EnvironmentError(
174
+ "WEBHOOK_URL is not set. " "Export it or pass webhook_url= explicitly."
175
+ )
176
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
177
+ req = urllib.request.Request(
178
+ self._url,
179
+ data=body,
180
+ headers={"Content-Type": "application/json"},
181
+ method="POST",
182
+ )
183
+ try:
184
+ with urllib.request.urlopen(req, timeout=10) as resp:
185
+ resp.read()
186
+ except urllib.error.HTTPError as exc:
187
+ raise WebhookDeliveryError(
188
+ f"Webhook returned HTTP {exc.code}: {exc.reason}"
189
+ ) from exc
190
+ except urllib.error.URLError as exc:
191
+ raise WebhookDeliveryError(
192
+ f"Failed to reach webhook: {exc.reason}"
193
+ ) from exc
194
+
195
+ logger.info("webhook_report_delivered")
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Provider registry + dispatcher
200
+ # ---------------------------------------------------------------------------
201
+
202
+ _PROVIDER_MAP: dict[str, type[NotificationProvider]] = {
203
+ "slack": SlackNotificationProvider,
204
+ "teams": TeamsNotificationProvider,
205
+ "webhook": WebhookNotificationProvider,
206
+ }
207
+
208
+
209
+ def build_notification_providers() -> list[NotificationProvider]:
210
+ raw = os.environ.get("NOTIFICATION_PROVIDER", "slack")
211
+ names = [n.strip().lower() for n in raw.split(",") if n.strip()]
212
+ providers: list[NotificationProvider] = []
213
+ for name in names:
214
+ cls = _PROVIDER_MAP.get(name)
215
+ if cls is None:
216
+ logger.warning("unknown_notification_provider", provider=name)
217
+ continue
218
+ providers.append(cls())
219
+ return providers
220
+
221
+
222
+ def notify_all(payload: dict[str, Any], dry_run: bool | None = None) -> None:
223
+ from core.config import get_settings
224
+
225
+ resolved_dry_run = (
226
+ dry_run if dry_run is not None else get_settings().report.dry_run
227
+ )
228
+
229
+ if resolved_dry_run:
230
+ logger.info(
231
+ "dry_run_notification_skipped",
232
+ payload_preview=json.dumps(payload, indent=2)[:500],
233
+ )
234
+ return
235
+
236
+ providers = build_notification_providers()
237
+ for provider in providers:
238
+ try:
239
+ provider.notify(payload)
240
+ except (NotificationDeliveryError, OSError) as exc:
241
+ logger.error(
242
+ "notification_delivery_failed",
243
+ provider=type(provider).__name__,
244
+ error=str(exc),
245
+ )
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Legacy function — delegates to notify_all for backward compatibility
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def post_to_slack(
254
+ payload: dict[str, Any],
255
+ webhook_url: str | None = None,
256
+ dry_run: bool | None = None,
257
+ ) -> None:
258
+ from core.config import get_settings
259
+
260
+ resolved_dry_run = (
261
+ dry_run if dry_run is not None else get_settings().report.dry_run
262
+ )
263
+
264
+ if resolved_dry_run:
265
+ logger.info(
266
+ "[DRY RUN] Slack payload (not sent):\n%s", json.dumps(payload, indent=2)
267
+ )
268
+ return
269
+
270
+ provider = SlackNotificationProvider(webhook_url=webhook_url)
271
+ provider.notify(payload)
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Local report saving (unchanged)
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ def save_reports_locally(
280
+ report: dict[str, Any],
281
+ base_dir: str | None = None,
282
+ ) -> str:
283
+ from core.config import get_settings
284
+ from core.reports.export import export_pdf, export_pptx, get_report_formats
285
+
286
+ resolved_dir = Path(base_dir or get_settings().report.local_report_dir)
287
+ now = datetime.now(tz=timezone.utc)
288
+ prefix = (
289
+ resolved_dir / report["cloud"] / now.strftime("%Y/%m/%d") / report["scan_id"]
290
+ )
291
+ prefix.parent.mkdir(parents=True, exist_ok=True)
292
+
293
+ formats = get_report_formats()
294
+ result_path = str(prefix)
295
+
296
+ if "json" in formats:
297
+ json_path = prefix.with_suffix(".json")
298
+ json_path.write_text(
299
+ json.dumps(report, indent=2, default=str), encoding="utf-8"
300
+ )
301
+ logger.info("json_report_saved", path=str(json_path))
302
+
303
+ if "html" in formats:
304
+ from core.reports.html import build_html_report
305
+
306
+ html_path = prefix.with_suffix(".html")
307
+ html_path.write_text(build_html_report(report), encoding="utf-8")
308
+ logger.info("html_report_saved", path=str(html_path))
309
+ result_path = str(html_path.resolve())
310
+
311
+ if "pdf" in formats:
312
+ try:
313
+ export_pdf(report, prefix)
314
+ except ImportError as exc:
315
+ logger.warning("pdf_export_skipped", reason=str(exc))
316
+
317
+ if "pptx" in formats:
318
+ try:
319
+ export_pptx(report, prefix)
320
+ except ImportError as exc:
321
+ logger.warning("pptx_export_skipped", reason=str(exc))
322
+
323
+ return result_path
core/reports/export.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ Multi-format report export: PDF and PPTX.
3
+
4
+ Both are optional — the libraries are only imported when the format is requested.
5
+ Install with: pip install weasyprint python-pptx
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import structlog
14
+
15
+ logger = structlog.get_logger(__name__)
16
+
17
+
18
+ def get_report_formats() -> set[str]:
19
+ from core.config import get_settings
20
+
21
+ return get_settings().report.formats
22
+
23
+
24
+ def export_pdf(report: dict[str, Any], output_path: Path) -> Path:
25
+ try:
26
+ from weasyprint import HTML # type: ignore[import-untyped]
27
+ except ImportError as exc:
28
+ raise ImportError(
29
+ "weasyprint is required for PDF export. "
30
+ "Install with: pip install weasyprint"
31
+ ) from exc
32
+
33
+ from core.reports.html import build_html_report
34
+
35
+ html_content = build_html_report(report)
36
+ pdf_path = output_path.with_suffix(".pdf")
37
+ HTML(string=html_content).write_pdf(str(pdf_path))
38
+ logger.info("pdf_report_saved", path=str(pdf_path))
39
+ return pdf_path
40
+
41
+
42
+ def export_pptx(report: dict[str, Any], output_path: Path) -> Path:
43
+ try:
44
+ from pptx import Presentation # type: ignore[import-untyped]
45
+ from pptx.util import Inches # type: ignore[import-untyped]
46
+ except ImportError as exc:
47
+ raise ImportError(
48
+ "python-pptx is required for PPTX export. "
49
+ "Install with: pip install python-pptx"
50
+ ) from exc
51
+
52
+ prs = Presentation()
53
+ prs.slide_width = Inches(13.333)
54
+ prs.slide_height = Inches(7.5)
55
+
56
+ # Title slide
57
+ slide = prs.slides.add_slide(prs.slide_layouts[0])
58
+ slide.shapes.title.text = f"Argus — {report['cloud'].upper()} Waste Report"
59
+ slide.placeholders[1].text = (
60
+ f"Generated: {report['generated_at'][:10]}\n"
61
+ f"Total estimated waste: ${report['total_estimated_waste_usd']:,.2f}/month\n"
62
+ f"Findings: {report['findings_count']}"
63
+ )
64
+
65
+ # Executive summary slide
66
+ slide = prs.slides.add_slide(prs.slide_layouts[1])
67
+ slide.shapes.title.text = "Executive Summary"
68
+ slide.placeholders[1].text = report.get("executive_summary", "")
69
+
70
+ # Top findings slide(s) — max 8 per slide
71
+ findings = report.get("findings", [])
72
+ chunk_size = 8
73
+ for i in range(0, len(findings), chunk_size):
74
+ chunk = findings[i : i + chunk_size]
75
+ slide = prs.slides.add_slide(prs.slide_layouts[1])
76
+ slide.shapes.title.text = (
77
+ f"Top Findings ({i + 1}–{i + len(chunk)} of {len(findings)})"
78
+ )
79
+ lines = []
80
+ for f in chunk:
81
+ name = f.get("name") or f["resource_id"]
82
+ cost = f["estimated_monthly_cost"]
83
+ priority = (f.get("priority") or "low").upper()
84
+ lines.append(
85
+ f"[{priority}] {name} — {f['resource_type']} — " f"${cost:,.2f}/mo"
86
+ )
87
+ lines.append(f" → {f.get('recommendation', '')}")
88
+ slide.placeholders[1].text = "\n".join(lines)
89
+
90
+ # Scan metadata slide
91
+ slide = prs.slides.add_slide(prs.slide_layouts[1])
92
+ slide.shapes.title.text = "Scan Details"
93
+ meta_lines = [
94
+ f"Scan ID: {report['scan_id']}",
95
+ f"Cloud: {report['cloud']}",
96
+ f"Accounts: {', '.join(report.get('accounts_scanned', []))}",
97
+ ]
98
+ if report.get("agent_input_tokens"):
99
+ meta_lines.append(
100
+ f"AI tokens: {report['agent_input_tokens']:,} in / "
101
+ f"{report['agent_output_tokens']:,} out"
102
+ )
103
+ meta_lines.append(
104
+ f"Estimated AI cost: ${report.get('estimated_agent_cost_usd', 0):.4f}"
105
+ )
106
+ slide.placeholders[1].text = "\n".join(meta_lines)
107
+
108
+ pptx_path = output_path.with_suffix(".pptx")
109
+ prs.save(str(pptx_path))
110
+ logger.info("pptx_report_saved", path=str(pptx_path))
111
+ return pptx_path