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.
File without changes
@@ -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
+ }
@@ -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()
@@ -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