cloudcosting 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.
@@ -0,0 +1,8 @@
1
+ """Multi-cloud infrastructure cost estimation tool."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("cloudcosting")
7
+ except PackageNotFoundError:
8
+ __version__ = "dev"
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m cloudcosting."""
2
+
3
+ from cloudcosting.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
cloudcosting/cache.py ADDED
@@ -0,0 +1,110 @@
1
+ """File-based Price Cache with TTL.
2
+
3
+ Provider-agnostic cache that stores keyed pricing data as JSON files.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from cloudcosting.domain import CacheError
15
+
16
+ DEFAULT_CACHE_DIR = Path.home() / ".cloudcosting" / "cache"
17
+ DEFAULT_TTL_SECONDS = 86400 # 24 hours
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CacheResult:
22
+ """Result of a cache retrieval."""
23
+
24
+ data: dict | None
25
+ status: str # "fresh", "stale", "miss"
26
+ age_seconds: float | None = None
27
+
28
+
29
+ class PriceCache:
30
+ """File-based key-value cache with TTL and provider-scoped refresh."""
31
+
32
+ def __init__(
33
+ self,
34
+ cache_dir: Path = DEFAULT_CACHE_DIR,
35
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
36
+ ):
37
+ self._cache_dir = cache_dir
38
+ self._ttl_seconds = ttl_seconds
39
+
40
+ def store(self, provider: str, key: tuple, data: dict) -> None:
41
+ """Store pricing data with a cache key."""
42
+ try:
43
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
44
+ entry = {
45
+ "provider": provider,
46
+ "key": str(key),
47
+ "data": data,
48
+ "timestamp": time.time(),
49
+ "ttl_seconds": self._ttl_seconds,
50
+ }
51
+ filepath = self._key_to_path(key)
52
+ filepath.write_text(json.dumps(entry, indent=2))
53
+ except OSError as e:
54
+ raise CacheError(f"Failed to write cache: {e}") from e
55
+
56
+ def retrieve(self, key: tuple, allow_stale: bool = False) -> CacheResult:
57
+ """Retrieve cached data. Returns fresh, stale, or miss."""
58
+ filepath = self._key_to_path(key)
59
+ if not filepath.exists():
60
+ return CacheResult(data=None, status="miss")
61
+
62
+ try:
63
+ entry = json.loads(filepath.read_text())
64
+ except (OSError, json.JSONDecodeError) as e:
65
+ raise CacheError(f"Failed to read cache: {e}") from e
66
+
67
+ age = time.time() - entry["timestamp"]
68
+ is_expired = age > entry.get("ttl_seconds", self._ttl_seconds)
69
+
70
+ if not is_expired:
71
+ return CacheResult(data=entry["data"], status="fresh", age_seconds=age)
72
+
73
+ if allow_stale:
74
+ return CacheResult(data=entry["data"], status="stale", age_seconds=age)
75
+
76
+ return CacheResult(data=None, status="miss", age_seconds=age)
77
+
78
+ def refresh_provider(self, provider: str) -> int:
79
+ """Delete all cached entries for a provider. Returns count deleted."""
80
+ if not self._cache_dir.exists():
81
+ return 0
82
+ count = 0
83
+ for filepath in self._cache_dir.glob("*.json"):
84
+ try:
85
+ entry = json.loads(filepath.read_text())
86
+ if entry.get("provider") == provider:
87
+ filepath.unlink()
88
+ count += 1
89
+ except (OSError, json.JSONDecodeError):
90
+ continue
91
+ return count
92
+
93
+ def refresh_all(self) -> int:
94
+ """Delete all cached entries. Returns count deleted."""
95
+ if not self._cache_dir.exists():
96
+ return 0
97
+ count = 0
98
+ for filepath in self._cache_dir.glob("*.json"):
99
+ try:
100
+ filepath.unlink()
101
+ count += 1
102
+ except OSError:
103
+ continue
104
+ return count
105
+
106
+ def _key_to_path(self, key: tuple) -> Path:
107
+ """Deterministic hash of cache key to file path."""
108
+ key_str = json.dumps(key, sort_keys=True, default=str)
109
+ key_hash = hashlib.sha256(key_str.encode()).hexdigest()[:16]
110
+ return self._cache_dir / f"{key_hash}.json"
cloudcosting/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ """CLI entry point for cloudcosting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from cloudcosting import __version__
12
+ from cloudcosting.cache import PriceCache
13
+ from cloudcosting.domain import CloudCostError
14
+
15
+
16
+ def main():
17
+ """Main CLI entry point."""
18
+ args = sys.argv[1:]
19
+
20
+ if not args or args[0] in ("-h", "--help"):
21
+ _print_help()
22
+ sys.exit(0)
23
+
24
+ if args[0] in ("-v", "--version"):
25
+ print(f"cloudcosting {__version__}")
26
+ sys.exit(0)
27
+
28
+ command = args[0]
29
+
30
+ if command == "estimate":
31
+ _cmd_estimate(args[1:])
32
+ elif command == "cache":
33
+ _cmd_cache(args[1:])
34
+ else:
35
+ print(f"Unknown command: {command}", file=sys.stderr)
36
+ _print_help()
37
+ sys.exit(1)
38
+
39
+
40
+ def _cmd_estimate(args: list[str]):
41
+ """Run cost estimation from a config file."""
42
+ if not args:
43
+ print(
44
+ "Usage: cloudcosting estimate <config.yaml> [--format yaml|json] [-o output]",
45
+ file=sys.stderr,
46
+ )
47
+ sys.exit(1)
48
+
49
+ config_path = Path(args[0])
50
+ output_format = "yaml"
51
+ output_path = None
52
+
53
+ i = 1
54
+ while i < len(args):
55
+ if args[i] == "--format" and i + 1 < len(args):
56
+ output_format = args[i + 1]
57
+ i += 2
58
+ elif args[i] == "-o" and i + 1 < len(args):
59
+ output_path = Path(args[i + 1])
60
+ i += 2
61
+ else:
62
+ print(f"Unknown option: {args[i]}", file=sys.stderr)
63
+ sys.exit(1)
64
+
65
+ try:
66
+ from cloudcosting.estimator import run_estimation
67
+
68
+ estimate = run_estimation(config_path)
69
+ result = estimate.to_dict()
70
+
71
+ if output_format == "json":
72
+ output = json.dumps(result, indent=2)
73
+ else:
74
+ output = yaml.dump(result, default_flow_style=False, sort_keys=False)
75
+
76
+ if output_path:
77
+ output_path.write_text(output)
78
+ print(f"Estimate written to {output_path}", file=sys.stderr)
79
+ else:
80
+ print(output)
81
+
82
+ # Print summary to stderr
83
+ totals = result["estimate"]["totals"]
84
+ status = result["estimate"]["status"]
85
+ n_resources = sum(len(p["resources"]) for p in result["estimate"]["providers"])
86
+ n_errors = len(result["estimate"]["errors"]) + sum(
87
+ len(p.get("errors", [])) for p in result["estimate"]["providers"]
88
+ )
89
+
90
+ print("\n--- Summary ---", file=sys.stderr)
91
+ print(f"Status: {status}", file=sys.stderr)
92
+ print(f"Resources estimated: {n_resources}", file=sys.stderr)
93
+ if n_errors > 0:
94
+ print(f"Errors: {n_errors}", file=sys.stderr)
95
+ print(f"Monthly total: ${totals['monthly']:,.2f}", file=sys.stderr)
96
+ print(f"Annual total: ${totals['annual']:,.2f}", file=sys.stderr)
97
+
98
+ sys.exit(0 if status == "complete" else 1)
99
+
100
+ except CloudCostError as e:
101
+ print(f"Error: {e}", file=sys.stderr)
102
+ sys.exit(1)
103
+
104
+
105
+ def _cmd_cache(args: list[str]):
106
+ """Cache management commands."""
107
+ if not args:
108
+ print("Usage: cloudcosting cache <refresh|status>", file=sys.stderr)
109
+ sys.exit(1)
110
+
111
+ subcommand = args[0]
112
+ cache = PriceCache()
113
+
114
+ if subcommand == "refresh":
115
+ provider = args[1] if len(args) > 1 else None
116
+ if provider:
117
+ count = cache.refresh_provider(provider)
118
+ print(f"Deleted {count} cached entries for provider '{provider}'")
119
+ else:
120
+ count = cache.refresh_all()
121
+ print(f"Deleted {count} cached entries")
122
+ elif subcommand == "status":
123
+ cache_dir = cache._cache_dir
124
+ if cache_dir.exists():
125
+ files = list(cache_dir.glob("*.json"))
126
+ print(f"Cache directory: {cache_dir}")
127
+ print(f"Cached entries: {len(files)}")
128
+ else:
129
+ print(f"Cache directory: {cache_dir} (does not exist)")
130
+ print("Cached entries: 0")
131
+ else:
132
+ print(f"Unknown cache command: {subcommand}", file=sys.stderr)
133
+ sys.exit(1)
134
+
135
+
136
+ def _print_help():
137
+ print(f"""cloudcosting {__version__} - Multi-cloud infrastructure cost estimation
138
+
139
+ Usage:
140
+ cloudcosting estimate <config.yaml> [--format yaml|json] [-o output_file]
141
+ cloudcosting cache refresh [provider]
142
+ cloudcosting cache status
143
+ cloudcosting --version
144
+ cloudcosting --help
145
+
146
+ Commands:
147
+ estimate Run cost estimation from a YAML configuration file
148
+ cache Manage the pricing data cache
149
+
150
+ Examples:
151
+ cloudcosting estimate infrastructure.yaml
152
+ cloudcosting estimate infrastructure.yaml --format json
153
+ cloudcosting estimate infrastructure.yaml -o costs.yaml
154
+ cloudcosting cache refresh aws
155
+ cloudcosting cache status""")
cloudcosting/config.py ADDED
@@ -0,0 +1,112 @@
1
+ """Config Loader: reads YAML, validates structure, returns EstimationConfig.
2
+
3
+ Validates structural requirements only. Provider-specific fields pass through.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from cloudcosting.domain import ConfigError, EstimationConfig, ResourceSpec
13
+
14
+ REQUIRED_RESOURCE_FIELDS = ("provider", "region", "type")
15
+
16
+
17
+ def load_config(
18
+ path: Path,
19
+ known_providers: set[str] | None = None,
20
+ ) -> EstimationConfig:
21
+ """Load and validate a YAML configuration file.
22
+
23
+ Args:
24
+ path: Path to YAML config file.
25
+ known_providers: If provided, validate provider IDs against this set.
26
+
27
+ Returns:
28
+ EstimationConfig with validated resource specs.
29
+
30
+ Raises:
31
+ ConfigError: On any structural validation failure.
32
+ """
33
+ try:
34
+ raw = yaml.safe_load(path.read_text())
35
+ except FileNotFoundError:
36
+ raise ConfigError(f"Configuration file not found: {path}") from None
37
+ except yaml.YAMLError as e:
38
+ raise ConfigError(f"Invalid YAML in {path}: {e}") from e
39
+
40
+ if not isinstance(raw, dict):
41
+ raise ConfigError(
42
+ f"Configuration must be a YAML mapping, got {type(raw).__name__}"
43
+ )
44
+
45
+ # Extract top-level defaults
46
+ defaults = {}
47
+ if "provider" in raw:
48
+ defaults["provider"] = str(raw["provider"])
49
+ if "region" in raw:
50
+ defaults["region"] = str(raw["region"])
51
+
52
+ # Resources list is required
53
+ if "resources" not in raw:
54
+ raise ConfigError("Configuration must contain a 'resources' key")
55
+
56
+ raw_resources = raw["resources"]
57
+ if not isinstance(raw_resources, list):
58
+ raise ConfigError("'resources' must be a list")
59
+
60
+ if len(raw_resources) == 0:
61
+ raise ConfigError("'resources' list must not be empty")
62
+
63
+ resources = []
64
+ for i, res in enumerate(raw_resources):
65
+ if not isinstance(res, dict):
66
+ raise ConfigError(
67
+ f"Resource {i}: must be a mapping, got {type(res).__name__}"
68
+ )
69
+
70
+ # Apply defaults for missing fields
71
+ effective = {**defaults, **res}
72
+
73
+ # Check required fields
74
+ for field in REQUIRED_RESOURCE_FIELDS:
75
+ if field not in effective:
76
+ raise ConfigError(f"Resource {i}: missing required field '{field}'")
77
+ if not isinstance(effective[field], str):
78
+ raise ConfigError(
79
+ f"Resource {i}: '{field}' must be a string, "
80
+ f"got {type(effective[field]).__name__}"
81
+ )
82
+
83
+ provider = effective["provider"]
84
+ region = effective["region"]
85
+ resource_type = effective["type"]
86
+
87
+ # Validate provider ID if registry is available
88
+ if known_providers is not None and provider not in known_providers:
89
+ raise ConfigError(
90
+ f"Resource {i}: unknown provider '{provider}'. "
91
+ f"Known providers: {sorted(known_providers)}"
92
+ )
93
+
94
+ # Extract label, pass everything else as params
95
+ label = str(effective.get("label", ""))
96
+ params = {
97
+ k: v
98
+ for k, v in effective.items()
99
+ if k not in ("provider", "region", "type", "label")
100
+ }
101
+
102
+ resources.append(
103
+ ResourceSpec(
104
+ provider=provider,
105
+ region=region,
106
+ type=resource_type,
107
+ params=params,
108
+ label=label,
109
+ )
110
+ )
111
+
112
+ return EstimationConfig(resources=tuple(resources), defaults=defaults)
cloudcosting/domain.py ADDED
@@ -0,0 +1,159 @@
1
+ """Domain objects and exceptions for cloudcosting.
2
+
3
+ All data flowing through the system is represented here.
4
+ No component defines its own ad-hoc dictionaries for passing data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ # -- Exceptions --
12
+
13
+
14
+ class CloudCostError(Exception):
15
+ """Base exception for all cloudcosting errors."""
16
+
17
+
18
+ class ConfigError(CloudCostError):
19
+ """Configuration file is malformed or missing required fields."""
20
+
21
+
22
+ class ProviderError(CloudCostError):
23
+ """Provider-specific validation failure."""
24
+
25
+
26
+ class PricingError(CloudCostError):
27
+ """Unable to fetch or parse pricing data."""
28
+
29
+
30
+ class CacheError(CloudCostError):
31
+ """Cache read/write failure (filesystem issues)."""
32
+
33
+
34
+ # -- Input domain --
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ResourceSpec:
39
+ """A single resource specification from the configuration."""
40
+
41
+ provider: str
42
+ region: str
43
+ type: str
44
+ params: dict = field(default_factory=dict)
45
+ label: str = ""
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class EstimationConfig:
50
+ """Full parsed configuration: resources plus global defaults."""
51
+
52
+ resources: tuple[ResourceSpec, ...]
53
+ defaults: dict = field(default_factory=dict)
54
+
55
+
56
+ # -- Output domain --
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class CostLineItem:
61
+ """One cost component (e.g., instance, storage, backup)."""
62
+
63
+ name: str
64
+ monthly: float
65
+
66
+ def to_dict(self) -> dict:
67
+ return {"name": self.name, "monthly": round(self.monthly, 2)}
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ResourceCost:
72
+ """Full cost breakdown for one successfully estimated resource."""
73
+
74
+ label: str
75
+ type: str
76
+ monthly: float
77
+ annual: float
78
+ line_items: tuple[CostLineItem, ...]
79
+ notes: tuple[str, ...] = ()
80
+
81
+ def to_dict(self) -> dict:
82
+ result = {
83
+ "label": self.label,
84
+ "type": self.type,
85
+ "monthly": round(self.monthly, 2),
86
+ "annual": round(self.annual, 2),
87
+ "line_items": [item.to_dict() for item in self.line_items],
88
+ }
89
+ if self.notes:
90
+ result["notes"] = list(self.notes)
91
+ return result
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class ResourceError:
96
+ """A failed resource with reason."""
97
+
98
+ label: str
99
+ type: str
100
+ reason: str
101
+
102
+ def to_dict(self) -> dict:
103
+ return {"label": self.label, "type": self.type, "reason": self.reason}
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class ProviderEstimate:
108
+ """Results for one provider: metadata + resource breakdowns + errors."""
109
+
110
+ provider: str
111
+ region: str
112
+ pricing_date: str
113
+ currency: str
114
+ cache_status: str
115
+ resources: tuple[ResourceCost, ...]
116
+ errors: tuple[ResourceError, ...] = ()
117
+
118
+ def to_dict(self) -> dict:
119
+ result = {
120
+ "provider": self.provider,
121
+ "region": self.region,
122
+ "pricing_date": self.pricing_date,
123
+ "currency": self.currency,
124
+ "cache_status": self.cache_status,
125
+ "resources": [r.to_dict() for r in self.resources],
126
+ }
127
+ if self.errors:
128
+ result["errors"] = [e.to_dict() for e in self.errors]
129
+ return result
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Estimate:
134
+ """Top-level output: everything the CLI needs to serialize."""
135
+
136
+ version: str
137
+ timestamp: str
138
+ status: str # "complete" or "partial"
139
+ providers: tuple[ProviderEstimate, ...]
140
+ totals: dict # {"monthly": float, "annual": float}
141
+ errors: tuple[ResourceError, ...] = ()
142
+ warnings: tuple[str, ...] = ()
143
+
144
+ def to_dict(self) -> dict:
145
+ result: dict = {
146
+ "estimate": {
147
+ "version": self.version,
148
+ "timestamp": self.timestamp,
149
+ "status": self.status,
150
+ "providers": [p.to_dict() for p in self.providers],
151
+ "totals": {
152
+ "monthly": round(self.totals.get("monthly", 0.0), 2),
153
+ "annual": round(self.totals.get("annual", 0.0), 2),
154
+ },
155
+ "errors": [e.to_dict() for e in self.errors],
156
+ "warnings": list(self.warnings),
157
+ }
158
+ }
159
+ return result
@@ -0,0 +1,104 @@
1
+ """Estimator: Transaction script that orchestrates the full estimation workflow.
2
+
3
+ Load config -> group resources by provider/region -> estimate -> aggregate -> return Estimate.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections import defaultdict
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ from cloudcosting import __version__
13
+ from cloudcosting.cache import PriceCache
14
+ from cloudcosting.config import load_config
15
+ from cloudcosting.domain import (
16
+ Estimate,
17
+ EstimationConfig,
18
+ ProviderEstimate,
19
+ ResourceError,
20
+ ResourceSpec,
21
+ )
22
+ from cloudcosting.providers.registry import ProviderRegistry
23
+
24
+
25
+ def run_estimation(
26
+ config_path: Path,
27
+ cache: PriceCache | None = None,
28
+ ) -> Estimate:
29
+ """Full estimation workflow: config -> providers -> aggregate.
30
+
31
+ This is the main entry point for the estimation pipeline.
32
+ """
33
+ if cache is None:
34
+ cache = PriceCache()
35
+
36
+ registry = ProviderRegistry(cache=cache)
37
+
38
+ # Load and validate config
39
+ config = load_config(config_path, known_providers=registry.known_ids)
40
+
41
+ return estimate_from_config(config, registry)
42
+
43
+
44
+ def estimate_from_config(
45
+ config: EstimationConfig,
46
+ registry: ProviderRegistry,
47
+ ) -> Estimate:
48
+ """Estimate costs from an already-loaded config."""
49
+ # Group resources by (provider, region)
50
+ groups: dict[tuple[str, str], list[ResourceSpec]] = defaultdict(list)
51
+ global_errors: list[ResourceError] = []
52
+
53
+ for spec in config.resources:
54
+ provider = registry.get(spec.provider)
55
+ if provider is None:
56
+ global_errors.append(
57
+ ResourceError(
58
+ label=spec.label or spec.type,
59
+ type=spec.type,
60
+ reason=f"No provider registered for '{spec.provider}'",
61
+ )
62
+ )
63
+ continue
64
+ groups[(spec.provider, spec.region)].append(spec)
65
+
66
+ # Run estimation per provider/region group
67
+ provider_estimates: list[ProviderEstimate] = []
68
+ for (provider_id, region), specs in groups.items():
69
+ provider = registry.get(provider_id)
70
+ pe = provider.estimate_resources(specs, region)
71
+ provider_estimates.append(pe)
72
+
73
+ # Aggregate totals
74
+ total_monthly = sum(rc.monthly for pe in provider_estimates for rc in pe.resources)
75
+ total_annual = total_monthly * 12
76
+
77
+ # Determine status
78
+ all_errors = list(global_errors)
79
+ for pe in provider_estimates:
80
+ all_errors.extend(pe.errors)
81
+
82
+ has_results = any(pe.resources for pe in provider_estimates)
83
+ if all_errors and has_results:
84
+ status = "partial"
85
+ elif all_errors and not has_results:
86
+ status = "failed"
87
+ else:
88
+ status = "complete"
89
+
90
+ # Collect warnings
91
+ warnings = []
92
+ for pe in provider_estimates:
93
+ if pe.cache_status == "stale":
94
+ warnings.append(f"Using stale cached pricing for {pe.provider}/{pe.region}")
95
+
96
+ return Estimate(
97
+ version=__version__,
98
+ timestamp=datetime.now(UTC).isoformat(),
99
+ status=status,
100
+ providers=tuple(provider_estimates),
101
+ totals={"monthly": total_monthly, "annual": total_annual},
102
+ errors=tuple(global_errors),
103
+ warnings=tuple(warnings),
104
+ )
File without changes
File without changes
File without changes