cloudwright-ai 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.
Files changed (81) hide show
  1. cloudwright/__init__.py +100 -0
  2. cloudwright/adapters/__init__.py +79 -0
  3. cloudwright/adapters/aws.py +314 -0
  4. cloudwright/adapters/azure.py +274 -0
  5. cloudwright/adapters/gcp.py +305 -0
  6. cloudwright/analyzer.py +180 -0
  7. cloudwright/architect.py +603 -0
  8. cloudwright/catalog/__init__.py +26 -0
  9. cloudwright/catalog/formula.py +257 -0
  10. cloudwright/catalog/refresh.py +248 -0
  11. cloudwright/catalog/store.py +672 -0
  12. cloudwright/cost.py +281 -0
  13. cloudwright/data/catalog.db +0 -0
  14. cloudwright/data/registry/analytics.yaml +32 -0
  15. cloudwright/data/registry/cache.yaml +64 -0
  16. cloudwright/data/registry/compute.yaml +78 -0
  17. cloudwright/data/registry/containers.yaml +116 -0
  18. cloudwright/data/registry/database_nosql.yaml +33 -0
  19. cloudwright/data/registry/database_relational.yaml +96 -0
  20. cloudwright/data/registry/messaging.yaml +47 -0
  21. cloudwright/data/registry/ml.yaml +32 -0
  22. cloudwright/data/registry/networking_api.yaml +31 -0
  23. cloudwright/data/registry/networking_cdn.yaml +29 -0
  24. cloudwright/data/registry/networking_dns.yaml +31 -0
  25. cloudwright/data/registry/networking_lb.yaml +77 -0
  26. cloudwright/data/registry/orchestration.yaml +32 -0
  27. cloudwright/data/registry/security_auth.yaml +29 -0
  28. cloudwright/data/registry/security_waf.yaml +31 -0
  29. cloudwright/data/registry/serverless.yaml +74 -0
  30. cloudwright/data/registry/storage_block.yaml +31 -0
  31. cloudwright/data/registry/storage_object.yaml +71 -0
  32. cloudwright/data/registry/streaming.yaml +32 -0
  33. cloudwright/data/templates/_index.yaml +99 -0
  34. cloudwright/data/templates/azure-microservices.yaml +81 -0
  35. cloudwright/data/templates/azure-serverless-api.yaml +49 -0
  36. cloudwright/data/templates/azure-three-tier-web.yaml +59 -0
  37. cloudwright/data/templates/batch-processing.yaml +103 -0
  38. cloudwright/data/templates/data-lake.yaml +91 -0
  39. cloudwright/data/templates/event-driven.yaml +89 -0
  40. cloudwright/data/templates/gcp-microservices.yaml +90 -0
  41. cloudwright/data/templates/gcp-serverless-api.yaml +48 -0
  42. cloudwright/data/templates/gcp-three-tier-web.yaml +59 -0
  43. cloudwright/data/templates/microservices.yaml +131 -0
  44. cloudwright/data/templates/ml_pipeline.yaml +60 -0
  45. cloudwright/data/templates/serverless_api.yaml +60 -0
  46. cloudwright/data/templates/static-site.yaml +92 -0
  47. cloudwright/data/templates/three_tier_web.yaml +59 -0
  48. cloudwright/differ.py +237 -0
  49. cloudwright/drift.py +90 -0
  50. cloudwright/evolution.py +63 -0
  51. cloudwright/exporter/__init__.py +105 -0
  52. cloudwright/exporter/aibom.py +104 -0
  53. cloudwright/exporter/cloudformation.py +218 -0
  54. cloudwright/exporter/compliance_report.py +161 -0
  55. cloudwright/exporter/d2.py +82 -0
  56. cloudwright/exporter/mermaid.py +69 -0
  57. cloudwright/exporter/sbom.py +68 -0
  58. cloudwright/exporter/terraform.py +1003 -0
  59. cloudwright/importer/__init__.py +78 -0
  60. cloudwright/importer/cloudformation.py +368 -0
  61. cloudwright/importer/terraform_state.py +320 -0
  62. cloudwright/importer/utils.py +45 -0
  63. cloudwright/linter.py +255 -0
  64. cloudwright/llm/__init__.py +45 -0
  65. cloudwright/llm/anthropic.py +42 -0
  66. cloudwright/llm/base.py +14 -0
  67. cloudwright/llm/openai.py +42 -0
  68. cloudwright/plugins.py +73 -0
  69. cloudwright/policy.py +204 -0
  70. cloudwright/providers/__init__.py +59 -0
  71. cloudwright/providers/aws.py +172 -0
  72. cloudwright/providers/azure.py +151 -0
  73. cloudwright/providers/gcp.py +151 -0
  74. cloudwright/py.typed +0 -0
  75. cloudwright/registry.py +223 -0
  76. cloudwright/scorer.py +369 -0
  77. cloudwright/spec.py +185 -0
  78. cloudwright/validator.py +841 -0
  79. cloudwright_ai-0.1.0.dist-info/METADATA +541 -0
  80. cloudwright_ai-0.1.0.dist-info/RECORD +81 -0
  81. cloudwright_ai-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,100 @@
