Skip to content

Commit 6972c42

Browse files
authored
add util function to enable transparent local boto3 endpoints (#40)
1 parent 6c1b213 commit 6972c42

File tree

12 files changed

+426
-204
lines changed

12 files changed

+426
-204
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jobs:
1313
fail-fast: false
1414
matrix:
1515
python-version:
16+
- "3.10"
1617
- "3.9"
1718
- "3.8"
1819
- "3.7"

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# LocalStack Python Client Change Log
22

3+
* v1.38: Add `enable_local_endpoints()` util function; slight project refactoring, migrate from `nose` to `pytests`
34
* v1.37: Add endpoint for Amazon Transcribe
45
* v1.36: Add endpoints for Fault Injection Service (FIS) and Marketplace Metering
56
* v1.35: Add endpoint for Amazon Managed Workflows for Apache Airflow (MWAA)

Makefile

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,19 @@ install: ## Install dependencies in local virtualenv folder
1414

1515
publish: ## Publish the library to the central PyPi repository
1616
# build and upload archive
17-
($(VENV_RUN); ./setup.py sdist && twine upload $(BUILD_DIR)/*.tar.gz)
17+
$(VENV_RUN); ./setup.py sdist && twine upload $(BUILD_DIR)/*.tar.gz
1818

1919
test: ## Run automated tests
2020
($(VENV_RUN); test `which localstack` || pip install .[test]) && \
21-
$(VENV_RUN); DEBUG=$(DEBUG) PYTHONPATH=`pwd` nosetests --with-coverage --logging-level=WARNING --nocapture --no-skip --exe --cover-erase --cover-tests --cover-inclusive --cover-package=localstack_client --with-xunit --exclude='$(VENV_DIR).*' .
21+
$(VENV_RUN); DEBUG=$(DEBUG) PYTHONPATH=. pytest -sv $(PYTEST_ARGS) tests
2222

2323
lint: ## Run code linter to check code style
24-
($(VENV_RUN); pycodestyle --max-line-length=100 --ignore=E128 --exclude=node_modules,legacy,$(VENV_DIR),dist .)
24+
$(VENV_RUN); flake8 --ignore=E501 localstack_client tests
25+
26+
format: ## Run code formatter (black)
27+
$(VENV_RUN); black localstack_client tests; isort localstack_client tests
2528

2629
clean: ## Clean up virtualenv
2730
rm -rf $(VENV_DIR)
2831

29-
.PHONY: usage install clean publish test lint
32+
.PHONY: usage install clean publish test lint format

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,27 @@ sqs = boto3.client('sqs')
6666
assert sqs.list_queues() is not None # list SQS in localstack
6767
```
6868

69+
### Enabling Transparent Local Endpoints
70+
71+
The library contains a small `enable_local_endpoints()` util function that can be used to transparently run all `boto3` requests against the local endpoints.
72+
73+
The following sample illustrates how it can be used - after calling `enable_local_endpoints()`, the S3 `ListBuckets` call will be run against LocalStack, even though we're using the default boto3 module.
74+
```
75+
import boto3
76+
from localstack_client.patch import enable_local_endpoints()
77+
enable_local_endpoints()
78+
# the call below will automatically target the LocalStack endpoints
79+
buckets = boto3.client("s3").list_buckets()
80+
```
81+
82+
The patch can also be unapplied by calling `disable_local_endpoints()`:
83+
```
84+
from localstack_client.patch import disable_local_endpoints()
85+
disable_local_endpoints()
86+
# the call below will target the real AWS cloud again
87+
buckets = boto3.client("s3").list_buckets()
88+
```
89+
6990
## Contributing
7091

7192
If you are interested in contributing to LocalStack Python Client, start by reading our [`CONTRIBUTING.md`](CONTRIBUTING.md) guide. You can further navigate our codebase and [open issues](https://github.com/localstack/localstack-python-client/issues). We are thankful for all the contributions and feedback we receive.

localstack_client/config.py

Lines changed: 133 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,164 +1,161 @@
1-
import os
21
import json
3-
from botocore.serialize import Serializer
4-
5-
# central entrypoint port for all LocalStack API endpoints
2+
import os
3+
from typing import Dict
64
from urllib.parse import urlparse
75

8-
EDGE_PORT = int(os.environ.get('EDGE_PORT') or 4566)
6+
# note: leave this import here for now, as some upstream code is depending on it (TODO needs to be updated)
7+
from localstack_client.patch import patch_expand_host_prefix # noqa
8+
9+
# central entrypoint port for all LocalStack API endpoints
10+
EDGE_PORT = int(os.environ.get("EDGE_PORT") or 4566)
911

1012
# NOTE: The endpoints below will soon become deprecated/removed, as the default in the
1113
# latest version is to access all services via a single "edge service" (port 4566 by default)
1214
_service_endpoints_template = {
13-
'edge': '{proto}://{host}:4566',
14-
'apigateway': '{proto}://{host}:4567',
15-
'apigatewayv2': '{proto}://{host}:4567',
16-
'kinesis': '{proto}://{host}:4568',
17-
'dynamodb': '{proto}://{host}:4569',
18-
'dynamodbstreams': '{proto}://{host}:4570',
19-
'elasticsearch': '{proto}://{host}:4571',
20-
's3': '{proto}://{host}:4572',
21-
'firehose': '{proto}://{host}:4573',
22-
'lambda': '{proto}://{host}:4574',
23-
'sns': '{proto}://{host}:4575',
24-
'sqs': '{proto}://{host}:4576',
25-
'redshift': '{proto}://{host}:4577',
26-
'redshift-data': '{proto}://{host}:4577',
27-
'es': '{proto}://{host}:4578',
28-
'opensearch': '{proto}://{host}:4578',
29-
'ses': '{proto}://{host}:4579',
30-
'sesv2': '{proto}://{host}:4579',
31-
'route53': '{proto}://{host}:4580',
32-
'route53resolver': '{proto}://{host}:4580',
33-
'cloudformation': '{proto}://{host}:4581',
34-
'cloudwatch': '{proto}://{host}:4582',
35-
'ssm': '{proto}://{host}:4583',
36-
'secretsmanager': '{proto}://{host}:4584',
37-
'stepfunctions': '{proto}://{host}:4585',
38-
'logs': '{proto}://{host}:4586',
39-
'events': '{proto}://{host}:4587',
40-
'elb': '{proto}://{host}:4588',
41-
'iot': '{proto}://{host}:4589',
42-
'iotanalytics': '{proto}://{host}:4589',
43-
'iotevents': '{proto}://{host}:4589',
44-
'iotevents-data': '{proto}://{host}:4589',
45-
'iotwireless': '{proto}://{host}:4589',
46-
'iot-data': '{proto}://{host}:4589',
47-
'iot-jobs-data': '{proto}://{host}:4589',
48-
'cognito-idp': '{proto}://{host}:4590',
49-
'cognito-identity': '{proto}://{host}:4591',
50-
'sts': '{proto}://{host}:4592',
51-
'iam': '{proto}://{host}:4593',
52-
'rds': '{proto}://{host}:4594',
53-
'rds-data': '{proto}://{host}:4594',
54-
'cloudsearch': '{proto}://{host}:4595',
55-
'swf': '{proto}://{host}:4596',
56-
'ec2': '{proto}://{host}:4597',
57-
'elasticache': '{proto}://{host}:4598',
58-
'kms': '{proto}://{host}:4599',
59-
'emr': '{proto}://{host}:4600',
60-
'ecs': '{proto}://{host}:4601',
61-
'eks': '{proto}://{host}:4602',
62-
'xray': '{proto}://{host}:4603',
63-
'elasticbeanstalk': '{proto}://{host}:4604',
64-
'appsync': '{proto}://{host}:4605',
65-
'cloudfront': '{proto}://{host}:4606',
66-
'athena': '{proto}://{host}:4607',
67-
'glue': '{proto}://{host}:4608',
68-
'sagemaker': '{proto}://{host}:4609',
69-
'sagemaker-runtime': '{proto}://{host}:4609',
70-
'ecr': '{proto}://{host}:4610',
71-
'qldb': '{proto}://{host}:4611',
72-
'qldb-session': '{proto}://{host}:4611',
73-
'cloudtrail': '{proto}://{host}:4612',
74-
'glacier': '{proto}://{host}:4613',
75-
'batch': '{proto}://{host}:4614',
76-
'organizations': '{proto}://{host}:4615',
77-
'autoscaling': '{proto}://{host}:4616',
78-
'mediastore': '{proto}://{host}:4617',
79-
'mediastore-data': '{proto}://{host}:4617',
80-
'transfer': '{proto}://{host}:4618',
81-
'acm': '{proto}://{host}:4619',
82-
'codecommit': '{proto}://{host}:4620',
83-
'kinesisanalytics': '{proto}://{host}:4621',
84-
'kinesisanalyticsv2': '{proto}://{host}:4621',
85-
'amplify': '{proto}://{host}:4622',
86-
'application-autoscaling': '{proto}://{host}:4623',
87-
'kafka': '{proto}://{host}:4624',
88-
'apigatewaymanagementapi': '{proto}://{host}:4625',
89-
'timestream': '{proto}://{host}:4626',
90-
'timestream-query': '{proto}://{host}:4626',
91-
'timestream-write': '{proto}://{host}:4626',
92-
's3control': '{proto}://{host}:4627',
93-
'elbv2': '{proto}://{host}:4628',
94-
'support': '{proto}://{host}:4629',
95-
'neptune': '{proto}://{host}:4594',
96-
'docdb': '{proto}://{host}:4594',
97-
'servicediscovery': '{proto}://{host}:4630',
98-
'serverlessrepo': '{proto}://{host}:4631',
99-
'appconfig': '{proto}://{host}:4632',
100-
'ce': '{proto}://{host}:4633',
101-
'mediaconvert': '{proto}://{host}:4634',
102-
'resourcegroupstaggingapi': '{proto}://{host}:4635',
103-
'resource-groups': '{proto}://{host}:4636',
104-
'efs': '{proto}://{host}:4637',
105-
'backup': '{proto}://{host}:4638',
106-
'lakeformation': '{proto}://{host}:4639',
107-
'waf': '{proto}://{host}:4640',
108-
'wafv2': '{proto}://{host}:4640',
109-
'config': '{proto}://{host}:4641',
110-
'configservice': '{proto}://{host}:4641',
111-
'mwaa': '{proto}://{host}:4642',
112-
'fis': '{proto}://{host}:4643',
113-
'meteringmarketplace': '{proto}://{host}:4644',
114-
'transcribe': '{proto}://{host}:4566',
15+
"edge": "{proto}://{host}:4566",
16+
"apigateway": "{proto}://{host}:4567",
17+
"apigatewayv2": "{proto}://{host}:4567",
18+
"kinesis": "{proto}://{host}:4568",
19+
"dynamodb": "{proto}://{host}:4569",
20+
"dynamodbstreams": "{proto}://{host}:4570",
21+
"elasticsearch": "{proto}://{host}:4571",
22+
"s3": "{proto}://{host}:4572",
23+
"firehose": "{proto}://{host}:4573",
24+
"lambda": "{proto}://{host}:4574",
25+
"sns": "{proto}://{host}:4575",
26+
"sqs": "{proto}://{host}:4576",
27+
"redshift": "{proto}://{host}:4577",
28+
"redshift-data": "{proto}://{host}:4577",
29+
"es": "{proto}://{host}:4578",
30+
"opensearch": "{proto}://{host}:4578",
31+
"ses": "{proto}://{host}:4579",
32+
"sesv2": "{proto}://{host}:4579",
33+
"route53": "{proto}://{host}:4580",
34+
"route53resolver": "{proto}://{host}:4580",
35+
"cloudformation": "{proto}://{host}:4581",
36+
"cloudwatch": "{proto}://{host}:4582",
37+
"ssm": "{proto}://{host}:4583",
38+
"secretsmanager": "{proto}://{host}:4584",
39+
"stepfunctions": "{proto}://{host}:4585",
40+
"logs": "{proto}://{host}:4586",
41+
"events": "{proto}://{host}:4587",
42+
"elb": "{proto}://{host}:4588",
43+
"iot": "{proto}://{host}:4589",
44+
"iotanalytics": "{proto}://{host}:4589",
45+
"iotevents": "{proto}://{host}:4589",
46+
"iotevents-data": "{proto}://{host}:4589",
47+
"iotwireless": "{proto}://{host}:4589",
48+
"iot-data": "{proto}://{host}:4589",
49+
"iot-jobs-data": "{proto}://{host}:4589",
50+
"cognito-idp": "{proto}://{host}:4590",
51+
"cognito-identity": "{proto}://{host}:4591",
52+
"sts": "{proto}://{host}:4592",
53+
"iam": "{proto}://{host}:4593",
54+
"rds": "{proto}://{host}:4594",
55+
"rds-data": "{proto}://{host}:4594",
56+
"cloudsearch": "{proto}://{host}:4595",
57+
"swf": "{proto}://{host}:4596",
58+
"ec2": "{proto}://{host}:4597",
59+
"elasticache": "{proto}://{host}:4598",
60+
"kms": "{proto}://{host}:4599",
61+
"emr": "{proto}://{host}:4600",
62+
"ecs": "{proto}://{host}:4601",
63+
"eks": "{proto}://{host}:4602",
64+
"xray": "{proto}://{host}:4603",
65+
"elasticbeanstalk": "{proto}://{host}:4604",
66+
"appsync": "{proto}://{host}:4605",
67+
"cloudfront": "{proto}://{host}:4606",
68+
"athena": "{proto}://{host}:4607",
69+
"glue": "{proto}://{host}:4608",
70+
"sagemaker": "{proto}://{host}:4609",
71+
"sagemaker-runtime": "{proto}://{host}:4609",
72+
"ecr": "{proto}://{host}:4610",
73+
"qldb": "{proto}://{host}:4611",
74+
"qldb-session": "{proto}://{host}:4611",
75+
"cloudtrail": "{proto}://{host}:4612",
76+
"glacier": "{proto}://{host}:4613",
77+
"batch": "{proto}://{host}:4614",
78+
"organizations": "{proto}://{host}:4615",
79+
"autoscaling": "{proto}://{host}:4616",
80+
"mediastore": "{proto}://{host}:4617",
81+
"mediastore-data": "{proto}://{host}:4617",
82+
"transfer": "{proto}://{host}:4618",
83+
"acm": "{proto}://{host}:4619",
84+
"codecommit": "{proto}://{host}:4620",
85+
"kinesisanalytics": "{proto}://{host}:4621",
86+
"kinesisanalyticsv2": "{proto}://{host}:4621",
87+
"amplify": "{proto}://{host}:4622",
88+
"application-autoscaling": "{proto}://{host}:4623",
89+
"kafka": "{proto}://{host}:4624",
90+
"apigatewaymanagementapi": "{proto}://{host}:4625",
91+
"timestream": "{proto}://{host}:4626",
92+
"timestream-query": "{proto}://{host}:4626",
93+
"timestream-write": "{proto}://{host}:4626",
94+
"s3control": "{proto}://{host}:4627",
95+
"elbv2": "{proto}://{host}:4628",
96+
"support": "{proto}://{host}:4629",
97+
"neptune": "{proto}://{host}:4594",
98+
"docdb": "{proto}://{host}:4594",
99+
"servicediscovery": "{proto}://{host}:4630",
100+
"serverlessrepo": "{proto}://{host}:4631",
101+
"appconfig": "{proto}://{host}:4632",
102+
"ce": "{proto}://{host}:4633",
103+
"mediaconvert": "{proto}://{host}:4634",
104+
"resourcegroupstaggingapi": "{proto}://{host}:4635",
105+
"resource-groups": "{proto}://{host}:4636",
106+
"efs": "{proto}://{host}:4637",
107+
"backup": "{proto}://{host}:4638",
108+
"lakeformation": "{proto}://{host}:4639",
109+
"waf": "{proto}://{host}:4640",
110+
"wafv2": "{proto}://{host}:4640",
111+
"config": "{proto}://{host}:4641",
112+
"configservice": "{proto}://{host}:4641",
113+
"mwaa": "{proto}://{host}:4642",
114+
"fis": "{proto}://{host}:4643",
115+
"meteringmarketplace": "{proto}://{host}:4644",
116+
"transcribe": "{proto}://{host}:4566",
115117
}
116118

117119
# TODO remove service port mapping above entirely
118-
if os.environ.get('USE_LEGACY_PORTS') not in ['1', 'true']:
120+
if os.environ.get("USE_LEGACY_PORTS") not in ["1", "true"]:
119121
for key, value in _service_endpoints_template.items():
120-
if key not in ['dashboard', 'elasticsearch']:
121-
_service_endpoints_template[key] = '%s:%s' % (value.rpartition(':')[0], EDGE_PORT)
122-
123-
124-
def get_service_endpoint(service, localstack_host=None):
122+
if key not in ["dashboard", "elasticsearch"]:
123+
_service_endpoints_template[key] = f"{value.rpartition(':')[0]}:{EDGE_PORT}"
124+
125+
126+
def get_service_endpoint(service: str, localstack_host: str = None) -> str:
127+
"""
128+
Return the local endpoint URL for the given boto3 service (e.g., "s3").
129+
If $AWS_ENDPOINT_URL is configured in the environment, it is returned directly.
130+
Otherwise, the service endpoint is constructed from the dict of service ports (usually http://localhost:4566).
131+
"""
132+
env_endpoint_url = os.environ.get("AWS_ENDPOINT_URL", "").strip()
133+
if env_endpoint_url:
134+
return env_endpoint_url
125135
endpoints = get_service_endpoints(localstack_host=localstack_host)
126136
return endpoints.get(service)
127137

128138

129-
def get_service_endpoints(localstack_host=None):
139+
def get_service_endpoints(localstack_host: str = None) -> Dict[str, str]:
130140
if localstack_host is None:
131-
localstack_host = os.environ.get('LOCALSTACK_HOST', 'localhost')
132-
protocol = 'https' if os.environ.get('USE_SSL') in ('1', 'true') else 'http'
141+
localstack_host = os.environ.get("LOCALSTACK_HOST", "localhost")
142+
protocol = "https" if os.environ.get("USE_SSL") in ("1", "true") else "http"
133143

134-
return json.loads(json.dumps(_service_endpoints_template)
135-
.replace('{proto}', protocol).replace('{host}', localstack_host))
144+
return json.loads(
145+
json.dumps(_service_endpoints_template)
146+
.replace("{proto}", protocol)
147+
.replace("{host}", localstack_host)
148+
)
136149

137150

138-
def get_service_port(service):
151+
def get_service_port(service: str) -> int:
139152
ports = get_service_ports()
140153
return ports.get(service)
141154

142155

143-
def get_service_ports():
156+
def get_service_ports() -> Dict[str, int]:
144157
endpoints = get_service_endpoints()
145158
result = {}
146159
for service, url in endpoints.items():
147160
result[service] = urlparse(url).port
148161
return result
149-
150-
151-
def patch_expand_host_prefix():
152-
"""Apply a patch to botocore, to skip adding host prefixes to endpoint URLs"""
153-
154-
def _expand_host_prefix(self, parameters, operation_model, *args, **kwargs):
155-
result = _expand_host_prefix_orig(self, parameters, operation_model, *args, **kwargs)
156-
# skip adding host prefixes, to avoid making requests to, e.g., http://data-localhost:4566
157-
if operation_model.service_model.service_name == "servicediscovery" and result == "data-":
158-
return None
159-
if operation_model.service_model.service_name == "mwaa" and result == "api.":
160-
return None
161-
return result
162-
163-
_expand_host_prefix_orig = Serializer._expand_host_prefix
164-
Serializer._expand_host_prefix = _expand_host_prefix

0 commit comments

Comments
 (0)