aztree 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.
aztree/__init__.py ADDED
@@ -0,0 +1,805 @@
1
+ """aztree: see where your Azure money goes, as a disktree-style treemap.
2
+
3
+ aztree # read your Azure costs (last 30 days) and open the map
4
+ aztree --demo # fake data, no Azure needed
5
+ python -m aztree # the same, from a clone without installing
6
+
7
+ Needs a logged-in Azure CLI (`az login`), or a token in AZURE_ACCESS_TOKEN. No other dependencies.
8
+ """
9
+ __version__ = "0.2.0"
10
+
11
+ import argparse
12
+ import datetime as dt
13
+ import json
14
+ import os
15
+ import random
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ import urllib.error
22
+ import urllib.parse
23
+ import urllib.request
24
+ import webbrowser
25
+ from collections import Counter
26
+ from importlib import resources
27
+ from pathlib import Path
28
+
29
+ def home():
30
+ """Where runs keep the page and the data: ~/.aztree/ (or AZTREE_HOME), never inside a repo by accident.
31
+ The data holds subscription IDs and costs."""
32
+ return Path(os.environ.get("AZTREE_HOME") or Path.home() / ".aztree")
33
+
34
+
35
+ def template():
36
+ """The page, shipped inside the package (works from a clone, a wheel or a zip)."""
37
+ return resources.files(__name__).joinpath("viewer.html").read_text(encoding="utf-8")
38
+
39
+ ARM = "https://management.azure.com"
40
+ API_VERSION = "2025-03-01" # Microsoft.CostManagement/query
41
+ MAX_TRIES = 8 # per request, when Cost Management throttles us
42
+ MAX_RESOURCE_PAGES = 10 # past this, the resource view reads one total per resource and period instead of daily rows
43
+ # What to sum, in order of preference. Some scopes reject USD columns, and older offers only know PreTaxCost.
44
+ AGGREGATIONS = [["Cost", "CostUSD"], ["Cost"], ["PreTaxCost", "PreTaxCostUSD"], ["PreTaxCost"]]
45
+ # A 400 about those columns names the aggregation or a column. "Cost" alone isn't enough: "Cost Management is not
46
+ # supported for this offer" and "not registered for Microsoft.CostManagement" are not about columns.
47
+ COLUMN_ERROR = re.compile(r"aggregation|column|costusd|pretaxcost|\bcost\b(?!\s*management)", re.I)
48
+
49
+ # Each view is outer box -> inner box. A query can group by two dimensions at most, so the
50
+ # subscription view is not queried: every query runs per subscription and call 1 feeds it too.
51
+ VIEWS = {
52
+ "service": ["ServiceName", "Meter"],
53
+ "subscription": ["SubscriptionId", "ServiceName"],
54
+ "region": ["ResourceLocation", "ServiceName"],
55
+ "resource": ["ResourceGroupName", "ResourceId"],
56
+ }
57
+
58
+
59
+ def die(msg):
60
+ print(f"\naztree: {msg}", file=sys.stderr)
61
+ sys.exit(1)
62
+
63
+
64
+ # ---------------------------------------------------------------- auth and subscriptions
65
+
66
+ def az_cli(args, run=subprocess.run, az_path=None):
67
+ """Run the Azure CLI and return stdout. `az` is az.cmd on Windows, so resolve the full path first."""
68
+ path = az_path or shutil.which("az")
69
+ if not path:
70
+ die("need the Azure CLI (https://aka.ms/azcli), or a token in AZURE_ACCESS_TOKEN.")
71
+ r = run([path, *args], capture_output=True, text=True)
72
+ if r.returncode:
73
+ err = r.stderr.strip()
74
+ hint = "\n -> Log in first: `az login`." if "login" in err.lower() or "expired" in err.lower() else ""
75
+ die(f"Azure CLI error: {err}{hint}")
76
+ return r.stdout
77
+
78
+
79
+ def get_token(env=os.environ, run=subprocess.run, az_path=None, tenant=None):
80
+ """A token for ARM. Tokens are per tenant, so ask for the tenant a subscription lives in."""
81
+ if env.get("AZURE_ACCESS_TOKEN"):
82
+ return env["AZURE_ACCESS_TOKEN"]
83
+ args = ["account", "get-access-token", "--resource", ARM + "/", "--query", "accessToken", "-o", "tsv"]
84
+ return az_cli(args + (["--tenant", tenant] if tenant else []), run=run, az_path=az_path).strip()
85
+
86
+
87
+ def cli_subscriptions(run=subprocess.run, az_path=None):
88
+ """Every subscription the Azure CLI knows, across every tenant you're logged in to."""
89
+ subs = json.loads(az_cli(["account", "list", "-o", "json"], run=run, az_path=az_path))
90
+ return [{"id": s["id"], "name": s["name"], "state": s["state"], "tenant": s.get("tenantId")} for s in subs]
91
+
92
+
93
+ def list_subscriptions(az):
94
+ """Every subscription a bare token can see, straight from ARM: only that token's tenant."""
95
+ subs, url = [], "/subscriptions?api-version=2022-12-01"
96
+ while url:
97
+ page = az.call("GET", url)
98
+ subs += [{"id": s["subscriptionId"], "name": s["displayName"], "state": s["state"], "tenant": s.get("tenantId")}
99
+ for s in page.get("value", [])]
100
+ url = page.get("nextLink")
101
+ return subs
102
+
103
+
104
+ def current_subscription(run=subprocess.run):
105
+ """The subscription `az account show` points at, or None without the CLI."""
106
+ if not shutil.which("az"):
107
+ return None
108
+ return json.loads(az_cli(["account", "show", "--query", "{id:id, name:name, tenant:tenantId}", "-o", "json"], run=run))
109
+
110
+
111
+ def sub_ref(s):
112
+ return {"id": s["id"], "name": s["name"], "tenant": s.get("tenant")}
113
+
114
+
115
+ def pick_subscriptions(available, wanted, all_, current):
116
+ """Choose which subscriptions to read: --all, --subscription (id or name), or the CLI's current one."""
117
+ if all_:
118
+ return [sub_ref(s) for s in available if s["state"] == "Enabled"]
119
+ if not wanted:
120
+ if not current:
121
+ die("no default subscription. Pass --subscription ID_OR_NAME or --all.")
122
+ return [sub_ref(current)]
123
+ picked = []
124
+ for w in wanted:
125
+ hits = [s for s in available if w.lower() in (s["id"].lower(), s["name"].lower())]
126
+ if not hits:
127
+ known = "\n ".join(f"{s['name']} ({s['id']})" for s in available) or "(none)"
128
+ die(f"no subscription matches '{w}'. Subscriptions you can read:\n {known}")
129
+ if len(hits) > 1:
130
+ ids = "\n ".join(s["id"] for s in hits)
131
+ die(f"{len(hits)} subscriptions are named '{w}'. Pass the one you mean by id:\n {ids}")
132
+ hit = hits[0]
133
+ if all(p["id"] != hit["id"] for p in picked):
134
+ picked.append(sub_ref(hit))
135
+ return picked
136
+
137
+
138
+ # ---------------------------------------------------------------- Azure REST client
139
+
140
+ class AzureError(Exception):
141
+ def __init__(self, status, message):
142
+ super().__init__(f"HTTP {status}: {message}")
143
+ self.status = status
144
+
145
+
146
+ class TooManyPages(Exception):
147
+ pass
148
+
149
+
150
+ def http_send(method, url, data, headers):
151
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
152
+ try:
153
+ with urllib.request.urlopen(req, timeout=120) as r:
154
+ return r.status, dict(r.headers), r.read()
155
+ except urllib.error.HTTPError as e:
156
+ return e.code, dict(e.headers), e.read()
157
+
158
+
159
+ def retry_after(headers):
160
+ """Cost Management throttles per scope, per client type and per QPU, each with its own
161
+ *-retry-after header. Wait for the longest one."""
162
+ waits = [int(v) for k, v in headers.items() if k.lower().endswith("retry-after") and str(v).strip().isdigit()]
163
+ return max(waits) if waits and max(waits) > 0 else 5
164
+
165
+
166
+ class Azure:
167
+ def __init__(self, token, send=http_send, sleep=time.sleep, verbose=False, log=print):
168
+ """`token` is a token, or a function tenant -> token for runs that span tenants."""
169
+ self.token, self.send, self.sleep, self.verbose, self.log = token, send, sleep, verbose, log
170
+ self.tokens = {}
171
+ self.requests = 0
172
+
173
+ def token_for(self, tenant):
174
+ if isinstance(self.token, str):
175
+ return self.token
176
+ if tenant not in self.tokens:
177
+ self.tokens[tenant] = self.token(tenant)
178
+ return self.tokens[tenant]
179
+
180
+ def call(self, method, url, body=None, tenant=None):
181
+ url = url if url.startswith("https://") else ARM + url
182
+ data = json.dumps(body).encode() if body is not None else None
183
+ headers = {"Authorization": "Bearer " + self.token_for(tenant), "Content-Type": "application/json"}
184
+ for attempt in range(1, MAX_TRIES + 1):
185
+ try:
186
+ status, resp_headers, raw = self.send(method, url, data, headers)
187
+ except OSError as e: # URLError, timeouts, resets: the network, not Azure, said no
188
+ reason = getattr(e, "reason", e)
189
+ if attempt == MAX_TRIES:
190
+ raise AzureError(0, f"network error: {reason}") from e
191
+ self.log(f" network error ({reason}); retrying in {5 * attempt}s ...")
192
+ self.sleep(5 * attempt)
193
+ continue
194
+ self.requests += 1
195
+ if self.verbose:
196
+ qpu = {k.lower(): v for k, v in resp_headers.items()}.get("x-ms-ratelimit-microsoft.costmanagement-qpu-consumed")
197
+ if qpu:
198
+ self.log(f" ({qpu} QPU consumed)")
199
+ if (status == 429 or status >= 500) and attempt < MAX_TRIES:
200
+ wait = retry_after(resp_headers)
201
+ self.log(f" Azure is throttling us (HTTP {status}); waiting {wait}s ...")
202
+ self.sleep(wait)
203
+ continue
204
+ if status >= 400:
205
+ raise AzureError(status, error_message(raw))
206
+ return json.loads(raw) if raw else {}
207
+
208
+
209
+ def error_message(raw):
210
+ try:
211
+ err = json.loads(raw)["error"]
212
+ return f"{err.get('code', '')}: {err.get('message', '')}".strip(": ")
213
+ except (ValueError, KeyError, TypeError):
214
+ return raw.decode(errors="replace")[:300] if isinstance(raw, bytes) else str(raw)[:300]
215
+
216
+
217
+ def explain(e):
218
+ hints = []
219
+ if e.status == 401:
220
+ hints.append("Your token is missing or expired. Run `az login` (or refresh AZURE_ACCESS_TOKEN).")
221
+ if e.status == 403:
222
+ hints.append("You need the Cost Management Reader (or Reader) role on the subscription or scope.")
223
+ if e.status == 429:
224
+ hints.append("Cost Management kept throttling. Wait a minute and try again, or read fewer subscriptions.")
225
+ if e.status == 0:
226
+ hints.append("Check your network connection and try again.")
227
+ return f"Azure error: {e}" + ("\n -> " + "\n -> ".join(hints) if hints else "")
228
+
229
+
230
+ def query(az, scope, start, end, groupings, metric, aggs=("Cost", "CostUSD"), max_pages=None, tenant=None,
231
+ granularity="Daily"):
232
+ """One Cost Management query. `start` and `end` are ISO dates, both inclusive. With granularity=None, Azure
233
+ sums each group over the whole period: one row per group instead of one per group per day.
234
+ Returns every row of every page as a dict keyed by column name."""
235
+ dataset = {
236
+ "aggregation": {f"total{a}": {"name": a, "function": "Sum"} for a in aggs},
237
+ "grouping": [{"type": "Dimension", "name": g} for g in groupings],
238
+ }
239
+ if granularity:
240
+ dataset["granularity"] = granularity
241
+ body = {
242
+ "type": metric,
243
+ "timeframe": "Custom",
244
+ "timePeriod": {"from": f"{start}T00:00:00Z", "to": f"{end}T23:59:59Z"},
245
+ "dataset": dataset,
246
+ }
247
+ url = f"{scope}/providers/Microsoft.CostManagement/query?api-version={API_VERSION}"
248
+ rows, pages = [], 0
249
+ while url:
250
+ pages += 1
251
+ if max_pages and pages > max_pages:
252
+ raise TooManyPages(f"more than {max_pages} pages")
253
+ props = az.call("POST", url, body, tenant=tenant).get("properties", {})
254
+ cols = [c["name"] for c in props.get("columns", [])]
255
+ if cols and aggs[0] not in cols:
256
+ raise AzureError(200, f"Cost Management answered without a {aggs[0]} column (columns: {', '.join(cols)})")
257
+ rows += [dict(zip(cols, r)) for r in props.get("rows", [])]
258
+ url = props.get("nextLink")
259
+ if getattr(az, "verbose", False):
260
+ az.log(f" ({pages} page{'s' if pages != 1 else ''}, {len(rows)} rows)")
261
+ return rows
262
+
263
+
264
+ # ---------------------------------------------------------------- reading costs
265
+
266
+ def subscription_target(sub):
267
+ return {"id": sub["id"], "name": sub["name"], "scope": f"/subscriptions/{sub['id']}", "tenant": sub.get("tenant")}
268
+
269
+
270
+ def scope_target(scope):
271
+ scope = "/" + scope.strip("/")
272
+ return {"id": scope, "name": scope, "scope": scope, "tenant": None}
273
+
274
+
275
+ def usage_day(v):
276
+ """UsageDate arrives as 20260930 (a number) or as '2026-09-30T00:00:00'. Return 'yyyymmdd'."""
277
+ s = str(v)
278
+ return s[:10].replace("-", "") if "-" in s else s[:8]
279
+
280
+
281
+ def group_key(target, rg, resource_id):
282
+ """The resource group's ARM id, or the subscription's for resources outside any group.
283
+ Keying on the id keeps two `rg-app` groups in different subscriptions apart."""
284
+ m = re.match(r"/subscriptions/[^/]+", resource_id or "", re.I)
285
+ sub = m.group(0).lower() if m else target["scope"].lower()
286
+ return f"{sub}/resourcegroups/{rg.lower()}" if rg else sub
287
+
288
+
289
+ def last_full_day(today=None):
290
+ """The day before yesterday. Azure takes 8-24 hours to post usage, so yesterday is still filling in
291
+ and would drag down every comparison, grower and pace figure."""
292
+ return (today or dt.date.today()) - dt.timedelta(2)
293
+
294
+
295
+ def fetch(az, targets, days, metric, advisor=True, log=print, today=None):
296
+ end = last_full_day(today)
297
+ start = end - dt.timedelta(2 * days - 1) # current window + previous window, for the "vs prev" deltas
298
+ dates = [(start + dt.timedelta(i)).isoformat() for i in range(2 * days)]
299
+ index = {d.replace("-", ""): i for i, d in enumerate(dates)}
300
+ raw = {v: [] for v in VIEWS} # (key, day index, cost, cost in USD), folded once the currency is known
301
+ names = {v: {} for v in VIEWS}
302
+ columns = {} # target id -> cost columns that target accepts, best first; every subscription starts at the top
303
+ subs, fallback = [], []
304
+ twins = Counter(t["name"] for t in targets) # "Pay-As-You-Go" twice needs the id to tell them apart
305
+ targets = [{**t, "name": f"{t['name']} ({t['id'][:8]})"} if twins[t["name"]] > 1 else t for t in targets]
306
+
307
+ def run(target, groupings, start=None, end=None, **kw):
308
+ aggs = columns.setdefault(target["id"], list(AGGREGATIONS))
309
+ while True:
310
+ try:
311
+ return query(az, target["scope"], start or dates[0], end or dates[-1], groupings, metric, aggs[0],
312
+ tenant=target.get("tenant"), **kw)
313
+ except AzureError as e:
314
+ # step down only when Azure objects to the cost columns; any other 400 is a real error
315
+ about_columns = COLUMN_ERROR.search(str(e))
316
+ if e.status != 400 or len(aggs) == 1 or not about_columns:
317
+ raise
318
+ aggs.pop(0)
319
+
320
+ def add(view, key, r, day=None):
321
+ """`day` is set for period totals, which have no UsageDate: they land on their period's first day."""
322
+ i = day if day is not None else index.get(usage_day(r.get("UsageDate")))
323
+ if i is not None:
324
+ cost, cost_usd = r.get("Cost", r.get("PreTaxCost")), r.get("CostUSD", r.get("PreTaxCostUSD"))
325
+ raw[view].append((key, i, cost or 0.0, cost_usd))
326
+
327
+ for t in targets:
328
+ log(f" {t['name']}: services ...")
329
+ currencies = Counter()
330
+ for r in run(t, VIEWS["service"]):
331
+ currencies[r.get("Currency")] += 1
332
+ add("service", (service_of(r), r.get("Meter") or "(no meter)"), r)
333
+ add("subscription", (t["id"], service_of(r)), r)
334
+ names["subscription"][t["id"]] = t["name"]
335
+
336
+ log(f" {t['name']}: resources ...")
337
+ try:
338
+ rows = [(r, None) for r in run(t, VIEWS["resource"], max_pages=MAX_RESOURCE_PAGES)]
339
+ except TooManyPages:
340
+ # too many resources x days: read one total per resource and period instead (two small queries)
341
+ log(f" {t['name']}: too many resources for daily detail, reading period totals instead")
342
+ prev = run(t, VIEWS["resource"], start=dates[0], end=dates[days - 1], granularity=None)
343
+ cur = run(t, VIEWS["resource"], start=dates[days], end=dates[-1], granularity=None)
344
+ rows = [(r, 0) for r in prev] + [(r, days) for r in cur]
345
+ fallback.append(t["name"])
346
+ for r, day in rows:
347
+ rg, rid = r.get("ResourceGroupName") or "", (r.get("ResourceId") or "").lower()
348
+ key = group_key(t, rg, rid)
349
+ label = rg or "(no resource group)"
350
+ names["resource"][key] = f"{label} · {t['name']}" if len(targets) > 1 else label
351
+ add("resource", (key, rid or "(no resource)"), r, day)
352
+
353
+ log(f" {t['name']}: regions ...")
354
+ for r in run(t, VIEWS["region"]):
355
+ add("region", ((r.get("ResourceLocation") or "").lower(), service_of(r)), r)
356
+ subs.append({"id": t["id"], "name": t["name"], "currency": next((c for c, _ in currencies.most_common() if c), None)})
357
+
358
+ recs, advisor_error = None, None
359
+ with_advisor = [t for t in targets if t["scope"].lower().startswith("/subscriptions/")] # Advisor is per subscription
360
+ if advisor and with_advisor:
361
+ recs = []
362
+ for t in with_advisor:
363
+ log(f" {t['name']}: Advisor ...")
364
+ try:
365
+ recs += advisor_recs(az, t)
366
+ except Exception as e: # Advisor needs Reader; cost data alone is still worth a page
367
+ # keep only the status: Azure's 403 text names the caller, and this lands in the AI export
368
+ advisor_error = f"HTTP {e.status}" if isinstance(e, AzureError) else type(e).__name__
369
+ log(f" {t['name']}: skipped Advisor ({e}). Reader on the subscription fixes access errors; --no-advisor hides this.")
370
+ recs.sort(key=lambda r: -(r["annual_savings"] or -1))
371
+
372
+ found = {s["currency"] for s in subs if s["currency"]}
373
+ # USD only if every row has a USD figure: a subscription may have answered in its billing currency alone
374
+ usd = len(found) > 1 and all(cost_usd is not None for entries in raw.values() for *_, cost_usd in entries)
375
+ if len(found) > 1 and not usd:
376
+ log(" warning: these subscriptions bill in different currencies and Azure won't convert them; totals mix currencies")
377
+ log(f" done: {az.requests} requests (Cost Management queries are free)")
378
+ return {
379
+ "days": dates, "split": days,
380
+ "views": {v: {"dims": dims, "names": names[v], "rows": fold(raw[v], len(dates), usd)} for v, dims in VIEWS.items()},
381
+ "currency": "USD" if usd or not found else min(found),
382
+ "subscriptions": subs, "resource_fallback": fallback,
383
+ "advisor": recs, "advisor_error": advisor_error, "demo": False,
384
+ }
385
+
386
+
387
+ def service_of(row):
388
+ return row.get("ServiceName") or "(no service)"
389
+
390
+
391
+ def fold(entries, n, usd):
392
+ """(key, day, cost, cost_usd) entries -> packed rows of daily totals, in one currency."""
393
+ rows = {}
394
+ for key, i, cost, cost_usd in entries:
395
+ amount = (cost_usd or 0.0) if usd else cost
396
+ if amount:
397
+ rows.setdefault(key, [0.0] * n)[i] += amount
398
+ return pack(rows)
399
+
400
+
401
+ def pack(rows):
402
+ out = [{"k": list(k), "d": [round(v, 4) for v in d]} for k, d in rows.items()]
403
+ # drop only noise: a charge in one period and its refund in the other sum to zero but still count in both
404
+ return [r for r in out if sum(abs(v) for v in r["d"]) >= 0.005]
405
+
406
+
407
+ # ---------------------------------------------------------------- demo data
408
+
409
+ P, S, D, X = (f"{c * 8}-{c * 4}-{c * 4}-{c * 4}-{c * 12}" for c in "1234")
410
+ DEMO_SUBS = {P: "acme-prod", S: "acme-staging", D: "acme-data", X: "sandbox-dev"}
411
+ VM, WEB, SQL, STG = ("microsoft.compute/virtualmachines", "microsoft.web/serverfarms", "microsoft.sql/servers",
412
+ "microsoft.storage/storageaccounts")
413
+
414
+ # service, meter, $/day, growth over the 60 days, [(subscription, region, resource group, provider/type/name, share)]
415
+ DEMO = [
416
+ ("Virtual Machines", "D4s v5", 58, 0.05, [(P, "us east", "rg-app-prod", f"{VM}/vm-app-01", .5),
417
+ (P, "us east", "rg-app-prod", f"{VM}/vm-app-02", .5)]),
418
+ ("Virtual Machines", "D8s v5", 44, 0.1, [(P, "us east", "mc_rg-aks-prod_aks-prod_eastus",
419
+ "microsoft.compute/virtualmachinescalesets/aks-nodepool1-vmss", 1)]),
420
+ ("Virtual Machines", "E8s v5", 38, -0.7, [(D, "eu west", "rg-etl", f"{VM}/vm-etl-worker", 1)]), # scaled down: a drop
421
+ ("Virtual Machines", "NC6s v3", 22, 0.3, [(D, "us east", "rg-ml", f"{VM}/vm-gpu-train", 1)]),
422
+ ("Virtual Machines", "D2 v2", 9, 0, [(S, "us east", "rg-legacy", f"{VM}/vm-legacy-ftp", 1)]),
423
+ ("Azure Kubernetes Service", "Standard Uptime SLA", 2.4, 0, [(P, "us east", "rg-aks-prod",
424
+ "microsoft.containerservice/managedclusters/aks-prod", 1)]),
425
+ ("SQL Database", "vCore", 41, 0, [(P, "us east", "rg-data-prod", f"{SQL}/sql-prod/databases/orders", .7),
426
+ (S, "us east", "rg-data-staging", f"{SQL}/sql-staging/databases/orders", .3)]),
427
+ ("SQL Database", "RA-GRS Data Stored", 6, 0.03, [(P, "us east", "rg-data-prod", f"{SQL}/sql-prod/databases/orders", 1)]),
428
+ ("Azure Cosmos DB", "100 RU/s", 19, 1.2, [(P, "us east", "rg-data-prod",
429
+ "microsoft.documentdb/databaseaccounts/cosmos-catalog", 1)]),
430
+ ("Azure Cosmos DB", "Data Stored", 3, 0.05, [(P, "us east", "rg-data-prod",
431
+ "microsoft.documentdb/databaseaccounts/cosmos-catalog", 1)]),
432
+ ("Redis Cache", "C1 Cache Instance", 3.3, 0, [(P, "us east", "rg-app-prod", "microsoft.cache/redis/redis-sessions", 1)]),
433
+ ("Storage", "Hot LRS Data Stored", 16, 0.04, [(D, "eu west", "rg-lake", f"{STG}/stlakeraw", .8),
434
+ (P, "us east", "rg-app-prod", f"{STG}/stappassets", .2)]),
435
+ ("Storage", "Hot LRS Write Operations", 3, 0.1, [(D, "eu west", "rg-lake", f"{STG}/stlakeraw", 1)]),
436
+ ("Storage", "P30 LRS Disk", 11, 0, [(P, "us east", "rg-app-prod", "microsoft.compute/disks/vm-app-01-data", 1)]),
437
+ ("Storage", "LRS Snapshots", 7, 0.15, [(P, "us east", "rg-backup", "microsoft.compute/snapshots/snap-vm-app-01-2025", 1)]),
438
+ ("Log Analytics", "Analytics Logs Data Ingestion", 34, 1.1, [
439
+ (P, "us east", "rg-monitoring", "microsoft.operationalinsights/workspaces/log-prod", .85),
440
+ (S, "us east", "rg-monitoring", "microsoft.operationalinsights/workspaces/log-staging", .15)]),
441
+ ("Azure Monitor", "Standard Web Test Execution", 1.5, 0, [(P, "us east", "rg-monitoring",
442
+ "microsoft.insights/webtests/ping-home", 1)]),
443
+ ("Microsoft Defender for Cloud", "Standard Node", 6, 0, [(P, "us east", "", "microsoft.security/pricings/virtualmachines", 1)]),
444
+ ("Azure App Service", "P1 v3 App", 14, 0, [(P, "us east", "rg-app-prod", f"{WEB}/asp-web-prod", 1)]),
445
+ ("Azure App Service", "P1 v2 App", 5, 0, [(S, "us east", "rg-app-staging", f"{WEB}/asp-web-staging", 1)]),
446
+ ("Functions", "Premium vCPU Duration", 8, 0.1, [(P, "us east", "rg-app-prod", f"{WEB}/asp-func-prod", 1)]),
447
+ ("Bandwidth", "Standard Data Transfer Out", 12, 0.1, [(P, "us east", "rg-app-prod", f"{VM}/vm-app-01", 1)]),
448
+ ("Bandwidth", "Inter Continent Data Transfer Out - NAM or EU To Any", 4, 0.2, [(D, "eu west", "rg-etl", f"{VM}/vm-etl-worker", 1)]),
449
+ ("NAT Gateway", "Standard Data Processed", 6, 0.3, [(P, "us east", "rg-network", "microsoft.network/natgateways/ng-prod", 1)]),
450
+ ("NAT Gateway", "Standard Gateway", 1.1, 0, [(P, "us east", "rg-network", "microsoft.network/natgateways/ng-prod", 1)]),
451
+ ("Virtual Network", "Standard Private Endpoint", 1.8, 0, [(P, "us east", "rg-network", "microsoft.network/privateendpoints/pe-sql-prod", 1)]),
452
+ ("Virtual Network", "Standard IPv4 Static Public IP", 1.2, 0, [(P, "us east", "rg-network", "microsoft.network/publicipaddresses/pip-ng-prod", 1)]),
453
+ ("Virtual Network", "Basic IPv4 Static Public IP", 1.4, 0, [(X, "us west 2", "rg-sandbox", "microsoft.network/publicipaddresses/pip-old-test", 1)]),
454
+ ("Azure Front Door Service", "Standard Base Fees", 1.2, 0, [(P, "global", "rg-edge", "microsoft.cdn/profiles/afd-web", 1)]),
455
+ ("Azure Front Door Service", "Standard Data Transfer Out", 4, 0.05, [(P, "global", "rg-edge", "microsoft.cdn/profiles/afd-web", 1)]),
456
+ ("Foundry Models", "gpt 4.1 Inp glbl Tokens", 6, 3, [(X, "us east 2", "rg-ai-sandbox", "microsoft.cognitiveservices/accounts/oai-sandbox", 1)]),
457
+ ("Foundry Models", "gpt 4.1 Outp glbl Tokens", 9, 3, [(X, "us east 2", "rg-ai-sandbox", "microsoft.cognitiveservices/accounts/oai-sandbox", 1)]),
458
+ ("Azure Data Factory v2", "Cloud Data Movement", 5, 0.2, [(D, "eu west", "rg-etl", "microsoft.datafactory/factories/adf-etl", 1)]),
459
+ ("Event Hubs", "Standard Throughput Unit", 3.6, 0, [(D, "eu west", "rg-etl", "microsoft.eventhub/namespaces/evh-ingest", 1)]),
460
+ ("Container Registry", "Standard Registry Unit", 0.67, 0, [(P, "us east", "rg-app-prod", "microsoft.containerregistry/registries/acmecr", 1)]),
461
+ ("Key Vault", "Operations", 0.3, 0, [(P, "us east", "rg-app-prod", "microsoft.keyvault/vaults/kv-app-prod", 1)]),
462
+ ]
463
+
464
+
465
+ def demo_rid(sub, rg, path):
466
+ return f"/subscriptions/{sub}/resourcegroups/{rg}/providers/{path}" if rg else f"/subscriptions/{sub}/providers/{path}"
467
+
468
+
469
+ def demo_advisor():
470
+ def rec(problem, sub, path, savings, sku=None, term=None, rg=None):
471
+ resource = demo_rid(sub, rg, path) if path else f"/subscriptions/{sub}"
472
+ return {"problem": problem, "solution": problem, "impact": "High", "resource": resource,
473
+ "resource_name": resource.rsplit("/", 1)[-1] if path else DEMO_SUBS[sub], "resource_type": None,
474
+ "sku": sku, "term": term, "annual_savings": savings, "currency": "USD", "subscription": DEMO_SUBS[sub]}
475
+
476
+ return [
477
+ rec("Consider SQL PaaS DB reserved instance to save over the pay-as-you-go costs", P, None, 4380.0,
478
+ "SQL DB Single/Elastic Pool - General Purpose - Gen 5", "P3Y"),
479
+ rec("Consider purchasing a savings plan to unlock lower prices", P, None, 3120.0, "Compute_Savings_Plan", "P1Y"),
480
+ rec("Right-size or shutdown underutilized virtual machines", D, f"{VM}/vm-etl-worker", 2150.0, "Standard_E4s_v5", rg="rg-etl"),
481
+ rec("Consider Cosmos DB reserved instance to save over the pay-as-you-go costs", P, None, 1020.0, "100 RU/s", "P1Y"),
482
+ rec("Right-size or shutdown underutilized virtual machines", S, f"{VM}/vm-legacy-ftp", 640.0, "Standard_B2s", rg="rg-legacy"),
483
+ ]
484
+
485
+
486
+ def demo(days, today=None):
487
+ rnd = random.Random(7)
488
+ end = last_full_day(today)
489
+ n = 2 * days
490
+ dates = [(end - dt.timedelta(n - 1 - i)).isoformat() for i in range(n)]
491
+ weekend = [dt.date.fromisoformat(d).weekday() >= 5 for d in dates]
492
+ rows = {v: {} for v in VIEWS}
493
+ names = {v: {} for v in VIEWS}
494
+ names["subscription"] = dict(DEMO_SUBS)
495
+ for service, meter, per_day, growth, spots in DEMO:
496
+ bursty = any(w in meter for w in ("Tokens", "Data Transfer", "Ingestion", "Operations", "Processed", "Duration"))
497
+ for sub, region, rg, path, share in spots:
498
+ daily = []
499
+ for i in range(n):
500
+ m = (1 + growth * i / (n - 1)) / (1 + growth / 2) # keep the average near per_day
501
+ m *= 0.8 if bursty and weekend[i] else 1
502
+ daily.append(max(0.0, per_day * share * m * rnd.gauss(1, 0.06)))
503
+ rid = demo_rid(sub, rg, path)
504
+ gkey = group_key({"scope": f"/subscriptions/{sub}"}, rg, rid)
505
+ names["resource"][gkey] = f"{rg or '(no resource group)'} · {DEMO_SUBS[sub]}"
506
+ for view, key in (("service", (service, meter)), ("subscription", (sub, service)),
507
+ ("region", (region, service)), ("resource", (gkey, rid))):
508
+ acc = rows[view].setdefault(key, [0.0] * n)
509
+ for i, v in enumerate(daily):
510
+ acc[i] += v
511
+ # a cancelled Cosmos DB reservation refunded as one negative day: no box can show it, so the header notes it
512
+ rid = demo_rid(P, "rg-data-prod", "microsoft.documentdb/databaseaccounts/cosmos-catalog")
513
+ gkey = group_key({"scope": f"/subscriptions/{P}"}, "rg-data-prod", rid)
514
+ for view, key in (("service", ("Azure Cosmos DB", "Reserved 100 RU/s")), ("subscription", (P, "Azure Cosmos DB")),
515
+ ("region", ("us east", "Azure Cosmos DB")), ("resource", (gkey, rid))):
516
+ rows[view].setdefault(key, [0.0] * n)[max(days, n - 6)] -= 150.0
517
+ return {
518
+ "days": dates, "split": days,
519
+ "views": {v: {"dims": VIEWS[v], "names": names[v], "rows": pack(rows[v])} for v in VIEWS},
520
+ "currency": "USD",
521
+ "subscriptions": [{"id": k, "name": v, "currency": "USD"} for k, v in DEMO_SUBS.items()],
522
+ "resource_fallback": [], "advisor": demo_advisor(), "advisor_error": None, "demo": True,
523
+ }
524
+
525
+
526
+ # ---------------------------------------------------------------- worth a look
527
+
528
+ # Known money pits: (service pattern, meter pattern, why). First match wins. Shared with the viewer's
529
+ # "worth a look" panel, so the patterns must mean the same in Python and JavaScript.
530
+ OLD_VM_SIZES = r"^(?:(?:Basic[ ._])?A\d+m?(?: v2)?|DS?\d+(?: v2)?|F\d+s?)(?:$|/| Low Priority| Spot)"
531
+ PITS = [
532
+ (r"^(?:Log Analytics|Azure Monitor)$", r"Data Ingestion",
533
+ "Log Analytics ingestion — trim noisy tables, use Basic logs, or a commitment tier past 100 GB/day"),
534
+ (r"^Bandwidth$", r"Data Transfer Out", "data transfer out — keep traffic in one region, cache at the edge"),
535
+ (r"^NAT Gateway$", r"Data Processed", "NAT data processing — service or private endpoints for Storage, SQL and ACR skip it"),
536
+ (r"^Azure Firewall$", r"Data Processed|Premium", "Azure Firewall — processing and Premium add up; route only what needs inspection"),
537
+ (r"^Virtual Network$", r"^Basic .*Public IP", "Basic public IPs — the Basic SKU retired on 30 Sep 2025, move to Standard"),
538
+ (r"^Virtual Network$", r"Public IP|IP Address Hours", "public IPs — billed per hour each; release the ones nothing uses"),
539
+ (r"^Storage$", r"Snapshot", "disk snapshots — prune old ones; incremental snapshots on Standard storage cost less"),
540
+ (r"^Virtual Machines$", OLD_VM_SIZES, "previous-gen VM sizes — current generations cost less for the same work"),
541
+ (r"^Azure App Service$", r"^P\d+ ?v2 App", "Premium v2 App Service plans — Premium v3 gives more per dollar and can be reserved"),
542
+ (r"", r"Extended Security Update", "Extended Security Updates — upgrade the OS or SQL version to stop paying for them"),
543
+ ]
544
+
545
+
546
+ def pit(service, meter):
547
+ return next((why for s, m, why in PITS if re.search(s, service) and re.search(m, meter)), None)
548
+
549
+
550
+ def advisor_recs(az, target):
551
+ """Azure Advisor's cost recommendations for one subscription, one per (kind, resource, SKU).
552
+ Advisor lists each reservation once per term and look-back period; keep the biggest saving."""
553
+ flt = urllib.parse.quote("Category eq 'Cost'")
554
+ url = f"{target['scope']}/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&$filter={flt}"
555
+ best = {}
556
+ while url:
557
+ page = az.call("GET", url, tenant=target.get("tenant"))
558
+ for item in page.get("value", []):
559
+ key, rec = advisor_rec(item.get("properties", {}), target)
560
+ if key not in best or (rec["annual_savings"] or 0) > (best[key]["annual_savings"] or 0):
561
+ best[key] = rec
562
+ url = page.get("nextLink")
563
+ return sorted(best.values(), key=lambda r: -(r["annual_savings"] or -1))
564
+
565
+
566
+ def advisor_rec(p, target):
567
+ ext = p.get("extendedProperties") or {}
568
+ short = p.get("shortDescription") or {}
569
+ resource = ((p.get("resourceMetadata") or {}).get("resourceId") or "").lower()
570
+ savings = ext.get("annualSavingsAmount")
571
+ name = p.get("impactedValue") or resource.rsplit("/", 1)[-1]
572
+ rec = {
573
+ "problem": short.get("problem", ""),
574
+ "solution": short.get("solution", ""),
575
+ "impact": p.get("impact"),
576
+ "resource": resource,
577
+ "resource_name": target["name"] if name.lower() == target["id"].lower() else name,
578
+ "resource_type": p.get("impactedField"),
579
+ "sku": ext.get("displaySKU") or ext.get("sku") or ext.get("targetSku"),
580
+ "term": ext.get("term"),
581
+ "annual_savings": float(savings) if savings not in (None, "") else None,
582
+ "currency": ext.get("savingsCurrency"),
583
+ "subscription": target["name"],
584
+ }
585
+ return (p.get("recommendationTypeId"), resource, rec["sku"]), rec
586
+
587
+
588
+ # ---------------------------------------------------------------- AI export
589
+
590
+ AI_INSTRUCTIONS = (
591
+ "This is an Azure cost breakdown exported by aztree. Amounts are in `currency`, for the cost type in `metric` "
592
+ "(ActualCost books reservation and savings plan purchases on the day they were bought; AmortizedCost spreads them "
593
+ "over the term). `current` is the most recent period and `previous` is the equally long period before it. "
594
+ "Line items are Azure meters grouped by service; `totals.credits_and_refunds` is the part of `current` that comes "
595
+ "from negative line items (credits, refunds). `flags` are known cost traps matched on meter names. `advisor` holds "
596
+ "Azure Advisor's cost recommendations, one per kind, resource and SKU, with the largest annual saving Advisor "
597
+ "reported; recommendations that cover the same usage (a reservation and a savings plan, a 1-year and a 3-year term) "
598
+ "are alternatives, not additive. "
599
+ "Please: 1) explain what drives the cost, 2) explain notable changes vs the previous period, "
600
+ "3) suggest concrete savings, each with an estimated monthly saving and how to verify it. "
601
+ "Levers to consider: reservations and savings plans for steady compute and databases; Azure Hybrid Benefit for "
602
+ "Windows Server and SQL Server licenses already owned; dev/test pricing for non-production subscriptions; "
603
+ "right-sizing and auto-shutdown for VMs and App Service plans; blob access tiers (cool, cold, archive) and lifecycle "
604
+ "rules; Log Analytics commitment tier pricing, Basic logs and shorter table retention; and keeping data transfer "
605
+ "inside one region."
606
+ )
607
+ TOP_RESOURCES = 20 # per resource group in the export; the rest are summed
608
+
609
+
610
+ def summarize(data):
611
+ """Turn the raw daily data into a compact JSON an AI agent can reason about."""
612
+ days, split = data["days"], data["split"]
613
+ n = len(days) - split
614
+
615
+ def money(v):
616
+ return round(v, 2)
617
+
618
+ def entry(cur, prev):
619
+ return {
620
+ "current": money(cur), "previous": money(prev), "change": money(cur - prev),
621
+ "change_pct": round(100 * (cur - prev) / prev, 1) if prev >= 0.01 else None,
622
+ "share_pct": round(100 * cur / grand, 2) if grand else 0,
623
+ }
624
+
625
+ def keep(cur, prev):
626
+ return abs(cur) >= 0.01 or abs(prev) >= 0.01
627
+
628
+ def grouped(view):
629
+ out = {}
630
+ for r in data["views"][view]["rows"]:
631
+ cur, prev = sum(r["d"][split:]), sum(r["d"][:split])
632
+ g = out.setdefault(r["k"][0], {"cur": 0, "prev": 0, "items": {}})
633
+ g["cur"] += cur
634
+ g["prev"] += prev
635
+ it = g["items"].setdefault(r["k"][1], [0, 0])
636
+ it[0] += cur
637
+ it[1] += prev
638
+ return sorted(out.items(), key=lambda kv: -kv[1]["cur"])
639
+
640
+ def breakdown(view, key_name, child_name, children="services", label=None, limit=None):
641
+ names = data["views"][view].get("names", {})
642
+ out = []
643
+ for k, g in grouped(view):
644
+ if not keep(g["cur"], g["prev"]):
645
+ continue
646
+ row = {key_name: label(k, names) if label else k}
647
+ if label:
648
+ row["id"] = k
649
+ elif names.get(k):
650
+ row["name"] = names[k]
651
+ row.update(entry(g["cur"], g["prev"]))
652
+ kids = sorted(({child_name: c, **entry(cc, p)} for c, (cc, p) in g["items"].items() if keep(cc, p)),
653
+ key=lambda x: -x["current"])
654
+ row[children] = kids[:limit]
655
+ if limit and len(kids) > limit:
656
+ row["other_" + children] = {"count": len(kids) - limit, "current": money(sum(c["current"] for c in kids[limit:]))}
657
+ out.append(row)
658
+ return out
659
+
660
+ services = grouped("service")
661
+ grand = sum(g["cur"] for _, g in services)
662
+ grand_prev = sum(g["prev"] for _, g in services)
663
+
664
+ line_items = []
665
+ for svc, g in services:
666
+ for meter, (cur, prev) in g["items"].items():
667
+ if keep(cur, prev):
668
+ line_items.append({"service": svc, "meter": meter, **entry(cur, prev)})
669
+ line_items.sort(key=lambda x: -x["current"])
670
+
671
+ growers = [x for x in line_items
672
+ if x["change"] >= max(1, grand * 0.005) and (x["change_pct"] is None or x["change_pct"] > 20)]
673
+ growers = sorted(growers, key=lambda x: -x["change"])[:10]
674
+ drops = [x for x in line_items # a refund (negative now) is a credit, not a saving: it goes in credits_and_refunds
675
+ if x["current"] > -0.01 and x["change"] <= -max(1, grand * 0.005) and (x["change_pct"] or 0) < -20]
676
+ drops = sorted(drops, key=lambda x: x["change"])[:10]
677
+ flags = []
678
+ for x in line_items:
679
+ why = pit(x["service"], x["meter"])
680
+ if why and x["current"] >= grand * 0.002:
681
+ flags.append({"service": x["service"], "meter": x["meter"], "current": x["current"], "reason": why})
682
+
683
+ daily = [0.0] * len(days)
684
+ for r in data["views"]["service"]["rows"]:
685
+ for i, v in enumerate(r["d"]):
686
+ daily[i] += v
687
+
688
+ return {
689
+ "tool": "aztree",
690
+ "instructions_for_ai": AI_INSTRUCTIONS,
691
+ "generated": data.get("generated"),
692
+ "metric": data.get("metric", "ActualCost"),
693
+ "currency": data.get("currency", "USD"),
694
+ "subscriptions": data.get("subscriptions", []),
695
+ "demo_data": bool(data.get("demo")),
696
+ "period": {
697
+ "current": {"start": days[split], "end": days[-1], "days": n},
698
+ "previous": {"start": days[0], "end": days[split - 1], "days": split},
699
+ },
700
+ "totals": {**entry(grand, grand_prev), "daily_avg": money(grand / n), "monthly_pace": money(grand / n * 30.4),
701
+ "credits_and_refunds": money(sum(min(x["current"], 0) for x in line_items))},
702
+ "by_service": [{"service": k, **entry(g["cur"], g["prev"])} for k, g in services if keep(g["cur"], g["prev"])],
703
+ "by_subscription": breakdown("subscription", "subscription_id", "service"),
704
+ "by_region": breakdown("region", "region", "service"),
705
+ "by_resource_group": breakdown("resource", "resource_group", "resource_id", "resources",
706
+ label=lambda k, names: names.get(k, k), limit=TOP_RESOURCES),
707
+ "top_growers": growers,
708
+ "top_drops": drops,
709
+ "flags": flags,
710
+ "advisor": data.get("advisor"),
711
+ "advisor_error": data.get("advisor_error"),
712
+ "line_items": line_items,
713
+ "daily_totals": [{"date": d, "cost": money(v)} for d, v in zip(days, daily)],
714
+ }
715
+
716
+
717
+ def export(data, path):
718
+ path.write_text(json.dumps(summarize(data), indent=2, ensure_ascii=False), encoding="utf-8")
719
+ return path
720
+
721
+
722
+ # ---------------------------------------------------------------- output
723
+
724
+ def render(data, out):
725
+ html = template()
726
+ page = {**data, "pits": PITS, "export": summarize(data)}
727
+ # < keeps names like "</script>" or "<!--" from ending the script block early
728
+ blob = json.dumps(page, separators=(",", ":"), ensure_ascii=False).replace("<", "\\u003c")
729
+ out.write_text(html.replace("__AZTREE_DATA__", blob), encoding="utf-8")
730
+
731
+
732
+ def resolve_targets(args, az, cli_list=None):
733
+ """`cli_list` lists subscriptions through the Azure CLI (every tenant); without it, ARM lists the token's tenant."""
734
+ if args.scope:
735
+ return [scope_target(args.scope)]
736
+ current = None if (args.all or args.subscription) else current_subscription()
737
+ available = cli_list() if cli_list else list_subscriptions(az)
738
+ return [subscription_target(s) for s in pick_subscriptions(available, args.subscription, args.all, current)]
739
+
740
+
741
+ def main(argv=None):
742
+ for stream in (sys.stdout, sys.stderr): # subscription names can hold characters a Windows pipe can't encode
743
+ if hasattr(stream, "reconfigure"):
744
+ stream.reconfigure(errors="replace")
745
+ saved = home() / "aztree-data.json"
746
+ ap = argparse.ArgumentParser(prog="aztree", description="See where your Azure money goes, as a treemap.")
747
+ ap.add_argument("--version", action="version", version=f"aztree {__version__}")
748
+ ap.add_argument("--demo", action="store_true", help="use fake data (no Azure access needed)")
749
+ who = ap.add_mutually_exclusive_group()
750
+ who.add_argument("--subscription", action="append", default=[], metavar="ID_OR_NAME",
751
+ help="subscription to read, repeat for more (default: the Azure CLI's current one)")
752
+ who.add_argument("--all", action="store_true", help="read every enabled subscription you can see")
753
+ who.add_argument("--scope", help="any Cost Management scope, e.g. a billing account (not tested yet)")
754
+ ap.add_argument("--days", type=int, default=30, help="period to show, compared with the period before it (default 30)")
755
+ ap.add_argument("--metric", default="ActualCost", choices=["ActualCost", "AmortizedCost"],
756
+ help="AmortizedCost spreads reservation and savings plan purchases over their term")
757
+ ap.add_argument("--no-advisor", action="store_true", help="skip Azure Advisor's cost recommendations")
758
+ ap.add_argument("--from", dest="source", nargs="?", const=str(saved), metavar="JSON",
759
+ help="reopen saved data without calling Azure (default: the last run)")
760
+ ap.add_argument("--out", default=str(home() / "aztree.html"), help="where to write the page (default ~/.aztree/aztree.html)")
761
+ ap.add_argument("--no-open", action="store_true", help="don't open the browser")
762
+ ap.add_argument("--export", nargs="?", const=str(home() / "aztree-export.json"), metavar="FILE",
763
+ help="write a summary JSON for an AI agent (default ~/.aztree/aztree-export.json) instead of the page")
764
+ ap.add_argument("--verbose", action="store_true", help="print pages, rows and query units per request")
765
+ args = ap.parse_args(argv)
766
+
767
+ if not 1 <= args.days <= 180:
768
+ die("--days must be between 1 and 180 (aztree reads two periods, and a query spans a year at most).")
769
+
770
+ if args.source:
771
+ source = Path(args.source)
772
+ if not source.exists():
773
+ die(f"no saved data at {source}. Read your costs once with `aztree` first (--demo runs aren't saved).")
774
+ data = json.loads(source.read_text(encoding="utf-8"))
775
+ elif args.demo:
776
+ data = demo(args.days)
777
+ data.update(generated=dt.datetime.now().strftime("%Y-%m-%d %H:%M"), metric=args.metric)
778
+ else:
779
+ try:
780
+ az = Azure(lambda tenant: get_token(tenant=tenant), verbose=args.verbose)
781
+ cli = shutil.which("az") and not os.environ.get("AZURE_ACCESS_TOKEN")
782
+ targets = resolve_targets(args, az, cli_list=cli_subscriptions if cli else None)
783
+ who_ = targets[0]["name"] if len(targets) == 1 else f"{len(targets)} subscriptions"
784
+ print(f"aztree: reading {who_}, last {args.days} days (+{args.days} before, for comparison)")
785
+ data = fetch(az, targets, args.days, args.metric, advisor=not args.no_advisor)
786
+ except AzureError as e:
787
+ die(explain(e))
788
+ data.update(generated=dt.datetime.now().strftime("%Y-%m-%d %H:%M"), metric=args.metric)
789
+ saved.parent.mkdir(parents=True, exist_ok=True)
790
+ saved.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
791
+ print(f"aztree: saved the data to {saved} (reopen it with: aztree --from)")
792
+
793
+ if args.export:
794
+ path = Path(args.export).resolve()
795
+ path.parent.mkdir(parents=True, exist_ok=True)
796
+ export(data, path)
797
+ print(f"aztree: wrote {path} (give this file to your AI agent)")
798
+ return
799
+
800
+ out = Path(args.out).resolve()
801
+ out.parent.mkdir(parents=True, exist_ok=True)
802
+ render(data, out)
803
+ print(f"aztree: wrote {out}")
804
+ if not args.no_open:
805
+ webbrowser.open(out.as_uri())