1
+ """Cloudwright — Architecture intelligence for cloud engineers."""
2
+
3
+ from cloudwright.spec import (
4
+ Alternative,
5
+ ArchSpec,
6
+ ArchVersion,
7
+ Component,
8
+ ComponentChange,
9
+ ComponentCost,
10
+ Connection,
11
+ ConnectionChange,
12
+ Constraints,
13
+ CostEstimate,
14
+ DiffResult,
15
+ ValidationCheck,
16
+ ValidationResult,
17
+ )
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "Alternative",
23
+ "ArchSpec",
24
+ "ArchVersion",
25
+ "Architect",
26
+ "Catalog",
27
+ "ConversationSession",
28
+ "Component",
29
+ "ComponentChange",
30
+ "ComponentCost",
31
+ "Connection",
32
+ "ConnectionChange",
33
+ "Constraints",
34
+ "CostEstimate",
35
+ "create_version",
36
+ "detect_drift",
37
+ "diff_versions",
38
+ "Differ",
39
+ "DiffResult",
40
+ "DriftReport",
41
+ "get_timeline",
42
+ "LintWarning",
43
+ "lint",
44
+ "ValidationCheck",
45
+ "ValidationResult",
46
+ "Validator",
47
+ ]
48
+
49
+
50
+ def __getattr__(name: str):
51
+ # Lazy imports for heavy modules that need LLM/DB
52
+ if name == "Architect":
53
+ from cloudwright.architect import Architect
54
+
55
+ return Architect
56
+ if name == "ConversationSession":
57
+ from cloudwright.architect import ConversationSession
58
+
59
+ return ConversationSession
60
+ if name == "Catalog":
61
+ from cloudwright.catalog import Catalog
62
+
63
+ return Catalog
64
+ if name == "Differ":
65
+ from cloudwright.differ import Differ
66
+
67
+ return Differ
68
+ if name == "Validator":
69
+ from cloudwright.validator import Validator
70
+
71
+ return Validator
72
+ if name == "lint":
73
+ from cloudwright.linter import lint
74
+
75
+ return lint
76
+ if name == "LintWarning":
77
+ from cloudwright.linter import LintWarning
78
+
79
+ return LintWarning
80
+ if name == "detect_drift":
81
+ from cloudwright.drift import detect_drift
82
+
83
+ return detect_drift
84
+ if name == "DriftReport":
85
+ from cloudwright.drift import DriftReport
86
+
87
+ return DriftReport
88
+ if name == "create_version":
89
+ from cloudwright.evolution import create_version
90
+
91
+ return create_version
92
+ if name == "get_timeline":
93
+ from cloudwright.evolution import get_timeline
94
+
95
+ return get_timeline
96
+ if name == "diff_versions":
97
+ from cloudwright.evolution import diff_versions
98
+
99
+ return diff_versions
100
+ raise AttributeError(f"module 'cloudwright' has no attribute {name!r}")
@@ -0,0 +1,79 @@
1
+ """Cloud pricing adapters — fetch live pricing data from provider APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ssl
6
+ import urllib.request
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+ from typing import Iterator
10
+
11
+
12
+ def _ssl_context() -> ssl.SSLContext:
13
+ """Create an SSL context using certifi CA bundle (macOS workaround)."""
14
+ try:
15
+ import certifi
16
+
17
+ return ssl.create_default_context(cafile=certifi.where())
18
+ except ImportError:
19
+ return ssl.create_default_context()
20
+
21
+
22
+ def urlopen_safe(req: urllib.request.Request, timeout: int = 30) -> bytes:
23
+ """urlopen with certifi SSL — use this instead of raw urllib.request.urlopen."""
24
+ ctx = _ssl_context()
25
+ with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
26
+ return resp.read()
27
+
28
+
29
+ @dataclass
30
+ class InstancePrice:
31
+ """Pricing record for a compute instance type."""
32
+
33
+ instance_type: str
34
+ region: str
35
+ vcpus: int
36
+ memory_gb: float
37
+ price_per_hour: float
38
+ price_type: str = "on_demand" # on_demand | reserved_1yr | reserved_3yr | spot
39
+ os: str = "linux"
40
+ storage_desc: str = ""
41
+ network_bandwidth: str = ""
42
+
43
+
44
+ @dataclass
45
+ class ManagedServicePrice:
46
+ """Pricing record for a managed service tier."""
47
+
48
+ service: str
49
+ tier_name: str
50
+ price_per_hour: float
51
+ price_per_month: float
52
+ description: str = ""
53
+ vcpus: int = 0
54
+ memory_gb: float = 0.0
55
+
56
+
57
+ class PricingAdapter(ABC):
58
+ """Abstract base for cloud pricing data adapters.
59
+
60
+ Subclasses fetch live pricing from provider-specific APIs and return
61
+ normalized InstancePrice / ManagedServicePrice records for catalog ingestion.
62
+ """
63
+
64
+ provider: str # "aws" | "gcp" | "azure"
65
+
66
+ @abstractmethod
67
+ def fetch_instance_pricing(self, region: str) -> Iterator[InstancePrice]:
68
+ """Yield compute instance prices for the given region."""
69
+
70
+ @abstractmethod
71
+ def fetch_managed_service_pricing(self, service: str, region: str) -> list[ManagedServicePrice]:
72
+ """Return pricing tiers for a managed service in the given region."""
73
+
74
+ @abstractmethod
75
+ def supported_managed_services(self) -> list[str]:
76
+ """List of managed service keys this adapter can fetch pricing for."""
77
+
78
+
79
+ __all__ = ["InstancePrice", "ManagedServicePrice", "PricingAdapter"]
@@ -0,0 +1,314 @@
1
+ """AWS Pricing API adapter.
2
+
3
+ Streams EC2 instance pricing from the AWS Bulk Pricing CSV (region-scoped)
4
+ and parses managed service pricing from the AWS JSON Pricing API for
5
+ Lambda, S3, RDS, and DynamoDB.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import io
12
+ import json
13
+ import re
14
+ import urllib.error
15
+ import urllib.request
16
+ from typing import Any, Iterator
17
+
18
+ from cloudwright.adapters import InstancePrice, ManagedServicePrice, PricingAdapter, urlopen_safe
19
+
20
+ _PRICING_BASE = "https://pricing.us-east-1.amazonaws.com"
21
+ _TIMEOUT = 30 # seconds
22
+
23
+ # AWS region code -> location name used in the pricing API
24
+ _REGION_TO_LOCATION: dict[str, str] = {
25
+ "us-east-1": "US East (N. Virginia)",
26
+ "us-east-2": "US East (Ohio)",
27
+ "us-west-1": "US West (N. California)",
28
+ "us-west-2": "US West (Oregon)",
29
+ "eu-west-1": "EU (Ireland)",
30
+ "eu-west-2": "EU (London)",
31
+ "eu-central-1": "EU (Frankfurt)",
32
+ "ap-southeast-1": "Asia Pacific (Singapore)",
33
+ "ap-southeast-2": "Asia Pacific (Sydney)",
34
+ "ap-northeast-1": "Asia Pacific (Tokyo)",
35
+ "ap-south-1": "Asia Pacific (Mumbai)",
36
+ "ca-central-1": "Canada (Central)",
37
+ "sa-east-1": "South America (Sao Paulo)",
38
+ }
39
+
40
+
41
+ def _parse_memory_gib(mem_str: str) -> float:
42
+ """Parse '16 GiB' or '16,384 MiB' -> float GiB."""
43
+ m = re.match(r"([\d,]+(?:\.\d+)?)\s*(GiB|MiB)", mem_str.strip())
44
+ if not m:
45
+ return 0.0
46
+ value = float(m.group(1).replace(",", ""))
47
+ return value / 1024 if m.group(2) == "MiB" else value
48
+
49
+
50
+ def _safe_int(val: str) -> int:
51
+ try:
52
+ return int(val)
53
+ except (ValueError, TypeError):
54
+ return 0
55
+
56
+
57
+ def _safe_float(val: str | float) -> float:
58
+ try:
59
+ return float(val)
60
+ except (ValueError, TypeError):
61
+ return 0.0
62
+
63
+
64
+ def _first_price(terms: dict) -> float:
65
+ """Extract the first USD price from a terms dict."""
66
+ for term in terms.values():
67
+ for dim in term.get("priceDimensions", {}).values():
68
+ p = _safe_float(dim.get("pricePerUnit", {}).get("USD", "0"))
69
+ if p > 0:
70
+ return p
71
+ return 0.0
72
+
73
+
74
+ class AWSPricingAdapter(PricingAdapter):
75
+ """Fetches AWS pricing from the bulk pricing API.
76
+
77
+ EC2 pricing is streamed from the CSV index (large file; streamed to avoid
78
+ loading the full ~1 GB decompressed CSV into memory at once).
79
+ Managed service pricing uses the JSON API.
80
+ """
81
+
82
+ provider = "aws"
83
+
84
+ def __init__(self, timeout: int = _TIMEOUT):
85
+ self._timeout = timeout
86
+
87
+ # Public interface
88
+
89
+ def fetch_instance_pricing(self, region: str = "us-east-1") -> Iterator[InstancePrice]:
90
+ """Stream on-demand Linux EC2 instance prices for the given region."""
91
+ url = f"{_PRICING_BASE}/offers/v1.0/aws/AmazonEC2/current/{region}/index.csv"
92
+ data = self._get(url)
93
+ yield from self._parse_ec2_csv(data, region)
94
+
95
+ def fetch_managed_service_pricing(self, service: str, region: str = "us-east-1") -> list[ManagedServicePrice]:
96
+ """Return pricing tiers for a supported managed service."""
97
+ parsers = {
98
+ "lambda": self._parse_lambda,
99
+ "s3": self._parse_s3,
100
+ "rds": self._parse_rds,
101
+ "dynamodb": self._parse_dynamodb,
102
+ }
103
+ handler = parsers.get(service)
104
+ return handler(region) if handler else []
105
+
106
+ def supported_managed_services(self) -> list[str]:
107
+ return ["lambda", "s3", "rds", "dynamodb"]
108
+
109
+ # EC2 CSV parsing
110
+
111
+ def _parse_ec2_csv(self, data: bytes, region: str) -> Iterator[InstancePrice]:
112
+ """Parse EC2 pricing CSV.
113
+
114
+ The CSV starts with several metadata lines before the actual header row
115
+ (the one whose first field is 'SKU'). We scan for it and then parse
116
+ the remainder as standard CSV.
117
+ """
118
+ text = data.decode("utf-8", errors="replace")
119
+ lines = text.splitlines()
120
+
121
+ # Find the header row — first line where field 0 is 'SKU' (quoted or bare)
122
+ header_idx = 0
123
+ for i, line in enumerate(lines):
124
+ stripped = line.strip().strip('"')
125
+ if stripped.startswith("SKU"):
126
+ header_idx = i
127
+ break
128
+
129
+ reader = csv.DictReader(io.StringIO("\n".join(lines[header_idx:])))
130
+
131
+ for row in reader:
132
+ if (
133
+ row.get("TermType") == "OnDemand"
134
+ and row.get("Operating System", "Linux") in ("Linux", "")
135
+ and row.get("Tenancy", "Shared") == "Shared"
136
+ and row.get("CapacityStatus", "Used") == "Used"
137
+ and row.get("Pre Installed S/W", "NA") in ("NA", "")
138
+ and row.get("productFamily", "Compute Instance") == "Compute Instance"
139
+ ):
140
+ price = _safe_float(row.get("PricePerUnit", "0"))
141
+ if price <= 0:
142
+ continue
143
+ yield InstancePrice(
144
+ instance_type=row.get("Instance Type", ""),
145
+ region=region,
146
+ vcpus=_safe_int(row.get("vCPU", "0")),
147
+ memory_gb=_parse_memory_gib(row.get("Memory", "0 GiB")),
148
+ price_per_hour=price,
149
+ price_type="on_demand",
150
+ os="linux",
151
+ storage_desc=row.get("Storage", ""),
152
+ network_bandwidth=row.get("Network Performance", ""),
153
+ )
154
+
155
+ # JSON API parsing
156
+
157
+ def _fetch_json(self, offer_code: str, region: str) -> dict[str, Any]:
158
+ url = f"{_PRICING_BASE}/offers/v1.0/aws/{offer_code}/current/{region}/index.json"
159
+ return json.loads(self._get(url))
160
+
161
+ def _parse_lambda(self, region: str) -> list[ManagedServicePrice]:
162
+ data = self._fetch_json("AWSLambda", region)
163
+ location = _REGION_TO_LOCATION.get(region, region)
164
+ on_demand = data.get("terms", {}).get("OnDemand", {})
165
+ prices: list[ManagedServicePrice] = []
166
+
167
+ for sku, product in data.get("products", {}).items():
168
+ attrs = product.get("attributes", {})
169
+ if attrs.get("location") not in (location, region):
170
+ continue
171
+ sku_terms = on_demand.get(sku, {})
172
+ for term in sku_terms.values():
173
+ for dim in term.get("priceDimensions", {}).values():
174
+ unit = dim.get("unit", "")
175
+ desc = dim.get("description", "")
176
+ price = _safe_float(dim.get("pricePerUnit", {}).get("USD", "0"))
177
+ if "request" in unit.lower() or "request" in desc.lower():
178
+ prices.append(
179
+ ManagedServicePrice(
180
+ service="lambda",
181
+ tier_name="per_request",
182
+ price_per_hour=0.0,
183
+ # price is per-request; store per-million
184
+ price_per_month=round(price * 1_000_000, 4),
185
+ description=desc,
186
+ )
187
+ )
188
+ elif "second" in unit.lower() or "gb-second" in unit.lower():
189
+ prices.append(
190
+ ManagedServicePrice(
191
+ service="lambda",
192
+ tier_name="per_gb_second",
193
+ price_per_hour=round(price * 3600, 6),
194
+ price_per_month=0.0,
195
+ description=desc,
196
+ )
197
+ )
198
+
199
+ return prices
200
+
201
+ def _parse_s3(self, region: str) -> list[ManagedServicePrice]:
202
+ # S3 uses a global index (no region path component)
203
+ url = f"{_PRICING_BASE}/offers/v1.0/aws/AmazonS3/current/index.json"
204
+ data = json.loads(self._get(url))
205
+ location = _REGION_TO_LOCATION.get(region, region)
206
+ on_demand = data.get("terms", {}).get("OnDemand", {})
207
+ prices: list[ManagedServicePrice] = []
208
+
209
+ for sku, product in data.get("products", {}).items():
210
+ attrs = product.get("attributes", {})
211
+ if attrs.get("location") != location:
212
+ continue
213
+ if attrs.get("storageClass") != "General Purpose":
214
+ continue
215
+ if attrs.get("volumeType") != "Standard":
216
+ continue
217
+ sku_terms = on_demand.get(sku, {})
218
+ for term in sku_terms.values():
219
+ for dim in term.get("priceDimensions", {}).values():
220
+ price = _safe_float(dim.get("pricePerUnit", {}).get("USD", "0"))
221
+ if price > 0:
222
+ prices.append(
223
+ ManagedServicePrice(
224
+ service="s3",
225
+ tier_name="standard_storage_gb",
226
+ price_per_hour=0.0,
227
+ price_per_month=price, # per GB/month
228
+ description=dim.get("description", ""),
229
+ )
230
+ )
231
+
232
+ return prices
233
+
234
+ def _parse_rds(self, region: str) -> list[ManagedServicePrice]:
235
+ data = self._fetch_json("AmazonRDS", region)
236
+ location = _REGION_TO_LOCATION.get(region, region)
237
+ on_demand = data.get("terms", {}).get("OnDemand", {})
238
+ prices: list[ManagedServicePrice] = []
239
+
240
+ for sku, product in data.get("products", {}).items():
241
+ attrs = product.get("attributes", {})
242
+ if attrs.get("location") not in (location, region):
243
+ continue
244
+ if attrs.get("databaseEngine") not in ("PostgreSQL", "MySQL"):
245
+ continue
246
+ if attrs.get("deploymentOption") != "Single-AZ":
247
+ continue
248
+ db_class = attrs.get("instanceType", "")
249
+ if not db_class:
250
+ continue
251
+
252
+ sku_terms = on_demand.get(sku, {})
253
+ price = _first_price(sku_terms)
254
+ if price > 0:
255
+ prices.append(
256
+ ManagedServicePrice(
257
+ service="rds",
258
+ tier_name=db_class,
259
+ price_per_hour=price,
260
+ price_per_month=round(price * 730, 2),
261
+ description=f"{attrs.get('databaseEngine')} {db_class} Single-AZ",
262
+ vcpus=_safe_int(attrs.get("vcpu", "0")),
263
+ memory_gb=_parse_memory_gib(attrs.get("memory", "0 GiB")),
264
+ )
265
+ )
266
+
267
+ return prices
268
+
269
+ def _parse_dynamodb(self, region: str) -> list[ManagedServicePrice]:
270
+ data = self._fetch_json("AmazonDynamoDB", region)
271
+ location = _REGION_TO_LOCATION.get(region, region)
272
+ on_demand = data.get("terms", {}).get("OnDemand", {})
273
+ prices: list[ManagedServicePrice] = []
274
+
275
+ for sku, product in data.get("products", {}).items():
276
+ attrs = product.get("attributes", {})
277
+ if attrs.get("location") not in (location, region):
278
+ continue
279
+ group = attrs.get("group", "")
280
+ sku_terms = on_demand.get(sku, {})
281
+ for term in sku_terms.values():
282
+ for dim in term.get("priceDimensions", {}).values():
283
+ price = _safe_float(dim.get("pricePerUnit", {}).get("USD", "0"))
284
+ dim_desc = dim.get("description", "")
285
+ if not price:
286
+ continue
287
+ if "write" in group.lower() or "write" in dim_desc.lower():
288
+ prices.append(
289
+ ManagedServicePrice(
290
+ service="dynamodb",
291
+ tier_name="write_request_unit",
292
+ price_per_hour=0.0,
293
+ price_per_month=round(price * 1_000_000, 4),
294
+ description=dim_desc,
295
+ )
296
+ )
297
+ elif "read" in group.lower() or "read" in dim_desc.lower():
298
+ prices.append(
299
+ ManagedServicePrice(
300
+ service="dynamodb",
301
+ tier_name="read_request_unit",
302
+ price_per_hour=0.0,
303
+ price_per_month=round(price * 1_000_000, 4),
304
+ description=dim_desc,
305
+ )
306
+ )
307
+
308
+ return prices
309
+
310
+ # HTTP
311
+
312
+ def _get(self, url: str) -> bytes:
313
+ req = urllib.request.Request(url, headers={"Accept": "*/*"})
314
+ return urlopen_safe(req, timeout=self._timeout)