Skip to content

Commit 2919e43

Browse files
Merge pull request #6 from ivyleavedtoadflax/issue-22-instance-pricing
feat: Add instance pricing to list command (#22)
2 parents 42529b2 + a8f8488 commit 2919e43

File tree

5 files changed

+648
-11
lines changed

5 files changed

+648
-11
lines changed

remotepy/instance.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ResourceNotFoundError,
1818
ValidationError,
1919
)
20+
from remotepy.pricing import format_price, get_instance_price, get_monthly_estimate
2021
from remotepy.utils import (
2122
get_ec2_client,
2223
get_instance_dns,
@@ -51,11 +52,20 @@ def _get_status_style(status: str) -> str:
5152

5253
@app.command("ls")
5354
@app.command("list")
54-
def list_instances() -> None:
55+
def list_instances(
56+
no_pricing: bool = typer.Option(
57+
False, "--no-pricing", help="Skip pricing lookup (faster, no cost columns)"
58+
),
59+
) -> None:
5560
"""
5661
List all EC2 instances.
5762
58-
Displays a table with instance name, ID, public DNS, status, type, and launch time.
63+
Displays a table with instance name, ID, public DNS, status, type, launch time,
64+
and pricing information (hourly and monthly estimates).
65+
66+
Examples:
67+
remote list # List with pricing
68+
remote list --no-pricing # List without pricing (faster)
5969
"""
6070
instances = get_instances()
6171
ids = get_instance_ids(instances)
@@ -71,18 +81,31 @@ def list_instances() -> None:
7181
table.add_column("Type")
7282
table.add_column("Launch Time")
7383

84+
if not no_pricing:
85+
table.add_column("$/hr", justify="right")
86+
table.add_column("$/month", justify="right")
87+
7488
for name, instance_id, dns, status, it, lt in zip(
7589
names, ids, public_dnss, statuses, instance_types, launch_times, strict=False
7690
):
7791
status_style = _get_status_style(status)
78-
table.add_row(
92+
93+
row_data = [
7994
name or "",
8095
instance_id or "",
8196
dns or "",
8297
f"[{status_style}]{status}[/{status_style}]",
8398
it or "",
8499
lt or "",
85-
)
100+
]
101+
102+
if not no_pricing:
103+
hourly_price = get_instance_price(it) if it else None
104+
monthly_price = get_monthly_estimate(hourly_price)
105+
row_data.append(format_price(hourly_price))
106+
row_data.append(format_price(monthly_price))
107+
108+
table.add_row(*row_data)
86109

87110
console.print(table)
88111

remotepy/pricing.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""AWS EC2 instance pricing functionality.
2+
3+
This module provides functions to retrieve EC2 instance pricing information
4+
using the AWS Pricing API.
5+
"""
6+
7+
import json
8+
from functools import lru_cache
9+
from typing import Any
10+
11+
import boto3
12+
from botocore.exceptions import ClientError, NoCredentialsError
13+
14+
# AWS region to location name mapping
15+
# The Pricing API uses location names, not region codes
16+
REGION_TO_LOCATION: dict[str, str] = {
17+
"us-east-1": "US East (N. Virginia)",
18+
"us-east-2": "US East (Ohio)",
19+
"us-west-1": "US West (N. California)",
20+
"us-west-2": "US West (Oregon)",
21+
"eu-west-1": "Europe (Ireland)",
22+
"eu-west-2": "Europe (London)",
23+
"eu-west-3": "Europe (Paris)",
24+
"eu-central-1": "Europe (Frankfurt)",
25+
"eu-north-1": "Europe (Stockholm)",
26+
"ap-northeast-1": "Asia Pacific (Tokyo)",
27+
"ap-northeast-2": "Asia Pacific (Seoul)",
28+
"ap-northeast-3": "Asia Pacific (Osaka)",
29+
"ap-southeast-1": "Asia Pacific (Singapore)",
30+
"ap-southeast-2": "Asia Pacific (Sydney)",
31+
"ap-south-1": "Asia Pacific (Mumbai)",
32+
"sa-east-1": "South America (Sao Paulo)",
33+
"ca-central-1": "Canada (Central)",
34+
}
35+
36+
# Hours per month (for calculating monthly estimates)
37+
HOURS_PER_MONTH = 730
38+
39+
40+
@lru_cache(maxsize=1)
41+
def get_pricing_client() -> Any:
42+
"""Get or create the Pricing client.
43+
44+
The Pricing API is only available in us-east-1 and ap-south-1.
45+
We use us-east-1 for consistency.
46+
47+
Returns:
48+
boto3 Pricing client instance
49+
"""
50+
return boto3.client("pricing", region_name="us-east-1")
51+
52+
53+
def get_current_region() -> str:
54+
"""Get the current AWS region from the session.
55+
56+
Returns:
57+
The current AWS region code
58+
"""
59+
session = boto3.session.Session()
60+
return session.region_name or "us-east-1"
61+
62+
63+
@lru_cache(maxsize=256)
64+
def get_instance_price(instance_type: str, region: str | None = None) -> float | None:
65+
"""Get the hourly on-demand price for an EC2 instance type.
66+
67+
Args:
68+
instance_type: The EC2 instance type (e.g., 't3.micro', 'm5.large')
69+
region: AWS region code. If None, uses the current session region.
70+
71+
Returns:
72+
The hourly price in USD, or None if pricing is unavailable.
73+
74+
Note:
75+
This function caches results to reduce API calls.
76+
Prices are for Linux on-demand instances with shared tenancy.
77+
"""
78+
if region is None:
79+
region = get_current_region()
80+
81+
# Get location name for region
82+
location = REGION_TO_LOCATION.get(region)
83+
if not location:
84+
# Region not in our mapping, return None
85+
return None
86+
87+
try:
88+
pricing_client = get_pricing_client()
89+
response = pricing_client.get_products(
90+
ServiceCode="AmazonEC2",
91+
Filters=[
92+
{"Type": "TERM_MATCH", "Field": "instanceType", "Value": instance_type},
93+
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
94+
{"Type": "TERM_MATCH", "Field": "operatingSystem", "Value": "Linux"},
95+
{"Type": "TERM_MATCH", "Field": "tenancy", "Value": "Shared"},
96+
{"Type": "TERM_MATCH", "Field": "preInstalledSw", "Value": "NA"},
97+
{"Type": "TERM_MATCH", "Field": "capacitystatus", "Value": "Used"},
98+
],
99+
MaxResults=1,
100+
)
101+
102+
price_list = response.get("PriceList", [])
103+
if not price_list:
104+
return None
105+
106+
# Parse the price from the response
107+
price_data = json.loads(price_list[0])
108+
terms = price_data.get("terms", {}).get("OnDemand", {})
109+
110+
if not terms:
111+
return None
112+
113+
# Get the first term and its price dimension
114+
for term in terms.values():
115+
price_dimensions = term.get("priceDimensions", {})
116+
for dimension in price_dimensions.values():
117+
price_per_unit = dimension.get("pricePerUnit", {}).get("USD")
118+
if price_per_unit:
119+
return float(price_per_unit)
120+
121+
return None
122+
123+
except ClientError:
124+
# Don't raise an exception for pricing errors - just return None
125+
# Pricing failures shouldn't block the main functionality
126+
return None
127+
except NoCredentialsError:
128+
return None
129+
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
130+
return None
131+
132+
133+
def get_monthly_estimate(hourly_price: float | None) -> float | None:
134+
"""Calculate monthly cost estimate from hourly price.
135+
136+
Args:
137+
hourly_price: The hourly price in USD
138+
139+
Returns:
140+
The estimated monthly cost in USD, or None if hourly_price is None
141+
"""
142+
if hourly_price is None:
143+
return None
144+
return hourly_price * HOURS_PER_MONTH
145+
146+
147+
def format_price(price: float | None, prefix: str = "$") -> str:
148+
"""Format a price for display.
149+
150+
Args:
151+
price: The price to format
152+
prefix: Currency prefix (default: "$")
153+
154+
Returns:
155+
Formatted price string, or "-" if price is None
156+
"""
157+
if price is None:
158+
return "-"
159+
if price < 0.01:
160+
return f"{prefix}{price:.4f}"
161+
return f"{prefix}{price:.2f}"
162+
163+
164+
def get_instance_pricing_info(instance_type: str, region: str | None = None) -> dict[str, Any]:
165+
"""Get comprehensive pricing information for an instance type.
166+
167+
Args:
168+
instance_type: The EC2 instance type
169+
region: AWS region code. If None, uses the current session region.
170+
171+
Returns:
172+
Dictionary with 'hourly', 'monthly', and formatted strings
173+
"""
174+
hourly = get_instance_price(instance_type, region)
175+
monthly = get_monthly_estimate(hourly)
176+
177+
return {
178+
"hourly": hourly,
179+
"monthly": monthly,
180+
"hourly_formatted": format_price(hourly),
181+
"monthly_formatted": format_price(monthly),
182+
}
183+
184+
185+
def clear_price_cache() -> None:
186+
"""Clear the pricing cache.
187+
188+
Useful for testing or when you want to refresh pricing data.
189+
"""
190+
get_instance_price.cache_clear()

specs/issue-22-instance-pricing.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Issue 22: Add Instance Pricing
22

3-
**Status:** Not started
3+
**Status:** COMPLETED
44
**Priority:** Low (v0.5.0)
55
**GitHub Issue:** #32
66

@@ -58,8 +58,8 @@ db-server i-0123456789abcdef1 running r5.large $0.126 $90.72
5858

5959
## Acceptance Criteria
6060

61-
- [ ] Add pricing column to `remote list` output
62-
- [ ] Cache pricing data to reduce API calls
63-
- [ ] Handle missing/unavailable pricing gracefully
64-
- [ ] Add `--no-pricing` flag to skip pricing lookup
65-
- [ ] Add tests with mocked pricing responses
61+
- [x] Add pricing column to `remote list` output
62+
- [x] Cache pricing data to reduce API calls
63+
- [x] Handle missing/unavailable pricing gracefully
64+
- [x] Add `--no-pricing` flag to skip pricing lookup
65+
- [x] Add tests with mocked pricing responses

tests/test_instance.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_should_show_table_headers_when_no_instances_exist(self, mocker):
3636
mock_paginator.paginate.return_value = [{"Reservations": []}]
3737
mock_ec2_client.return_value.get_paginator.return_value = mock_paginator
3838

39-
result = runner.invoke(app, ["list"])
39+
result = runner.invoke(app, ["list", "--no-pricing"])
4040

4141
assert result.exit_code == 0
4242
assert "Name" in result.stdout
@@ -53,6 +53,10 @@ def test_should_display_instance_details_when_instances_exist(self, mocker, mock
5353
mock_paginator.paginate.return_value = [mock_ec2_instances]
5454
mock_ec2_client.return_value.get_paginator.return_value = mock_paginator
5555

56+
# Mock pricing to avoid actual API calls
57+
mocker.patch("remotepy.instance.get_instance_price", return_value=0.0104)
58+
mocker.patch("remotepy.instance.get_monthly_estimate", return_value=7.59)
59+
5660
result = runner.invoke(app, ["list"])
5761

5862
# Verify paginator was used
@@ -72,6 +76,81 @@ def test_should_display_instance_details_when_instances_exist(self, mocker, mock
7276
assert "t2.micro" in result.stdout
7377
assert "2023-07-15 00:00:00 UTC" in result.stdout
7478

79+
def test_should_show_pricing_columns_by_default(self, mocker):
80+
"""Should display pricing columns when --no-pricing is not specified."""
81+
mock_ec2_client = mocker.patch("remotepy.utils.get_ec2_client")
82+
mock_paginator = mocker.MagicMock()
83+
mock_paginator.paginate.return_value = [{"Reservations": []}]
84+
mock_ec2_client.return_value.get_paginator.return_value = mock_paginator
85+
86+
result = runner.invoke(app, ["list"])
87+
88+
assert result.exit_code == 0
89+
assert "$/hr" in result.stdout
90+
assert "$/month" in result.stdout
91+
92+
def test_should_hide_pricing_columns_with_no_pricing_flag(self, mocker):
93+
"""Should not display pricing columns when --no-pricing is specified."""
94+
mock_ec2_client = mocker.patch("remotepy.utils.get_ec2_client")
95+
mock_paginator = mocker.MagicMock()
96+
mock_paginator.paginate.return_value = [{"Reservations": []}]
97+
mock_ec2_client.return_value.get_paginator.return_value = mock_paginator
98+
99+
result = runner.invoke(app, ["list", "--no-pricing"])
100+
101+
assert result.exit_code == 0
102+
assert "$/hr" not in result.stdout
103+
assert "$/month" not in result.stdout
104+
105+
def test_should_display_pricing_data_for_instances(self, mocker, mock_ec2_instances):
106+
"""Should show pricing information for each instance type."""
107+
mock_ec2_client = mocker.patch("remotepy.utils.get_ec2_client")
108+
mock_paginator = mocker.MagicMock()
109+
mock_paginator.paginate.return_value = [mock_ec2_instances]
110+
mock_ec2_client.return_value.get_paginator.return_value = mock_paginator
111+
112+
# Mock pricing functions
113+
mocker.patch("remotepy.instance.get_instance_price", return_value=0.0104)
114+
mocker.patch("remotepy.instance.get_monthly_estimate", return_value=7.59)
115+
116+
result = runner.invoke(app, ["list"])
117+
118+
assert result.exit_code == 0
119+
# format_price formats 0.0104 as "$0.01" since it's >= 0.01 (uses 2 decimal places)
120+
assert "$0.01" in result.stdout
121+
assert "$7.59" in result.stdout
122+
123+
def test_should_handle_unavailable_pricing_gracefully(self, mocker, mock_ec2_instances):
124+
"""Should display dash when pricing is unavailable."""
125+
mock_ec2_client = mocker.patch("remotepy.utils.get_ec2_client")
126+
mock_paginator = mocker.MagicMock()
127+
mock_paginator.paginate.return_value = [mock_ec2_instances]
128+
mock_ec2_client.return_value.get_paginator.return_value = mock_paginator
129+
130+
# Mock pricing to return None (unavailable)
131+
mocker.patch("remotepy.instance.get_instance_price", return_value=None)
132+
mocker.patch("remotepy.instance.get_monthly_estimate", return_value=None)
133+
134+
result = runner.invoke(app, ["list"])
135+
136+
assert result.exit_code == 0
137+
# format_price returns "-" for None values
138+
assert "-" in result.stdout
139+
140+
def test_should_not_call_pricing_api_with_no_pricing_flag(self, mocker, mock_ec2_instances):
141+
"""Should skip pricing API calls when --no-pricing flag is used."""
142+
mock_ec2_client = mocker.patch("remotepy.utils.get_ec2_client")
143+
mock_paginator = mocker.MagicMock()
144+
mock_paginator.paginate.return_value = [mock_ec2_instances]
145+
mock_ec2_client.return_value.get_paginator.return_value = mock_paginator
146+
147+
mock_get_price = mocker.patch("remotepy.instance.get_instance_price")
148+
149+
result = runner.invoke(app, ["list", "--no-pricing"])
150+
151+
assert result.exit_code == 0
152+
mock_get_price.assert_not_called()
153+
75154

76155
class TestLaunchTemplateUtilities:
77156
"""Test launch template utility functions."""

0 commit comments

Comments
 (0)