azure-rvtools 1.0.0__tar.gz

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,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: azure-rvtools
3
+ Version: 1.0.0
4
+ Summary: Convert RVTools Excel exports into an Azure IaaS cost estimate.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/KimTholstorf/azure-rvtools-cost-estimator
7
+ Project-URL: Repository, https://github.com/KimTholstorf/azure-rvtools-cost-estimator
8
+ Keywords: azure,rvtools,vmware,cloud,cost,estimator
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Environment :: Console
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: pandas>=2.0
17
+ Requires-Dist: openpyxl>=3.1
18
+ Requires-Dist: tabulate>=0.9
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+ VERSION = "1.0.0"
4
+ __version__ = VERSION
5
+ __all__ = ["main", "__version__"]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from .cli import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,358 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .excel import write_workbook
9
+ from .output import (
10
+ ReservationRec,
11
+ VMResult,
12
+ print_recommendations,
13
+ print_summary,
14
+ write_csv,
15
+ )
16
+ from .pricing import PricingClient
17
+ from .rvtools import VMRecord, filter_vms, list_topology, parse_rvtools
18
+ from .sku_mapper import ALL_VM_SKUS, find_disk_tier, find_vm_sku
19
+
20
+ VERSION = "1.0.0"
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Argument parsing
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
28
+ parser = argparse.ArgumentParser(
29
+ prog="azure-rvtools",
30
+ description="Convert RVTools Excel exports into an Azure IaaS cost estimate.",
31
+ )
32
+
33
+ parser.add_argument(
34
+ "--rvtools",
35
+ metavar="PATH",
36
+ required=True,
37
+ help="RVTools XLSX export file",
38
+ )
39
+ parser.add_argument(
40
+ "--region",
41
+ metavar="REGION",
42
+ default=None,
43
+ help="Azure region slug (e.g. westeurope, eastus). Required unless --list is used.",
44
+ )
45
+
46
+ parser.add_argument(
47
+ "--pricing",
48
+ choices=["optimized", "all-payg", "all-reserved"],
49
+ default="optimized",
50
+ help="Pricing mode (default: optimized)",
51
+ )
52
+ parser.add_argument(
53
+ "--disk-type",
54
+ choices=["premium-ssd", "standard-ssd", "standard-hdd"],
55
+ default="premium-ssd",
56
+ help="Managed disk type (default: premium-ssd)",
57
+ )
58
+ parser.add_argument(
59
+ "--include-powered-off",
60
+ action="store_true",
61
+ help="Include VMs that are not powered on",
62
+ )
63
+ parser.add_argument(
64
+ "--list",
65
+ action="store_true",
66
+ help="Print all Datacenters and Clusters in the file and exit",
67
+ )
68
+ parser.add_argument(
69
+ "--datacenter",
70
+ metavar="NAME",
71
+ nargs="+",
72
+ help="Only include VMs from these Datacenter(s) (case-insensitive, repeatable)",
73
+ )
74
+ parser.add_argument(
75
+ "--cluster",
76
+ metavar="NAME",
77
+ nargs="+",
78
+ help="Only include VMs from these Cluster(s) (case-insensitive, repeatable)",
79
+ )
80
+ parser.add_argument(
81
+ "--output",
82
+ metavar="PATH",
83
+ default="azure_estimate.xlsx",
84
+ help="Excel workbook output path (default: azure_estimate.xlsx)",
85
+ )
86
+ parser.add_argument(
87
+ "--csv",
88
+ metavar="PATH",
89
+ help="Also write a CSV to this path",
90
+ )
91
+ parser.add_argument(
92
+ "--reserved-term",
93
+ choices=["1-year", "3-year"],
94
+ default="3-year",
95
+ help="Reservation term for optimized / all-reserved pricing (default: 3-year)",
96
+ )
97
+ parser.add_argument(
98
+ "--support",
99
+ choices=["basic", "developer", "standard", "professional-direct"],
100
+ default="basic",
101
+ metavar="PLAN",
102
+ help=(
103
+ "Azure support plan to include in the estimate: "
104
+ "basic (free, default), developer ($29/mo), "
105
+ "standard ($100/mo), professional-direct ($1000/mo)"
106
+ ),
107
+ )
108
+ parser.add_argument(
109
+ "--os-license-included",
110
+ action="store_true",
111
+ dest="os_license_included",
112
+ help=(
113
+ "Use OS-included (Windows) pricing for Windows VMs. "
114
+ "By default Azure Hybrid Benefit is assumed (Linux compute rate)."
115
+ ),
116
+ )
117
+ parser.add_argument(
118
+ "--no-cache",
119
+ action="store_true",
120
+ help="Bypass local pricing cache",
121
+ )
122
+ parser.add_argument(
123
+ "--currency",
124
+ metavar="CODE",
125
+ default="USD",
126
+ help="Currency code (default: USD)",
127
+ )
128
+ parser.add_argument(
129
+ "--version",
130
+ action="version",
131
+ version=VERSION,
132
+ )
133
+
134
+ args = parser.parse_args(argv)
135
+
136
+ # --region is required for everything except --list
137
+ if not args.list and not args.region:
138
+ parser.error(
139
+ "--region is required (e.g. --region westeurope). "
140
+ "Use --list to inspect the file without specifying a region."
141
+ )
142
+
143
+ return args
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Recommendation builder
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def build_recommendations(results: list[VMResult]) -> list[ReservationRec]:
151
+ """
152
+ Group VMResults by SKU name and build reservation recommendations.
153
+ Only includes VMs where both sku and reserved_compute_monthly are not None.
154
+ Sorted by savings_monthly descending.
155
+ """
156
+ groups: dict[str, list[VMResult]] = {}
157
+ for r in results:
158
+ if r.sku is None or r.reserved_compute_monthly is None:
159
+ continue
160
+ groups.setdefault(r.sku.name, []).append(r)
161
+
162
+ recs: list[ReservationRec] = []
163
+ for sku_name, group in groups.items():
164
+ payg_total = sum(r.payg_compute_monthly for r in group)
165
+ rsv_total = sum(r.reserved_compute_monthly for r in group) # type: ignore[misc]
166
+ recs.append(
167
+ ReservationRec(
168
+ sku_name=sku_name,
169
+ vm_count=len(group),
170
+ payg_compute_monthly=payg_total,
171
+ rsv_compute_monthly=rsv_total,
172
+ )
173
+ )
174
+
175
+ recs.sort(key=lambda rec: rec.savings_monthly, reverse=True)
176
+ return recs
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Main orchestration
181
+ # ---------------------------------------------------------------------------
182
+
183
+ def main(argv: list[str] | None = None) -> int:
184
+ try:
185
+ args = parse_args(argv)
186
+
187
+ # --- Validate input file ---
188
+ rvtools_path = Path(args.rvtools)
189
+ if not rvtools_path.exists():
190
+ print(f"[ERROR] File not found: {rvtools_path}", file=sys.stderr)
191
+ raise SystemExit(1)
192
+
193
+ # --- --list: print topology and exit (no --region needed) ---
194
+ if args.list:
195
+ list_topology(rvtools_path)
196
+ return 0
197
+
198
+ # --- Parse RVTools (always include powered-off so we can count them) ---
199
+ print(f"[INFO] Reading {args.rvtools}...", file=sys.stderr)
200
+ try:
201
+ all_vms: list[VMRecord] = parse_rvtools(rvtools_path, include_powered_off=True)
202
+ except ValueError as exc:
203
+ print(f"[ERROR] Failed to parse RVTools file: {exc}", file=sys.stderr)
204
+ raise SystemExit(1)
205
+
206
+ # --- Apply datacenter / cluster filters ---
207
+ if args.datacenter or args.cluster:
208
+ before = len(all_vms)
209
+ all_vms = filter_vms(all_vms, args.datacenter, args.cluster)
210
+ after = len(all_vms)
211
+ filters = []
212
+ if args.datacenter:
213
+ filters.append(f"Datacenter={', '.join(args.datacenter)}")
214
+ if args.cluster:
215
+ filters.append(f"Cluster={', '.join(args.cluster)}")
216
+ print(
217
+ f"[INFO] Filter ({' | '.join(filters)}): {before} → {after} VM(s).",
218
+ file=sys.stderr,
219
+ )
220
+
221
+ if not all_vms:
222
+ print(
223
+ "[WARN] No VMs remain after filtering (use --list to see available names).",
224
+ file=sys.stderr,
225
+ )
226
+ return 0
227
+
228
+ powered_on_count = sum(1 for v in all_vms if v.is_powered_on)
229
+ powered_off_count = sum(1 for v in all_vms if not v.is_powered_on)
230
+ print(
231
+ f"[INFO] Found {len(all_vms)} VM(s) "
232
+ f"({powered_on_count} powered-on, {powered_off_count} powered-off).",
233
+ file=sys.stderr,
234
+ )
235
+
236
+ # Drop powered-off VMs from cost processing unless explicitly included
237
+ vms = all_vms if args.include_powered_off else [v for v in all_vms if v.is_powered_on]
238
+
239
+ # --- Pricing client ---
240
+ client = PricingClient(
241
+ region=args.region,
242
+ currency=args.currency,
243
+ no_cache=args.no_cache,
244
+ )
245
+ client.ensure_loaded(args.disk_type)
246
+
247
+ # --- Build results ---
248
+ results: list[VMResult] = []
249
+
250
+ for vm in vms:
251
+ # SKU matching
252
+ sku_match = find_vm_sku(vm.vcpus, vm.ram_gb)
253
+
254
+ # Disk tier mapping
255
+ disk_tier_counts: dict[str, int] = {}
256
+ disk_monthly = 0.0
257
+
258
+ for disk in vm.effective_disks:
259
+ tier = find_disk_tier(disk.capacity_gb, args.disk_type)
260
+ if tier is not None:
261
+ disk_tier_counts[tier.tier] = disk_tier_counts.get(tier.tier, 0) + 1
262
+ disk_monthly += client.get_disk_price(tier.tier, args.disk_type)
263
+
264
+ # Sort disk tiers by numeric size (P2 < P10, not alphabetically)
265
+ def _tier_num(name: str) -> int:
266
+ m = re.search(r"\d+", name)
267
+ return int(m.group()) if m else 0
268
+
269
+ disk_tiers_sorted = sorted(disk_tier_counts.items(), key=lambda x: _tier_num(x[0]))
270
+
271
+ # VM pricing
272
+ payg_compute_monthly = 0.0
273
+ reserved_compute_monthly: float | None = None
274
+
275
+ if sku_match is not None and vm.is_powered_on:
276
+ vm_prices = client.get_vm_prices(
277
+ sku_match.sku.name,
278
+ vm.os_type,
279
+ hybrid_benefit=not args.os_license_included,
280
+ )
281
+ payg_compute_monthly = vm_prices.payg_monthly
282
+ if args.reserved_term == "3-year":
283
+ reserved_compute_monthly = vm_prices.reserved_3yr_monthly
284
+ else:
285
+ reserved_compute_monthly = vm_prices.reserved_1yr_monthly
286
+
287
+ results.append(
288
+ VMResult(
289
+ vm=vm,
290
+ sku=sku_match.sku if sku_match else None,
291
+ sku_notes=sku_match.notes if sku_match else [],
292
+ disk_tiers=disk_tiers_sorted,
293
+ payg_compute_monthly=payg_compute_monthly,
294
+ reserved_compute_monthly=reserved_compute_monthly,
295
+ disk_monthly=disk_monthly,
296
+ )
297
+ )
298
+
299
+ # --- Build recommendations ---
300
+ recommendations: list[ReservationRec] = []
301
+ if args.pricing == "optimized":
302
+ recommendations = build_recommendations(results)
303
+
304
+ # --- Output ---
305
+ total_disk_monthly = sum(r.disk_monthly for r in results if r.vm.is_powered_on)
306
+
307
+ print_summary(
308
+ results=results,
309
+ recommendations=recommendations,
310
+ pricing_mode=args.pricing,
311
+ currency=args.currency,
312
+ region=args.region,
313
+ disk_type=args.disk_type,
314
+ reserved_term=args.reserved_term,
315
+ hybrid_benefit=not args.os_license_included,
316
+ support_plan=args.support,
317
+ )
318
+
319
+ if args.pricing == "optimized" and recommendations:
320
+ print("")
321
+ print_recommendations(recommendations, args.currency, total_disk_monthly,
322
+ args.reserved_term)
323
+
324
+ # --- Excel workbook ---
325
+ print(f"[INFO] Writing Excel workbook to {args.output}...", file=sys.stderr)
326
+ write_workbook(
327
+ results=results,
328
+ recommendations=recommendations,
329
+ output_path=args.output,
330
+ region=args.region,
331
+ currency=args.currency,
332
+ pricing_mode=args.pricing,
333
+ disk_type=args.disk_type,
334
+ rvtools_filename=Path(args.rvtools).name,
335
+ reserved_term=args.reserved_term,
336
+ hybrid_benefit=not args.os_license_included,
337
+ support_plan=args.support,
338
+ )
339
+ print(f"[INFO] Workbook saved: {args.output}", file=sys.stderr)
340
+
341
+ # --- CSV output ---
342
+ if args.csv:
343
+ write_csv(results, recommendations, args.csv, args.pricing, args.reserved_term)
344
+ print(f"[INFO] CSV written to {args.csv}", file=sys.stderr)
345
+
346
+ return 0
347
+
348
+ except SystemExit as exc:
349
+ code = exc.code
350
+ if isinstance(code, int):
351
+ return code
352
+ return 1
353
+ except KeyboardInterrupt:
354
+ print("\n[INFO] Interrupted.", file=sys.stderr)
355
+ return 130
356
+ except Exception as exc: # noqa: BLE001
357
+ print(f"[ERROR] {exc}", file=sys.stderr)
358
+ return 1