diff --git a/awscli/botocore/service_module_names.py b/awscli/botocore/service_module_names.py new file mode 100644 index 000000000000..7224711aae79 --- /dev/null +++ b/awscli/botocore/service_module_names.py @@ -0,0 +1,129 @@ +# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +"""Service module names that differ from the name derived from ``serviceId``. + +Keyed by ``serviceId``. An entry exists either because the service shipped +with that name before names were derived from ``serviceId``, or because the +derived name collided with an existing one. + +Module names are public. Never change a value while the service ships; +remove an entry only when its models are removed from ``botocore/data``. +""" + +SERVICE_MODULE_NAMES = { + 'amp': 'PrometheusService', + 'AppIntegrations': 'AppIntegrationsService', + 'Application Signals': 'CloudWatchApplicationSignals', + 'b2bi': 'B2BI', + 'BCM Dashboards': 'BillingandCostManagementDashboards', + 'BCM Data Exports': 'BillingandCostManagementDataExports', + 'BCM Pricing Calculator': 'BillingandCostManagementPricingCalculator', + 'BCM Recommended Actions': 'BillingandCostManagementRecommendedActions', + 'Bedrock Agent': 'AgentsforBedrock', + 'Bedrock Agent Runtime': 'AgentsforBedrockRuntime', + 'Bedrock Data Automation': 'DataAutomationforBedrock', + 'Bedrock Data Automation Runtime': 'RuntimeforBedrockDataAutomation', + 'billingconductor': 'BillingConductor', + 'chatbot': 'Chatbot', + 'CleanRooms': 'CleanRoomsService', + 'CloudControl': 'CloudControlApi', + 'CloudTrail Data': 'CloudTrailDataService', + 'codeartifact': 'CodeArtifact', + 'codestar notifications': 'CodeStarNotifications', + 'ConnectCampaigns': 'ConnectCampaignService', + 'ConnectCampaignsV2': 'ConnectCampaignServiceV2', + 'DataBrew': 'GlueDataBrew', + 'deadline': 'DeadlineCloud', + 'DevOps Agent': 'DevOpsAgentService', + 'DSQL': 'AuroraDSQL', + 'ECR PUBLIC': 'ECRPublic', + 'EMR containers': 'EMRContainers', + 'evs': 'EVS', + 'fis': 'FIS', + 'forecast': 'ForecastService', + 'forecastquery': 'ForecastQueryService', + 'Geo Maps': 'LocationServiceMapsV2', + 'Geo Places': 'LocationServicePlacesV2', + 'Geo Routes': 'LocationServiceRoutesV2', + 'grafana': 'ManagedGrafana', + 'IAM Toolbox': 'IAMToolboxPreview', + 'identitystore': 'IdentityStore', + 'Inspector Scan': 'inspectorscan', + 'InternetMonitor': 'CloudWatchInternetMonitor', + 'IoT Managed Integrations': 'ManagedintegrationsforIoTDeviceManagement', + 'IotDeviceAdvisor': 'IoTDeviceAdvisor', + 'ivs': 'IVS', + 'IVS RealTime': 'ivsrealtime', + 'Kinesis Video Signaling': 'KinesisVideoSignalingChannels', + 'Lambda Microvms': 'LambdaMicroVMs', + 'Location': 'LocationService', + 'm2': 'MainframeModernization', + 'Marketplace Agreement': 'AgreementService', + 'Marketplace Deployment': 'MarketplaceDeploymentService', + 'Marketplace Reporting': 'MarketplaceReportingService', + 'MediaPackageV2': 'mediapackagev2', + 'Medical Imaging': 'HealthImaging', + 'MigrationHubStrategy': 'MigrationHubStrategyRecommendations', + 'MPA': 'MultipartyApproval', + 'mq': 'MQ', + 'neptunedata': 'NeptuneData', + 'Network Security Manager': 'NetworkSecurityManagerCustomerAPI', + 'NetworkMonitor': 'CloudWatchNetworkMonitor', + 'Notifications': 'UserNotifications', + 'NotificationsContacts': 'UserNotificationsContacts', + 'Nova Act': 'NovaActService', + 'OAM': 'CloudWatchObservabilityAccessManager', + 'ObservabilityAdmin': 'CloudWatchObservabilityAdminService', + 'OpenSearch': 'OpenSearchService', + 'OpenSearchServerless': 'OpenSearchServiceServerless', + 'OSIS': 'OpenSearchIngestion', + 'PartnerCentral Account': 'PartnerCentralAccountAPI', + 'PartnerCentral Channel': 'PartnerCentralChannelAPI', + 'PartnerCentral Revenue Measurement': 'PartnerCentralRevenueMeasurementAPI', + 'PartnerCentral Selling': 'PartnerCentralSellingAPI', + 'Payment Cryptography': 'PaymentCryptographyControlPlane', + 'Payment Cryptography Data': 'PaymentCryptographyDataPlane', + 'Pca Connector Scep': 'PrivateCAConnectorforSCEP', + 'PCS': 'ParallelComputingService', + 'Pipes': 'EventBridgePipes', + 'rbin': 'RecycleBin', + 'RDS Data': 'RDSDataService', + 'Redshift Data': 'RedshiftDataAPIService', + 'repostspace': 'rePostPrivate', + 'resiliencehub': 'ResilienceHub', + 'resiliencehubv2': 'ResilienceHubV2', + 'Resource Explorer 2': 'ResourceExplorer', + 'RolesAnywhere': 'IAMRolesAnywhere', + 'RUM': 'CloudWatchRUM', + 'SageMaker A2I Runtime': 'AugmentedAIRuntime', + 'Sagemaker Edge': 'SagemakerEdgeManager', + 'SageMaker Geospatial': 'SageMakergeospatialcapabilities', + 'SagemakerJobRuntime': 'SagemakerJobRuntimeService', + 'savingsplans': 'SavingsPlans', + 'Scheduler': 'EventBridgeScheduler', + 'schemas': 'Schemas', + 'Security IR': 'SecurityIncidentResponse', + 'Service Catalog AppRegistry': 'AppRegistry', + 'SESv2': 'SESV2', + 'Signer Data': 'SignerDataPlane', + 'Signin': 'SignInService', + 'SocialMessaging': 'EndUserMessagingSocial', + 'SSM GuiConnect': 'SSMGUIConnect', + 'SSM QuickSetup': 'SystemsManagerQuickSetup', + 'synthetics': 'Synthetics', + 'Transcribe': 'TranscribeService', + 'TrustedAdvisor': 'TrustedAdvisorPublicAPI', + 'uxc': 'UserExperienceCustomization', + 'Wickr': 'WickrAdminAPI', + 'Wisdom': 'ConnectWisdomService', +} diff --git a/awscli/botocore/utils.py b/awscli/botocore/utils.py index 50a728948f0e..4fbef3e89efb 100644 --- a/awscli/botocore/utils.py +++ b/awscli/botocore/utils.py @@ -79,6 +79,7 @@ UnsupportedS3ControlArnError, UnsupportedS3ControlConfigurationError, ) +from botocore.service_module_names import SERVICE_MODULE_NAMES from botocore.useragent import register_feature_id from dateutil.tz import tzutc from urllib3.exceptions import LocationParseError @@ -218,20 +219,33 @@ def has_header(header_name, headers): return header_name.lower() in [key.lower() for key in headers.keys()] +_TABLED_MODULE_NAMES = frozenset(SERVICE_MODULE_NAMES.values()) + + def get_service_module_name(service_model): """Returns the module name for a service - This is the value used in both the documentation and client class name + This is the value used in both the documentation and client class name. + + The name is taken from ``SERVICE_MODULE_NAMES`` when the ``serviceId`` + has an entry there, otherwise derived from the ``serviceId`` itself. + + :raises ValueError: if the derived name is already used by an entry in + ``SERVICE_MODULE_NAMES``. """ - name = service_model.metadata.get( - 'serviceAbbreviation', - service_model.metadata.get( - 'serviceFullName', service_model.service_name - ), - ) - name = name.replace('Amazon', '') - name = name.replace('AWS', '') - name = re.sub(r'\W+', '', name) + service_id = service_model.metadata.get('serviceId') + if service_id is None: + return service_model.service_name + if service_id in SERVICE_MODULE_NAMES: + return SERVICE_MODULE_NAMES[service_id] + name = re.sub(r'\W+', '', service_id) + if name in _TABLED_MODULE_NAMES: + raise ValueError( + f'serviceId {service_id!r} derives the module name {name!r}, ' + f'which is already used by another service in ' + f'SERVICE_MODULE_NAMES. Record a different name for ' + f'{service_id!r} in that table.' + ) return name diff --git a/tests/functional/botocore/test_service_module_names.py b/tests/functional/botocore/test_service_module_names.py new file mode 100644 index 000000000000..5d462bb6a024 --- /dev/null +++ b/tests/functional/botocore/test_service_module_names.py @@ -0,0 +1,91 @@ +# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import copy + +import pytest +from botocore.loaders import Loader +from botocore.model import ServiceModel +from botocore.service_module_names import SERVICE_MODULE_NAMES +from botocore.utils import get_service_module_name + +LOADER = Loader() +DOCGEN_KEYS = ('serviceFullName', 'serviceAbbreviation') +SENTINEL = 'AWS Renamed Service' + + +def _all_service_models(): + for service_name in LOADER.list_available_services('service-2'): + for api_version in LOADER.list_api_versions(service_name, 'service-2'): + yield service_name, api_version + + +def _load(service_name, api_version): + description = LOADER.load_service_model( + service_name, 'service-2', api_version + ) + return ServiceModel(description, service_name) + + +def _with_docgen_sentinels(service_model): + description = copy.deepcopy(service_model._service_description) + for key in DOCGEN_KEYS: + description['metadata'][key] = SENTINEL + return ServiceModel(description, service_model.service_name) + + +@pytest.mark.parametrize( + 'service_name, api_version', list(_all_service_models()) +) +def test_module_name_ignores_docgen_ids(service_name, api_version): + model = _load(service_name, api_version) + expected = get_service_module_name(model) + actual = get_service_module_name(_with_docgen_sentinels(model)) + assert actual == expected, ( + f'{service_name}/{api_version}: module name changed from ' + f'{expected!r} to {actual!r} when documentation ids changed' + ) + + +@pytest.mark.parametrize( + 'service_name, api_version', list(_all_service_models()) +) +def test_every_shipped_model_has_service_id(service_name, api_version): + model = _load(service_name, api_version) + assert model.metadata.get('serviceId'), ( + f'{service_name}/{api_version} has no serviceId; the module name ' + f'would fall back to the model directory name' + ) + + +def test_table_keys_are_shipped_service_ids(): + shipped = { + _load(s, v).metadata.get('serviceId') for s, v in _all_service_models() + } + unknown = set(SERVICE_MODULE_NAMES) - shipped + assert not unknown, f'Table entries for unknown serviceIds: {unknown}' + + +def test_no_two_services_share_module_name(): + service_ids_by_name = {} + for service_name, api_version in _all_service_models(): + model = _load(service_name, api_version) + name = get_service_module_name(model) + service_ids_by_name.setdefault(name, set()).add( + model.metadata['serviceId'] + ) + collisions = { + name: sorted(service_ids) + for name, service_ids in service_ids_by_name.items() + if len(service_ids) > 1 + } + assert not collisions, f'Module name collisions: {collisions}' diff --git a/tests/unit/botocore/test_client.py b/tests/unit/botocore/test_client.py index b2b1b1ad3216..31e93510f93f 100644 --- a/tests/unit/botocore/test_client.py +++ b/tests/unit/botocore/test_client.py @@ -221,45 +221,35 @@ def test_client_name(self): service_client = creator.create_client( 'myservice', 'us-west-2', credentials=self.credentials ) - self.assertTrue(service_client.__class__.__name__, 'MyService') + self.assertEqual(service_client.__class__.__name__, 'MyService') - def test_client_name_with_amazon(self): - self.service_description['metadata']['serviceFullName'] = ( - 'Amazon MyService' - ) + def test_client_name_from_service_id_with_spaces(self): + self.service_description['metadata']['serviceId'] = 'My Big Service' creator = self.create_client_creator() service_client = creator.create_client( 'myservice', 'us-west-2', credentials=self.credentials ) - self.assertTrue(service_client.__class__.__name__, 'MyService') + self.assertEqual(service_client.__class__.__name__, 'MyBigService') - def test_client_name_using_abreviation(self): - self.service_description['metadata']['serviceAbbreviation'] = ( - 'Abbreviation' - ) - creator = self.create_client_creator() - service_client = creator.create_client( - 'myservice', 'us-west-2', credentials=self.credentials - ) - self.assertTrue(service_client.__class__.__name__, 'Abbreviation') - - def test_client_name_with_non_alphabet_characters(self): + def test_client_name_ignores_full_name(self): self.service_description['metadata']['serviceFullName'] = ( - 'Amazon My-Service' + 'Amazon Renamed Service' ) creator = self.create_client_creator() service_client = creator.create_client( 'myservice', 'us-west-2', credentials=self.credentials ) - self.assertTrue(service_client.__class__.__name__, 'MyService') + self.assertEqual(service_client.__class__.__name__, 'MyService') - def test_client_name_with_no_full_name_or_abbreviation(self): - del self.service_description['metadata']['serviceFullName'] + def test_client_name_ignores_abbreviation(self): + self.service_description['metadata']['serviceAbbreviation'] = ( + 'Abbreviation' + ) creator = self.create_client_creator() service_client = creator.create_client( 'myservice', 'us-west-2', credentials=self.credentials ) - self.assertTrue(service_client.__class__.__name__, 'myservice') + self.assertEqual(service_client.__class__.__name__, 'MyService') def test_client_generated_from_model(self): creator = self.create_client_creator() diff --git a/tests/unit/botocore/test_errorfactory.py b/tests/unit/botocore/test_errorfactory.py index 6c850aadbd40..2c350b8c4867 100644 --- a/tests/unit/botocore/test_errorfactory.py +++ b/tests/unit/botocore/test_errorfactory.py @@ -51,6 +51,7 @@ def setUp(self): self.model = { "metadata": { 'endpointPrefix': 'myservice', + 'serviceId': 'MyService', 'serviceFullName': 'MyService', }, 'operations': { diff --git a/tests/unit/botocore/test_utils.py b/tests/unit/botocore/test_utils.py index 9f225d78d9ab..1971c6143a6b 100644 --- a/tests/unit/botocore/test_utils.py +++ b/tests/unit/botocore/test_utils.py @@ -1434,7 +1434,9 @@ class TestGetServiceModuleName(unittest.TestCase): def setUp(self): self.service_description = { 'metadata': { + 'serviceId': 'My Service', 'serviceFullName': 'AWS MyService', + 'serviceAbbreviation': 'Abbreviation', 'apiVersion': '2014-01-01', 'endpointPrefix': 'myservice', 'signatureVersion': 'v4', @@ -1447,41 +1449,66 @@ def setUp(self): self.service_description, 'myservice' ) - def test_default(self): + def test_derived_from_service_id(self): self.assertEqual( get_service_module_name(self.service_model), 'MyService' ) - def test_client_name_with_amazon(self): - self.service_description['metadata']['serviceFullName'] = ( - 'Amazon MyService' - ) + def test_service_id_spaces_are_removed(self): + self.service_description['metadata']['serviceId'] = 'My Big Service' self.assertEqual( - get_service_module_name(self.service_model), 'MyService' + get_service_module_name(self.service_model), 'MyBigService' ) - def test_client_name_using_abreviation(self): - self.service_description['metadata']['serviceAbbreviation'] = ( - 'Abbreviation' - ) + def test_service_id_casing_is_preserved(self): + self.service_description['metadata']['serviceId'] = 'API Gateway' self.assertEqual( - get_service_module_name(self.service_model), 'Abbreviation' + get_service_module_name(self.service_model), 'APIGateway' ) - def test_client_name_with_non_alphabet_characters(self): + def test_docgen_ids_are_not_read(self): self.service_description['metadata']['serviceFullName'] = ( - 'Amazon My-Service' + 'Amazon Renamed' ) + self.service_description['metadata']['serviceAbbreviation'] = 'Renamed' self.assertEqual( get_service_module_name(self.service_model), 'MyService' ) - def test_client_name_with_no_full_name_or_abbreviation(self): - del self.service_description['metadata']['serviceFullName'] + def test_table_wins_over_derivation(self): + # 'Transcribe' shipped as TranscribeService before the table + # existed and must keep that name. + self.service_description['metadata']['serviceId'] = 'Transcribe' + self.assertEqual( + get_service_module_name(self.service_model), + 'TranscribeService', + ) + + def test_table_key_is_exact(self): + self.service_description['metadata']['serviceId'] = 'transcribe' + self.assertEqual( + get_service_module_name(self.service_model), 'transcribe' + ) + + def test_no_service_id_falls_back_to_service_name(self): + del self.service_description['metadata']['serviceId'] self.assertEqual( get_service_module_name(self.service_model), 'myservice' ) + def test_derived_name_colliding_with_tabled_name_raises(self): + # 'Medical Imaging' is tabled as HealthImaging. A new service whose + # serviceId derives the same name must fail rather than share it. + self.service_description['metadata']['serviceId'] = 'Health Imaging' + with self.assertRaisesRegex(ValueError, 'HealthImaging'): + get_service_module_name(self.service_model) + + def test_tabled_service_does_not_collide_with_itself(self): + self.service_description['metadata']['serviceId'] = 'Medical Imaging' + self.assertEqual( + get_service_module_name(self.service_model), 'HealthImaging' + ) + class TestPercentEncodeSequence(unittest.TestCase): def test_percent_encode_empty(self):