opencloudcosts 0.7.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.
- opencloudcosts/__init__.py +0 -0
- opencloudcosts/cache.py +202 -0
- opencloudcosts/config.py +46 -0
- opencloudcosts/models.py +214 -0
- opencloudcosts/providers/__init__.py +0 -0
- opencloudcosts/providers/aws.py +1166 -0
- opencloudcosts/providers/azure.py +385 -0
- opencloudcosts/providers/base.py +87 -0
- opencloudcosts/providers/gcp.py +765 -0
- opencloudcosts/server.py +139 -0
- opencloudcosts/tools/__init__.py +0 -0
- opencloudcosts/tools/availability.py +689 -0
- opencloudcosts/tools/bom.py +322 -0
- opencloudcosts/tools/lookup.py +1335 -0
- opencloudcosts/utils/__init__.py +0 -0
- opencloudcosts/utils/baseline.py +53 -0
- opencloudcosts/utils/gcp_specs.py +437 -0
- opencloudcosts/utils/regions.py +215 -0
- opencloudcosts/utils/units.py +68 -0
- opencloudcosts-0.7.0.dist-info/METADATA +298 -0
- opencloudcosts-0.7.0.dist-info/RECORD +24 -0
- opencloudcosts-0.7.0.dist-info/WHEEL +4 -0
- opencloudcosts-0.7.0.dist-info/entry_points.txt +2 -0
- opencloudcosts-0.7.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
opencloudcosts/cache.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import aiosqlite
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_CREATE_SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS prices (
|
|
16
|
+
cache_key TEXT PRIMARY KEY,
|
|
17
|
+
provider TEXT NOT NULL,
|
|
18
|
+
service TEXT NOT NULL,
|
|
19
|
+
region TEXT NOT NULL,
|
|
20
|
+
data TEXT NOT NULL, -- JSON-serialised payload
|
|
21
|
+
fetched_at TEXT NOT NULL,
|
|
22
|
+
expires_at TEXT NOT NULL
|
|
23
|
+
);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_prices_lookup ON prices(provider, service, region);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_prices_expiry ON prices(expires_at);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
28
|
+
cache_key TEXT PRIMARY KEY,
|
|
29
|
+
data TEXT NOT NULL,
|
|
30
|
+
fetched_at TEXT NOT NULL,
|
|
31
|
+
expires_at TEXT NOT NULL
|
|
32
|
+
);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_metadata_expiry ON metadata(expires_at);
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _make_key(*parts: Any) -> str:
|
|
38
|
+
raw = json.dumps(parts, sort_keys=True, default=str)
|
|
39
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _now() -> str:
|
|
43
|
+
return datetime.now(timezone.utc).isoformat()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _expires(hours: float) -> str:
|
|
47
|
+
from datetime import timedelta
|
|
48
|
+
return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CacheManager:
|
|
52
|
+
def __init__(self, cache_dir: Path) -> None:
|
|
53
|
+
self._path = cache_dir / "pricing.db"
|
|
54
|
+
self._db: aiosqlite.Connection | None = None
|
|
55
|
+
|
|
56
|
+
async def initialize(self) -> None:
|
|
57
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self._db = await aiosqlite.connect(self._path)
|
|
59
|
+
self._db.row_factory = aiosqlite.Row
|
|
60
|
+
await self._db.executescript(_CREATE_SCHEMA)
|
|
61
|
+
await self._db.commit()
|
|
62
|
+
logger.info("Cache initialised at %s", self._path)
|
|
63
|
+
|
|
64
|
+
async def close(self) -> None:
|
|
65
|
+
if self._db:
|
|
66
|
+
await self._db.close()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def db(self) -> aiosqlite.Connection:
|
|
70
|
+
if not self._db:
|
|
71
|
+
raise RuntimeError("CacheManager not initialised — call initialize() first")
|
|
72
|
+
return self._db
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Prices table
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async def get_prices(
|
|
79
|
+
self,
|
|
80
|
+
provider: str,
|
|
81
|
+
service: str,
|
|
82
|
+
region: str,
|
|
83
|
+
key_extras: dict[str, Any],
|
|
84
|
+
) -> list[dict[str, Any]] | None:
|
|
85
|
+
key = _make_key(provider, service, region, key_extras)
|
|
86
|
+
async with self.db.execute(
|
|
87
|
+
"SELECT data, expires_at FROM prices WHERE cache_key = ?", (key,)
|
|
88
|
+
) as cur:
|
|
89
|
+
row = await cur.fetchone()
|
|
90
|
+
if row is None:
|
|
91
|
+
return None
|
|
92
|
+
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
|
|
93
|
+
await self.db.execute("DELETE FROM prices WHERE cache_key = ?", (key,))
|
|
94
|
+
await self.db.commit()
|
|
95
|
+
return None
|
|
96
|
+
return json.loads(row["data"])
|
|
97
|
+
|
|
98
|
+
async def set_prices(
|
|
99
|
+
self,
|
|
100
|
+
provider: str,
|
|
101
|
+
service: str,
|
|
102
|
+
region: str,
|
|
103
|
+
key_extras: dict[str, Any],
|
|
104
|
+
data: list[dict[str, Any]],
|
|
105
|
+
ttl_hours: float,
|
|
106
|
+
) -> None:
|
|
107
|
+
key = _make_key(provider, service, region, key_extras)
|
|
108
|
+
await self.db.execute(
|
|
109
|
+
"""
|
|
110
|
+
INSERT OR REPLACE INTO prices
|
|
111
|
+
(cache_key, provider, service, region, data, fetched_at, expires_at)
|
|
112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
113
|
+
""",
|
|
114
|
+
(key, provider, service, region, json.dumps(data), _now(), _expires(ttl_hours)),
|
|
115
|
+
)
|
|
116
|
+
await self.db.commit()
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Metadata table (region lists, attribute values, etc.)
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async def get_metadata(self, key: str) -> Any | None:
|
|
123
|
+
async with self.db.execute(
|
|
124
|
+
"SELECT data, expires_at FROM metadata WHERE cache_key = ?", (key,)
|
|
125
|
+
) as cur:
|
|
126
|
+
row = await cur.fetchone()
|
|
127
|
+
if row is None:
|
|
128
|
+
return None
|
|
129
|
+
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
|
|
130
|
+
await self.db.execute("DELETE FROM metadata WHERE cache_key = ?", (key,))
|
|
131
|
+
await self.db.commit()
|
|
132
|
+
return None
|
|
133
|
+
return json.loads(row["data"])
|
|
134
|
+
|
|
135
|
+
async def set_metadata(self, key: str, data: Any, ttl_hours: float) -> None:
|
|
136
|
+
await self.db.execute(
|
|
137
|
+
"""
|
|
138
|
+
INSERT OR REPLACE INTO metadata (cache_key, data, fetched_at, expires_at)
|
|
139
|
+
VALUES (?, ?, ?, ?)
|
|
140
|
+
""",
|
|
141
|
+
(key, json.dumps(data), _now(), _expires(ttl_hours)),
|
|
142
|
+
)
|
|
143
|
+
await self.db.commit()
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# Cache management
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
async def purge_expired(self) -> int:
|
|
150
|
+
now = _now()
|
|
151
|
+
async with self.db.execute(
|
|
152
|
+
"DELETE FROM prices WHERE expires_at < ?", (now,)
|
|
153
|
+
) as cur:
|
|
154
|
+
prices_deleted = cur.rowcount
|
|
155
|
+
async with self.db.execute(
|
|
156
|
+
"DELETE FROM metadata WHERE expires_at < ?", (now,)
|
|
157
|
+
) as cur:
|
|
158
|
+
meta_deleted = cur.rowcount
|
|
159
|
+
await self.db.commit()
|
|
160
|
+
return prices_deleted + meta_deleted
|
|
161
|
+
|
|
162
|
+
async def clear_all(self) -> dict[str, int]:
|
|
163
|
+
"""Delete all cached prices and metadata — called on server startup."""
|
|
164
|
+
async with self.db.execute("DELETE FROM prices") as cur:
|
|
165
|
+
prices_deleted = cur.rowcount
|
|
166
|
+
async with self.db.execute("DELETE FROM metadata") as cur:
|
|
167
|
+
meta_deleted = cur.rowcount
|
|
168
|
+
await self.db.commit()
|
|
169
|
+
logger.info(
|
|
170
|
+
"Cache cleared on startup: %d price entries, %d metadata entries",
|
|
171
|
+
prices_deleted, meta_deleted,
|
|
172
|
+
)
|
|
173
|
+
return {"prices_deleted": prices_deleted, "metadata_deleted": meta_deleted}
|
|
174
|
+
|
|
175
|
+
async def clear_provider(self, provider: str) -> dict[str, int]:
|
|
176
|
+
async with self.db.execute(
|
|
177
|
+
"DELETE FROM prices WHERE provider = ?", (provider,)
|
|
178
|
+
) as cur:
|
|
179
|
+
prices_deleted = cur.rowcount
|
|
180
|
+
async with self.db.execute(
|
|
181
|
+
"DELETE FROM metadata WHERE cache_key LIKE ?", (f"{provider}:%",)
|
|
182
|
+
) as cur:
|
|
183
|
+
meta_deleted = cur.rowcount
|
|
184
|
+
await self.db.commit()
|
|
185
|
+
logger.info(
|
|
186
|
+
"Cleared cache for provider %s: %d price entries, %d metadata entries",
|
|
187
|
+
provider, prices_deleted, meta_deleted,
|
|
188
|
+
)
|
|
189
|
+
return {"prices_deleted": prices_deleted, "metadata_deleted": meta_deleted}
|
|
190
|
+
|
|
191
|
+
async def stats(self) -> dict[str, Any]:
|
|
192
|
+
async with self.db.execute("SELECT COUNT(*) as n FROM prices") as cur:
|
|
193
|
+
prices_count = (await cur.fetchone())["n"]
|
|
194
|
+
async with self.db.execute("SELECT COUNT(*) as n FROM metadata") as cur:
|
|
195
|
+
meta_count = (await cur.fetchone())["n"]
|
|
196
|
+
size_bytes = self._path.stat().st_size if self._path.exists() else 0
|
|
197
|
+
return {
|
|
198
|
+
"price_entries": prices_count,
|
|
199
|
+
"metadata_entries": meta_count,
|
|
200
|
+
"db_size_mb": round(size_bytes / 1024 / 1024, 2),
|
|
201
|
+
"db_path": str(self._path),
|
|
202
|
+
}
|
opencloudcosts/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
model_config = SettingsConfigDict(
|
|
11
|
+
env_prefix="OCC_",
|
|
12
|
+
env_file=".env",
|
|
13
|
+
env_file_encoding="utf-8",
|
|
14
|
+
extra="ignore",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# General
|
|
18
|
+
cache_dir: Path = Path.home() / ".cache" / "opencloudcosts"
|
|
19
|
+
cache_ttl_hours: int = 24
|
|
20
|
+
metadata_ttl_days: int = 7
|
|
21
|
+
effective_price_ttl_hours: int = 1
|
|
22
|
+
spot_cache_ttl_minutes: int = Field(default=5, description="TTL for spot price cache entries in minutes")
|
|
23
|
+
default_currency: str = "USD"
|
|
24
|
+
default_regions: list[str] = ["us-east-1", "us-west-2"]
|
|
25
|
+
max_results: int = 20
|
|
26
|
+
|
|
27
|
+
# AWS
|
|
28
|
+
aws_profile: str | None = None
|
|
29
|
+
aws_region: str = "us-east-1"
|
|
30
|
+
# Cost Explorer costs $0.01/call — opt-in only
|
|
31
|
+
aws_enable_cost_explorer: bool = False
|
|
32
|
+
|
|
33
|
+
# GCP (Phase 3)
|
|
34
|
+
gcp_project_id: str | None = None
|
|
35
|
+
gcp_billing_dataset: str | None = None
|
|
36
|
+
gcp_api_key: str | None = None
|
|
37
|
+
|
|
38
|
+
# HTTP transport (used with --transport http)
|
|
39
|
+
http_port: int = Field(default=8080, description="HTTP server port (used with --transport http)")
|
|
40
|
+
http_host: str = Field(default="127.0.0.1", description="HTTP bind address (used with --transport http)")
|
|
41
|
+
api_key: str = Field(default="", description="Optional bearer token for HTTP transport authentication")
|
|
42
|
+
|
|
43
|
+
@field_validator("cache_dir", mode="before")
|
|
44
|
+
@classmethod
|
|
45
|
+
def expand_path(cls, v: str | Path) -> Path:
|
|
46
|
+
return Path(v).expanduser()
|
opencloudcosts/models.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CloudProvider(str, Enum):
|
|
12
|
+
AWS = "aws"
|
|
13
|
+
GCP = "gcp"
|
|
14
|
+
AZURE = "azure"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PricingTerm(str, Enum):
|
|
18
|
+
ON_DEMAND = "on_demand"
|
|
19
|
+
RESERVED_1YR = "reserved_1yr"
|
|
20
|
+
RESERVED_3YR = "reserved_3yr"
|
|
21
|
+
RESERVED_1YR_PARTIAL = "reserved_1yr_partial"
|
|
22
|
+
RESERVED_1YR_ALL = "reserved_1yr_all"
|
|
23
|
+
RESERVED_3YR_PARTIAL = "reserved_3yr_partial"
|
|
24
|
+
RESERVED_3YR_ALL = "reserved_3yr_all"
|
|
25
|
+
SPOT = "spot"
|
|
26
|
+
SAVINGS_PLAN = "savings_plan" # AWS
|
|
27
|
+
CUD_1YR = "cud_1yr" # GCP Committed Use Discount 1yr
|
|
28
|
+
CUD_3YR = "cud_3yr" # GCP Committed Use Discount 3yr
|
|
29
|
+
SUD = "sud" # GCP Sustained Use Discount
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PriceUnit(str, Enum):
|
|
33
|
+
PER_HOUR = "per_hour"
|
|
34
|
+
PER_MONTH = "per_month"
|
|
35
|
+
PER_GB_MONTH = "per_gb_month"
|
|
36
|
+
PER_GB = "per_gb"
|
|
37
|
+
PER_IOPS_MONTH = "per_iops_month"
|
|
38
|
+
PER_REQUEST = "per_request"
|
|
39
|
+
PER_GB_SECOND = "per_gb_second" # Lambda duration
|
|
40
|
+
PER_QUERY = "per_query" # Route53, Athena
|
|
41
|
+
PER_UNIT = "per_unit" # generic fallback
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NormalizedPrice(BaseModel):
|
|
45
|
+
"""Provider-agnostic pricing entry — the core data model."""
|
|
46
|
+
provider: CloudProvider
|
|
47
|
+
service: str # e.g. "compute", "storage", "database"
|
|
48
|
+
sku_id: str # provider-native SKU ID
|
|
49
|
+
product_family: str # e.g. "Compute Instance", "Storage"
|
|
50
|
+
description: str # human-readable
|
|
51
|
+
region: str # normalized region code (us-east-1, us-east1)
|
|
52
|
+
attributes: dict[str, str] = Field(default_factory=dict)
|
|
53
|
+
# ^ instance_type, vcpu, memory_gb, os, storage_type, engine, etc.
|
|
54
|
+
pricing_term: PricingTerm
|
|
55
|
+
price_per_unit: Decimal
|
|
56
|
+
unit: PriceUnit
|
|
57
|
+
currency: str = "USD"
|
|
58
|
+
effective_date: datetime | None = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def monthly_cost(self) -> Decimal:
|
|
62
|
+
"""Convenience: monthly cost assuming 730 hrs/month."""
|
|
63
|
+
if self.unit == PriceUnit.PER_HOUR:
|
|
64
|
+
return self.price_per_unit * Decimal("730")
|
|
65
|
+
if self.unit == PriceUnit.PER_MONTH:
|
|
66
|
+
return self.price_per_unit
|
|
67
|
+
return self.price_per_unit
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def hourly_cost(self) -> Decimal:
|
|
71
|
+
if self.unit == PriceUnit.PER_HOUR:
|
|
72
|
+
return self.price_per_unit
|
|
73
|
+
if self.unit == PriceUnit.PER_MONTH:
|
|
74
|
+
return self.price_per_unit / Decimal("730")
|
|
75
|
+
return self.price_per_unit
|
|
76
|
+
|
|
77
|
+
def summary(self) -> dict[str, Any]:
|
|
78
|
+
"""Compact dict for LLM consumption."""
|
|
79
|
+
from opencloudcosts.utils.regions import region_display_name
|
|
80
|
+
return {
|
|
81
|
+
"provider": self.provider.value,
|
|
82
|
+
"description": self.description,
|
|
83
|
+
"region": self.region,
|
|
84
|
+
"region_name": region_display_name(self.provider.value, self.region),
|
|
85
|
+
"term": self.pricing_term.value,
|
|
86
|
+
"price": (
|
|
87
|
+
# Use 2-significant-figure scientific notation for sub-microprice SKUs
|
|
88
|
+
# (e.g. Lambda per-request at $0.0000002 would show as "$0.000000" at 6dp).
|
|
89
|
+
# Standard 6dp for everything else.
|
|
90
|
+
f"${float(self.price_per_unit):.2e} {self.unit.value}"
|
|
91
|
+
if self.price_per_unit > 0 and self.price_per_unit < Decimal("0.0000005")
|
|
92
|
+
else f"${self.price_per_unit:.6f} {self.unit.value}"
|
|
93
|
+
),
|
|
94
|
+
"monthly_estimate": f"${self.monthly_cost:.2f}/mo" if self.unit == PriceUnit.PER_HOUR else None,
|
|
95
|
+
**{k: v for k, v in self.attributes.items() if k in ("instanceType", "vcpu", "memory", "operatingSystem", "storage_type", "volumeType")},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PriceComparison(BaseModel):
|
|
100
|
+
"""Result of a cross-region or cross-provider price comparison."""
|
|
101
|
+
query_description: str
|
|
102
|
+
results: list[NormalizedPrice]
|
|
103
|
+
cheapest: NormalizedPrice | None = None
|
|
104
|
+
most_expensive: NormalizedPrice | None = None
|
|
105
|
+
price_delta_pct: float | None = None # (most_expensive - cheapest) / cheapest * 100
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_results(cls, query: str, results: list[NormalizedPrice]) -> "PriceComparison":
|
|
109
|
+
if not results:
|
|
110
|
+
return cls(query_description=query, results=[])
|
|
111
|
+
sorted_results = sorted(results, key=lambda r: r.price_per_unit)
|
|
112
|
+
cheapest = sorted_results[0]
|
|
113
|
+
most_expensive = sorted_results[-1]
|
|
114
|
+
delta = None
|
|
115
|
+
if cheapest.price_per_unit > 0:
|
|
116
|
+
delta = float(
|
|
117
|
+
(most_expensive.price_per_unit - cheapest.price_per_unit)
|
|
118
|
+
/ cheapest.price_per_unit * 100
|
|
119
|
+
)
|
|
120
|
+
return cls(
|
|
121
|
+
query_description=query,
|
|
122
|
+
results=sorted_results,
|
|
123
|
+
cheapest=cheapest,
|
|
124
|
+
most_expensive=most_expensive,
|
|
125
|
+
price_delta_pct=round(delta, 2) if delta is not None else None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class BomLineItem(BaseModel):
|
|
130
|
+
"""A single line in a Bill of Materials."""
|
|
131
|
+
description: str
|
|
132
|
+
service: str
|
|
133
|
+
provider: CloudProvider
|
|
134
|
+
region: str
|
|
135
|
+
quantity: int
|
|
136
|
+
hours_per_month: float = 730.0
|
|
137
|
+
unit_price: NormalizedPrice
|
|
138
|
+
monthly_cost: Decimal
|
|
139
|
+
annual_cost: Decimal
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_price(
|
|
143
|
+
cls,
|
|
144
|
+
description: str,
|
|
145
|
+
price: NormalizedPrice,
|
|
146
|
+
quantity: int,
|
|
147
|
+
hours_per_month: float = 730.0,
|
|
148
|
+
size_gb: float = 1.0,
|
|
149
|
+
) -> "BomLineItem":
|
|
150
|
+
if price.unit == PriceUnit.PER_HOUR:
|
|
151
|
+
monthly = price.price_per_unit * Decimal(str(hours_per_month)) * quantity
|
|
152
|
+
elif price.unit == PriceUnit.PER_GB_MONTH:
|
|
153
|
+
monthly = price.price_per_unit * Decimal(str(size_gb)) * quantity
|
|
154
|
+
elif price.unit == PriceUnit.PER_MONTH:
|
|
155
|
+
monthly = price.price_per_unit * quantity
|
|
156
|
+
else:
|
|
157
|
+
monthly = price.price_per_unit * quantity
|
|
158
|
+
return cls(
|
|
159
|
+
description=description,
|
|
160
|
+
service=price.service,
|
|
161
|
+
provider=price.provider,
|
|
162
|
+
region=price.region,
|
|
163
|
+
quantity=quantity,
|
|
164
|
+
hours_per_month=hours_per_month,
|
|
165
|
+
unit_price=price,
|
|
166
|
+
monthly_cost=monthly,
|
|
167
|
+
annual_cost=monthly * 12,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class BomEstimate(BaseModel):
|
|
172
|
+
"""Total cost of ownership for a Bill of Materials."""
|
|
173
|
+
items: list[BomLineItem]
|
|
174
|
+
total_monthly: Decimal
|
|
175
|
+
total_annual: Decimal
|
|
176
|
+
currency: str = "USD"
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_items(cls, items: list[BomLineItem], currency: str = "USD") -> "BomEstimate":
|
|
180
|
+
total_monthly = sum(i.monthly_cost for i in items)
|
|
181
|
+
return cls(
|
|
182
|
+
items=items,
|
|
183
|
+
total_monthly=total_monthly,
|
|
184
|
+
total_annual=total_monthly * 12,
|
|
185
|
+
currency=currency,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class EffectivePrice(BaseModel):
|
|
190
|
+
"""Bespoke pricing reflecting actual account discounts."""
|
|
191
|
+
base_price: NormalizedPrice # public on-demand price
|
|
192
|
+
effective_price_per_unit: Decimal # actual rate after discounts
|
|
193
|
+
discount_type: str # "RI", "SP", "CUD", "EDP", "SUD"
|
|
194
|
+
discount_pct: float # e.g. 35.0 for 35% off
|
|
195
|
+
commitment_term: str | None = None # "1yr", "3yr", None for SUD/EDP
|
|
196
|
+
source: str = "" # "cost_explorer", "savings_plans_api", "billing_export"
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def savings_vs_on_demand(self) -> Decimal:
|
|
200
|
+
return self.base_price.price_per_unit - self.effective_price_per_unit
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class InstanceTypeInfo(BaseModel):
|
|
204
|
+
"""Metadata about a compute instance type."""
|
|
205
|
+
provider: CloudProvider
|
|
206
|
+
instance_type: str
|
|
207
|
+
vcpu: int
|
|
208
|
+
memory_gb: float
|
|
209
|
+
gpu_count: int = 0
|
|
210
|
+
gpu_type: str | None = None
|
|
211
|
+
network_performance: str | None = None
|
|
212
|
+
storage: str | None = None
|
|
213
|
+
region: str
|
|
214
|
+
available: bool = True
|
|
File without changes
|