Skip to content

Commit f0ac744

Browse files
committed
feat: Support AmiProduct download CLI
Add a `download` CLI command to get configuration files from listings in AWS Marketplace. The downloaded configuration files have a same foramt as the local YAML config file. This can be useful for users who do not have a the full configuration but request a changes to an existing listing. The LiteralString class is introduced to dump strings in literal block style(|) in the YAML file.
1 parent 1915b8a commit f0ac744

5 files changed

Lines changed: 177 additions & 13 deletions

File tree

awsmp/cli.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import yaml
1212
from botocore.exceptions import ClientError
1313

14-
from . import _driver, models
14+
from . import _driver, models, yaml_utils
1515
from .errors import (
1616
AccessDeniedException,
1717
NoProductIdProvidedException,
@@ -443,6 +443,46 @@ def ami_product_update(product_id: str, config: TextIO, allow_price_change: bool
443443
print(f'https://aws.amazon.com/marketplace/management/requests/{response["ChangeSetId"]}')
444444

445445

446+
@public_offer.command("download")
447+
@click.option("--product-id", required=True, prompt=True, help="Product id of the listing")
448+
@click.option(
449+
"--config", type=click.File("w+"), required=True, prompt=True, help="File path of local configuration file"
450+
)
451+
def ami_product_download(product_id: str, config: TextIO) -> None:
452+
"""
453+
Download YAML local configuration from AWS Marketplace live listing.
454+
:prarm str product_id: Id of listing
455+
:param TextIO config: file path of local configuration file to download
456+
:return: None
457+
:rtype: None
458+
"""
459+
460+
# Get product specific details
461+
listing_resp = _driver.get_entity_details(product_id)
462+
# Remove version output except latest version
463+
# Since local yaml file only store one version information
464+
versions = listing_resp["Versions"]
465+
latest_version = sorted(versions, key=lambda x: x["CreationDate"])[-1]
466+
listing_resp["Versions"] = latest_version
467+
468+
# Get offer specific details
469+
offer_id = _driver.get_public_offer_id(product_id)
470+
listing_offer_resp = _driver.get_entity_details(offer_id)
471+
472+
# filtering required term details only
473+
listing_resp["Terms"] = []
474+
term_order = {"SupportTerm": 0, "UsageBasedPricingTerm": 1, "ConfigurableUpfrontPricingTerm": 2}
475+
if "Terms" in listing_offer_resp:
476+
listing_resp["Terms"] = sorted(
477+
[term for term in listing_offer_resp.get("Terms", []) if term["Type"] in term_order],
478+
key=lambda x: term_order.get(x["Type"], 3),
479+
)
480+
481+
yaml_config = models.EntityModel(**listing_resp).get_yaml_from_entity()
482+
yaml_utils.dump_yaml(yaml_config, config)
483+
print(f"{config.name} has been successfully written")
484+
485+
446486
def _load_configuration(config_path: TextIO, required_fields: List[List[str]]) -> Dict:
447487
"""
448488
Check if keys exist in config file before creating changeset and return config dict

awsmp/models.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
)
3030

3131
from .constants import CATEGORIES
32+
from .yaml_utils import LiteralString
3233

3334

3435
class InstanceTypePricing(BaseModel):
@@ -372,8 +373,8 @@ def to_dict(self) -> dict[str, Any]:
372373
"""
373374
return {
374375
"product_title": self.ProductTitle,
375-
"short_description": self.ShortDescription,
376-
"long_description": self.LongDescription,
376+
"short_description": LiteralString(self.ShortDescription),
377+
"long_description": LiteralString(self.LongDescription),
377378
"sku": self.Sku,
378379
"highlights": self.Highlights,
379380
"search_keywords": self.SearchKeywords,
@@ -439,7 +440,7 @@ def to_dict(self) -> dict[str, Any]:
439440
:return: Dictionary of support information
440441
:rtype: dict[str, Any]
441442
"""
442-
return {"support_description": self.Description, "support_resources": self.Resources}
443+
return {"support_description": LiteralString(self.Description), "support_resources": self.Resources}
443444

444445

445446
class RegionAvailabilityModel(BaseModel):
@@ -620,7 +621,7 @@ def to_dict(self) -> dict[str, Any]:
620621
:return: Dictionary of version information
621622
:rtype: dict[str, Any]
622623
"""
623-
return {**{"usage_instructions": self.Instructions["Usage"]}, **self.Recommendations.to_dict()}
624+
return {**{"usage_instructions": LiteralString(self.Instructions["Usage"])}, **self.Recommendations.to_dict()}
624625

625626

626627
class VersionModel(BaseModel):
@@ -717,17 +718,17 @@ def to_dict(self) -> dict[str, Any]:
717718
:rtype: dcit[str, Any]
718719
"""
719720
description_configs = {
720-
**self.Description._to_yaml(),
721-
**self.PromotionalResources._to_yaml(),
722-
**self.SupportInformation._to_yaml(),
721+
**self.Description.to_dict(),
722+
**self.PromotionalResources.to_dict(),
723+
**self.SupportInformation.to_dict(),
723724
}
724725
config = {
725726
"product": {
726727
"description": description_configs,
727-
"region": self.RegionAvailability._to_yaml(),
728-
"version": self.Versions._to_yaml()
728+
"region": self.RegionAvailability.to_dict(),
729+
"version": self.Versions.to_dict(),
729730
},
730-
"offer": self._convert_terms_to_yaml(),
731+
"offer": self._convert_terms_to_dict(),
731732
}
732733

733734
return config
@@ -742,7 +743,7 @@ def _convert_terms_to_dict(self) -> dict[str, Any]:
742743

743744
for term in self.Terms:
744745
if term.Type == "SupportTerm":
745-
yaml_config["refund_policy"] = term.RefundPolicy
746+
yaml_config["refund_policy"] = LiteralString(term.RefundPolicy)
746747
else:
747748
# Pricing term
748749
if term.Type == "UsageBasedPricingTerm":

awsmp/yaml_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import yaml
2+
from yaml.dumper import SafeDumper
3+
4+
5+
class LiteralString(str):
6+
pass
7+
8+
9+
def literal_str_representer(dumper, data):
10+
text = data if data.endswith("\n") else data + "\n"
11+
return dumper.represent_scalar("tag:yaml.org,2002:str", text, style="|")
12+
13+
14+
class IndentListDumper(SafeDumper):
15+
def increase_indent(self, flow=False, indentless=False):
16+
return super().increase_indent(flow, False)
17+
18+
19+
yaml.add_representer(LiteralString, literal_str_representer, Dumper=IndentListDumper)
20+
21+
22+
def dump_yaml(data, config):
23+
yaml.dump(data, config, Dumper=IndentListDumper, default_flow_style=False, indent=2, sort_keys=False)

docs/public-offer/index.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,5 +241,17 @@ To update AMI product listing with multiple requests for product details (Descri
241241
--config listing_configuration.yaml
242242
243243
244+
Download AMI product listing details
245+
------------------------------------
246+
247+
To download AMI product listing information as a YAML file, run the command below, passing the product ID and product configuration file:
248+
249+
.. code-block:: sh
250+
251+
awsmp public-offer download \
252+
--product-id prod-fwu3xsqup23cs
253+
--config listing.yaml
254+
255+
244256
.. _`AWS marketplace management portal`: https://aws.amazon.com/marketplace/management/
245257
.. _`AWS Marketplace update legal resources API reference`: https://docs.aws.amazon.com/marketplace/latest/APIReference/work-with-private-offers.html#update-legal-terms

tests/test_cli.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import json
22
import tempfile
3+
from io import StringIO
34
from typing import Any, List
4-
from unittest.mock import MagicMock, patch
5+
from unittest.mock import MagicMock, mock_open, patch
56

67
import pytest
78
import yaml
@@ -894,3 +895,90 @@ def test_public_offer_product_update_instance_type_pricing_change_exception(
894895
["--product-id", "some-prod-id", "--config", "./tests/test_config.yaml", "--no-allow-price-change"],
895896
)
896897
assert res.exit_code == 1 and res.exc_info is not None and "Restricted listings" in res.exc_info[1].args[0]
898+
899+
900+
@pytest.mark.parametrize(
901+
"key1, key2, value",
902+
[
903+
("description", "product_title", "test"),
904+
("description", "categories", ["Migration"]),
905+
("description", "long_description", "test_long_description\n"),
906+
("version", "version_title", "Test Ubuntu AMI"),
907+
("version", "usage_instructions", "test_usage_instruction\n"),
908+
("version", "ami_id", "ami-12345678910"),
909+
("description", "support_description", "test_support_description\n"),
910+
("region", "commercial_regions", ["us-east-1", "us-east-2"]),
911+
("region", "future_region_support", True),
912+
],
913+
)
914+
@patch("awsmp._driver.get_entity_details")
915+
@patch("awsmp._driver.get_public_offer_id")
916+
def test_public_offer_product_download_product(mock_get_public_offer_id, mock_get_entity_details, key1, key2, value):
917+
with open("./tests/test_config.json") as f:
918+
mock_prod_resp = json.load(f)
919+
mock_prod_resp.pop("Terms")
920+
921+
mock_prod_resp["Versions"]["CreationDate"] = "2025-01-01"
922+
mock_prod_resp["Versions"] = [mock_prod_resp["Versions"]]
923+
924+
with open("./tests/test_config.json") as f:
925+
mock_offer_resp = {"Terms": json.load(f)["Terms"]}
926+
927+
mock_get_entity_details.side_effect = [mock_prod_resp, mock_offer_resp]
928+
mock_get_public_offer_id.return_value = "test-offer-id"
929+
930+
runner = CliRunner()
931+
config_file = tempfile.NamedTemporaryFile()
932+
res = runner.invoke(
933+
cli.ami_product_download,
934+
["--product-id", "some-prod-id", "--config", config_file.name],
935+
)
936+
937+
with open(config_file.name, "r") as f:
938+
config = yaml.safe_load(f)
939+
940+
assert config["product"][key1][key2] == value
941+
942+
943+
@pytest.mark.parametrize(
944+
"key1, key2, value",
945+
[
946+
("offer", "refund_policy", "test_refund_policy_term\n"),
947+
(
948+
"offer",
949+
"instance_types",
950+
[
951+
{"name": "a1.large", "hourly": "0.004", "yearly": "24.528"},
952+
{"name": "a1.xlarge", "hourly": "0.007", "yearly": "49.056"},
953+
],
954+
),
955+
("offer", "eula_document", [{"type": ""}]),
956+
],
957+
)
958+
@patch("awsmp._driver.get_entity_details")
959+
@patch("awsmp._driver.get_public_offer_id")
960+
def test_public_offer_product_download_offer(mock_get_public_offer_id, mock_get_entity_details, key1, key2, value):
961+
with open("./tests/test_config.json") as f:
962+
mock_prod_resp = json.load(f)
963+
mock_prod_resp.pop("Terms")
964+
965+
mock_prod_resp["Versions"]["CreationDate"] = "2025-01-01"
966+
mock_prod_resp["Versions"] = [mock_prod_resp["Versions"]]
967+
968+
with open("./tests/test_config.json") as f:
969+
mock_offer_resp = {"Terms": json.load(f)["Terms"]}
970+
971+
mock_get_entity_details.side_effect = [mock_prod_resp, mock_offer_resp]
972+
mock_get_public_offer_id.return_value = "test-offer-id"
973+
974+
runner = CliRunner()
975+
config_file = tempfile.NamedTemporaryFile()
976+
res = runner.invoke(
977+
cli.ami_product_download,
978+
["--product-id", "some-prod-id", "--config", config_file.name],
979+
)
980+
981+
with open(config_file.name, "r") as f:
982+
config = yaml.safe_load(f)
983+
984+
assert config[key1][key2] == value

0 commit comments

Comments
 (0)