diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72ce939..d48bbf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: fail-fast: false matrix: python-version: + - "3.10" - "3.9" - "3.8" - "3.7" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9390993..f755514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # LocalStack Python Client Change Log +* v1.38: Add `enable_local_endpoints()` util function; slight project refactoring, migrate from `nose` to `pytests` * v1.37: Add endpoint for Amazon Transcribe * v1.36: Add endpoints for Fault Injection Service (FIS) and Marketplace Metering * v1.35: Add endpoint for Amazon Managed Workflows for Apache Airflow (MWAA) diff --git a/Makefile b/Makefile index c13b208..150194b 100644 --- a/Makefile +++ b/Makefile @@ -14,16 +14,19 @@ install: ## Install dependencies in local virtualenv folder publish: ## Publish the library to the central PyPi repository # build and upload archive - ($(VENV_RUN); ./setup.py sdist && twine upload $(BUILD_DIR)/*.tar.gz) + $(VENV_RUN); ./setup.py sdist && twine upload $(BUILD_DIR)/*.tar.gz test: ## Run automated tests ($(VENV_RUN); test `which localstack` || pip install .[test]) && \ - $(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).*' . + $(VENV_RUN); DEBUG=$(DEBUG) PYTHONPATH=. pytest -sv $(PYTEST_ARGS) tests lint: ## Run code linter to check code style - ($(VENV_RUN); pycodestyle --max-line-length=100 --ignore=E128 --exclude=node_modules,legacy,$(VENV_DIR),dist .) + $(VENV_RUN); flake8 --ignore=E501 localstack_client tests + +format: ## Run code formatter (black) + $(VENV_RUN); black localstack_client tests; isort localstack_client tests clean: ## Clean up virtualenv rm -rf $(VENV_DIR) -.PHONY: usage install clean publish test lint +.PHONY: usage install clean publish test lint format diff --git a/README.md b/README.md index 8371dce..5a4922d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,27 @@ sqs = boto3.client('sqs') assert sqs.list_queues() is not None # list SQS in localstack ``` +### Enabling Transparent Local Endpoints + +The library contains a small `enable_local_endpoints()` util function that can be used to transparently run all `boto3` requests against the local endpoints. + +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. +``` +import boto3 +from localstack_client.patch import enable_local_endpoints() +enable_local_endpoints() +# the call below will automatically target the LocalStack endpoints +buckets = boto3.client("s3").list_buckets() +``` + +The patch can also be unapplied by calling `disable_local_endpoints()`: +``` +from localstack_client.patch import disable_local_endpoints() +disable_local_endpoints() +# the call below will target the real AWS cloud again +buckets = boto3.client("s3").list_buckets() +``` + ## Contributing 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. diff --git a/localstack_client/config.py b/localstack_client/config.py index 939f366..ca689c3 100644 --- a/localstack_client/config.py +++ b/localstack_client/config.py @@ -1,164 +1,161 @@ -import os import json -from botocore.serialize import Serializer - -# central entrypoint port for all LocalStack API endpoints +import os +from typing import Dict from urllib.parse import urlparse -EDGE_PORT = int(os.environ.get('EDGE_PORT') or 4566) +# note: leave this import here for now, as some upstream code is depending on it (TODO needs to be updated) +from localstack_client.patch import patch_expand_host_prefix # noqa + +# central entrypoint port for all LocalStack API endpoints +EDGE_PORT = int(os.environ.get("EDGE_PORT") or 4566) # NOTE: The endpoints below will soon become deprecated/removed, as the default in the # latest version is to access all services via a single "edge service" (port 4566 by default) _service_endpoints_template = { - 'edge': '{proto}://{host}:4566', - 'apigateway': '{proto}://{host}:4567', - 'apigatewayv2': '{proto}://{host}:4567', - 'kinesis': '{proto}://{host}:4568', - 'dynamodb': '{proto}://{host}:4569', - 'dynamodbstreams': '{proto}://{host}:4570', - 'elasticsearch': '{proto}://{host}:4571', - 's3': '{proto}://{host}:4572', - 'firehose': '{proto}://{host}:4573', - 'lambda': '{proto}://{host}:4574', - 'sns': '{proto}://{host}:4575', - 'sqs': '{proto}://{host}:4576', - 'redshift': '{proto}://{host}:4577', - 'redshift-data': '{proto}://{host}:4577', - 'es': '{proto}://{host}:4578', - 'opensearch': '{proto}://{host}:4578', - 'ses': '{proto}://{host}:4579', - 'sesv2': '{proto}://{host}:4579', - 'route53': '{proto}://{host}:4580', - 'route53resolver': '{proto}://{host}:4580', - 'cloudformation': '{proto}://{host}:4581', - 'cloudwatch': '{proto}://{host}:4582', - 'ssm': '{proto}://{host}:4583', - 'secretsmanager': '{proto}://{host}:4584', - 'stepfunctions': '{proto}://{host}:4585', - 'logs': '{proto}://{host}:4586', - 'events': '{proto}://{host}:4587', - 'elb': '{proto}://{host}:4588', - 'iot': '{proto}://{host}:4589', - 'iotanalytics': '{proto}://{host}:4589', - 'iotevents': '{proto}://{host}:4589', - 'iotevents-data': '{proto}://{host}:4589', - 'iotwireless': '{proto}://{host}:4589', - 'iot-data': '{proto}://{host}:4589', - 'iot-jobs-data': '{proto}://{host}:4589', - 'cognito-idp': '{proto}://{host}:4590', - 'cognito-identity': '{proto}://{host}:4591', - 'sts': '{proto}://{host}:4592', - 'iam': '{proto}://{host}:4593', - 'rds': '{proto}://{host}:4594', - 'rds-data': '{proto}://{host}:4594', - 'cloudsearch': '{proto}://{host}:4595', - 'swf': '{proto}://{host}:4596', - 'ec2': '{proto}://{host}:4597', - 'elasticache': '{proto}://{host}:4598', - 'kms': '{proto}://{host}:4599', - 'emr': '{proto}://{host}:4600', - 'ecs': '{proto}://{host}:4601', - 'eks': '{proto}://{host}:4602', - 'xray': '{proto}://{host}:4603', - 'elasticbeanstalk': '{proto}://{host}:4604', - 'appsync': '{proto}://{host}:4605', - 'cloudfront': '{proto}://{host}:4606', - 'athena': '{proto}://{host}:4607', - 'glue': '{proto}://{host}:4608', - 'sagemaker': '{proto}://{host}:4609', - 'sagemaker-runtime': '{proto}://{host}:4609', - 'ecr': '{proto}://{host}:4610', - 'qldb': '{proto}://{host}:4611', - 'qldb-session': '{proto}://{host}:4611', - 'cloudtrail': '{proto}://{host}:4612', - 'glacier': '{proto}://{host}:4613', - 'batch': '{proto}://{host}:4614', - 'organizations': '{proto}://{host}:4615', - 'autoscaling': '{proto}://{host}:4616', - 'mediastore': '{proto}://{host}:4617', - 'mediastore-data': '{proto}://{host}:4617', - 'transfer': '{proto}://{host}:4618', - 'acm': '{proto}://{host}:4619', - 'codecommit': '{proto}://{host}:4620', - 'kinesisanalytics': '{proto}://{host}:4621', - 'kinesisanalyticsv2': '{proto}://{host}:4621', - 'amplify': '{proto}://{host}:4622', - 'application-autoscaling': '{proto}://{host}:4623', - 'kafka': '{proto}://{host}:4624', - 'apigatewaymanagementapi': '{proto}://{host}:4625', - 'timestream': '{proto}://{host}:4626', - 'timestream-query': '{proto}://{host}:4626', - 'timestream-write': '{proto}://{host}:4626', - 's3control': '{proto}://{host}:4627', - 'elbv2': '{proto}://{host}:4628', - 'support': '{proto}://{host}:4629', - 'neptune': '{proto}://{host}:4594', - 'docdb': '{proto}://{host}:4594', - 'servicediscovery': '{proto}://{host}:4630', - 'serverlessrepo': '{proto}://{host}:4631', - 'appconfig': '{proto}://{host}:4632', - 'ce': '{proto}://{host}:4633', - 'mediaconvert': '{proto}://{host}:4634', - 'resourcegroupstaggingapi': '{proto}://{host}:4635', - 'resource-groups': '{proto}://{host}:4636', - 'efs': '{proto}://{host}:4637', - 'backup': '{proto}://{host}:4638', - 'lakeformation': '{proto}://{host}:4639', - 'waf': '{proto}://{host}:4640', - 'wafv2': '{proto}://{host}:4640', - 'config': '{proto}://{host}:4641', - 'configservice': '{proto}://{host}:4641', - 'mwaa': '{proto}://{host}:4642', - 'fis': '{proto}://{host}:4643', - 'meteringmarketplace': '{proto}://{host}:4644', - 'transcribe': '{proto}://{host}:4566', + "edge": "{proto}://{host}:4566", + "apigateway": "{proto}://{host}:4567", + "apigatewayv2": "{proto}://{host}:4567", + "kinesis": "{proto}://{host}:4568", + "dynamodb": "{proto}://{host}:4569", + "dynamodbstreams": "{proto}://{host}:4570", + "elasticsearch": "{proto}://{host}:4571", + "s3": "{proto}://{host}:4572", + "firehose": "{proto}://{host}:4573", + "lambda": "{proto}://{host}:4574", + "sns": "{proto}://{host}:4575", + "sqs": "{proto}://{host}:4576", + "redshift": "{proto}://{host}:4577", + "redshift-data": "{proto}://{host}:4577", + "es": "{proto}://{host}:4578", + "opensearch": "{proto}://{host}:4578", + "ses": "{proto}://{host}:4579", + "sesv2": "{proto}://{host}:4579", + "route53": "{proto}://{host}:4580", + "route53resolver": "{proto}://{host}:4580", + "cloudformation": "{proto}://{host}:4581", + "cloudwatch": "{proto}://{host}:4582", + "ssm": "{proto}://{host}:4583", + "secretsmanager": "{proto}://{host}:4584", + "stepfunctions": "{proto}://{host}:4585", + "logs": "{proto}://{host}:4586", + "events": "{proto}://{host}:4587", + "elb": "{proto}://{host}:4588", + "iot": "{proto}://{host}:4589", + "iotanalytics": "{proto}://{host}:4589", + "iotevents": "{proto}://{host}:4589", + "iotevents-data": "{proto}://{host}:4589", + "iotwireless": "{proto}://{host}:4589", + "iot-data": "{proto}://{host}:4589", + "iot-jobs-data": "{proto}://{host}:4589", + "cognito-idp": "{proto}://{host}:4590", + "cognito-identity": "{proto}://{host}:4591", + "sts": "{proto}://{host}:4592", + "iam": "{proto}://{host}:4593", + "rds": "{proto}://{host}:4594", + "rds-data": "{proto}://{host}:4594", + "cloudsearch": "{proto}://{host}:4595", + "swf": "{proto}://{host}:4596", + "ec2": "{proto}://{host}:4597", + "elasticache": "{proto}://{host}:4598", + "kms": "{proto}://{host}:4599", + "emr": "{proto}://{host}:4600", + "ecs": "{proto}://{host}:4601", + "eks": "{proto}://{host}:4602", + "xray": "{proto}://{host}:4603", + "elasticbeanstalk": "{proto}://{host}:4604", + "appsync": "{proto}://{host}:4605", + "cloudfront": "{proto}://{host}:4606", + "athena": "{proto}://{host}:4607", + "glue": "{proto}://{host}:4608", + "sagemaker": "{proto}://{host}:4609", + "sagemaker-runtime": "{proto}://{host}:4609", + "ecr": "{proto}://{host}:4610", + "qldb": "{proto}://{host}:4611", + "qldb-session": "{proto}://{host}:4611", + "cloudtrail": "{proto}://{host}:4612", + "glacier": "{proto}://{host}:4613", + "batch": "{proto}://{host}:4614", + "organizations": "{proto}://{host}:4615", + "autoscaling": "{proto}://{host}:4616", + "mediastore": "{proto}://{host}:4617", + "mediastore-data": "{proto}://{host}:4617", + "transfer": "{proto}://{host}:4618", + "acm": "{proto}://{host}:4619", + "codecommit": "{proto}://{host}:4620", + "kinesisanalytics": "{proto}://{host}:4621", + "kinesisanalyticsv2": "{proto}://{host}:4621", + "amplify": "{proto}://{host}:4622", + "application-autoscaling": "{proto}://{host}:4623", + "kafka": "{proto}://{host}:4624", + "apigatewaymanagementapi": "{proto}://{host}:4625", + "timestream": "{proto}://{host}:4626", + "timestream-query": "{proto}://{host}:4626", + "timestream-write": "{proto}://{host}:4626", + "s3control": "{proto}://{host}:4627", + "elbv2": "{proto}://{host}:4628", + "support": "{proto}://{host}:4629", + "neptune": "{proto}://{host}:4594", + "docdb": "{proto}://{host}:4594", + "servicediscovery": "{proto}://{host}:4630", + "serverlessrepo": "{proto}://{host}:4631", + "appconfig": "{proto}://{host}:4632", + "ce": "{proto}://{host}:4633", + "mediaconvert": "{proto}://{host}:4634", + "resourcegroupstaggingapi": "{proto}://{host}:4635", + "resource-groups": "{proto}://{host}:4636", + "efs": "{proto}://{host}:4637", + "backup": "{proto}://{host}:4638", + "lakeformation": "{proto}://{host}:4639", + "waf": "{proto}://{host}:4640", + "wafv2": "{proto}://{host}:4640", + "config": "{proto}://{host}:4641", + "configservice": "{proto}://{host}:4641", + "mwaa": "{proto}://{host}:4642", + "fis": "{proto}://{host}:4643", + "meteringmarketplace": "{proto}://{host}:4644", + "transcribe": "{proto}://{host}:4566", } # TODO remove service port mapping above entirely -if os.environ.get('USE_LEGACY_PORTS') not in ['1', 'true']: +if os.environ.get("USE_LEGACY_PORTS") not in ["1", "true"]: for key, value in _service_endpoints_template.items(): - if key not in ['dashboard', 'elasticsearch']: - _service_endpoints_template[key] = '%s:%s' % (value.rpartition(':')[0], EDGE_PORT) - - -def get_service_endpoint(service, localstack_host=None): + if key not in ["dashboard", "elasticsearch"]: + _service_endpoints_template[key] = f"{value.rpartition(':')[0]}:{EDGE_PORT}" + + +def get_service_endpoint(service: str, localstack_host: str = None) -> str: + """ + Return the local endpoint URL for the given boto3 service (e.g., "s3"). + If $AWS_ENDPOINT_URL is configured in the environment, it is returned directly. + Otherwise, the service endpoint is constructed from the dict of service ports (usually http://localhost:4566). + """ + env_endpoint_url = os.environ.get("AWS_ENDPOINT_URL", "").strip() + if env_endpoint_url: + return env_endpoint_url endpoints = get_service_endpoints(localstack_host=localstack_host) return endpoints.get(service) -def get_service_endpoints(localstack_host=None): +def get_service_endpoints(localstack_host: str = None) -> Dict[str, str]: if localstack_host is None: - localstack_host = os.environ.get('LOCALSTACK_HOST', 'localhost') - protocol = 'https' if os.environ.get('USE_SSL') in ('1', 'true') else 'http' + localstack_host = os.environ.get("LOCALSTACK_HOST", "localhost") + protocol = "https" if os.environ.get("USE_SSL") in ("1", "true") else "http" - return json.loads(json.dumps(_service_endpoints_template) - .replace('{proto}', protocol).replace('{host}', localstack_host)) + return json.loads( + json.dumps(_service_endpoints_template) + .replace("{proto}", protocol) + .replace("{host}", localstack_host) + ) -def get_service_port(service): +def get_service_port(service: str) -> int: ports = get_service_ports() return ports.get(service) -def get_service_ports(): +def get_service_ports() -> Dict[str, int]: endpoints = get_service_endpoints() result = {} for service, url in endpoints.items(): result[service] = urlparse(url).port return result - - -def patch_expand_host_prefix(): - """Apply a patch to botocore, to skip adding host prefixes to endpoint URLs""" - - def _expand_host_prefix(self, parameters, operation_model, *args, **kwargs): - result = _expand_host_prefix_orig(self, parameters, operation_model, *args, **kwargs) - # skip adding host prefixes, to avoid making requests to, e.g., http://data-localhost:4566 - if operation_model.service_model.service_name == "servicediscovery" and result == "data-": - return None - if operation_model.service_model.service_name == "mwaa" and result == "api.": - return None - return result - - _expand_host_prefix_orig = Serializer._expand_host_prefix - Serializer._expand_host_prefix = _expand_host_prefix diff --git a/localstack_client/patch.py b/localstack_client/patch.py new file mode 100644 index 0000000..b8b9f39 --- /dev/null +++ b/localstack_client/patch.py @@ -0,0 +1,151 @@ +import types + +import boto3 +from boto3.session import Session +from botocore.serialize import Serializer + +_state = {} + +DEFAULT_ACCESS_KEY_ID = "test" +DEFAULT_SECRET_ACCESS_KEY = "test" + + +def enable_local_endpoints(): + """Patch the boto3 library to transparently use the LocalStack endpoints by default.""" + from localstack_client.config import get_service_endpoint + + def _add_custom_kwargs( + kwargs, + service_name, + endpoint_url=None, + aws_access_key_id=None, + aws_secret_access_key=None, + ): + kwargs["endpoint_url"] = endpoint_url or get_service_endpoint(service_name) + kwargs["aws_access_key_id"] = aws_access_key_id or DEFAULT_ACCESS_KEY_ID + kwargs["aws_secret_access_key"] = ( + aws_secret_access_key or DEFAULT_SECRET_ACCESS_KEY + ) + + def _client( + self, + service_name, + region_name=None, + api_version=None, + use_ssl=True, + verify=None, + endpoint_url=None, + aws_access_key_id=None, + aws_secret_access_key=None, + **kwargs, + ): + _add_custom_kwargs( + kwargs, + service_name, + endpoint_url=endpoint_url, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + ) + return _client_orig( + self, + service_name, + region_name=region_name, + api_version=api_version, + use_ssl=use_ssl, + verify=verify, + **kwargs, + ) + + def _resource( + self, + service_name, + region_name=None, + api_version=None, + use_ssl=True, + verify=None, + endpoint_url=None, + aws_access_key_id=None, + aws_secret_access_key=None, + **kwargs, + ): + _add_custom_kwargs( + kwargs, + service_name, + endpoint_url=endpoint_url, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + ) + return _resource_orig( + self, + service_name, + region_name=region_name, + api_version=api_version, + use_ssl=use_ssl, + verify=verify, + **kwargs, + ) + + if _state.get("_client_orig"): + # patch already applied -> return + return + + # patch boto3 default session (if available) + try: + session = boto3._get_default_session() + _state["_default_client_orig"] = session.client + session.client = types.MethodType(_client, session) + _state["_default_resource_orig"] = session.resource + session.resource = types.MethodType(_resource, session) + except Exception: + # swallowing for now - looks like the default session is not available (yet) + pass + + # patch session.client(..) + _client_orig = Session.client + _state["_client_orig"] = _client_orig + Session.client = _client + + # patch session.resource(..) + _resource_orig = Session.resource + _state["_resource_orig"] = _resource_orig + Session.resource = _resource + + +def disable_local_endpoints(): + """Disable the boto3 patches and revert to using the default endpoints against real AWS.""" + + _client = _state.pop("_client_orig", None) + if _client: + Session.client = _client + _resource = _state.pop("_resource_orig", None) + if _resource: + Session.resource = _resource + + # undo patches for boto3 default session + try: + session = boto3._get_default_session() + if _state.get("_default_client_orig"): + session.client = _state["_default_client_orig"] + if _state.get("_default_resource_orig"): + session.resource = _state["_default_resource_orig"] + except Exception: + pass + + +def patch_expand_host_prefix(): + """Apply a patch to botocore, to skip adding host prefixes to endpoint URLs""" + + def _expand_host_prefix(self, parameters, operation_model, *args, **kwargs): + result = _expand_host_prefix_orig( + self, parameters, operation_model, *args, **kwargs + ) + # skip adding host prefixes, to avoid making requests to, e.g., http://data-localhost:4566 + is_sd = operation_model.service_model.service_name == "servicediscovery" + if is_sd and result == "data-": + return None + if operation_model.service_model.service_name == "mwaa" and result == "api.": + return None + return result + + _expand_host_prefix_orig = Serializer._expand_host_prefix + Serializer._expand_host_prefix = _expand_host_prefix diff --git a/localstack_client/session.py b/localstack_client/session.py index 2415383..9bb7bb5 100644 --- a/localstack_client/session.py +++ b/localstack_client/session.py @@ -1,6 +1,7 @@ from boto3 import client as boto3_client from boto3 import resource as boto3_resource from botocore.credentials import Credentials + from localstack_client import config DEFAULT_SESSION = None @@ -12,10 +13,17 @@ class Session(object): emulate the boto3.session object. """ - def __init__(self, aws_access_key_id='accesskey', aws_secret_access_key='secretkey', - aws_session_token='token', region_name='us-east-1', - botocore_session=None, profile_name=None, localstack_host=None): - self.env = 'local' + def __init__( + self, + aws_access_key_id="accesskey", + aws_secret_access_key="secretkey", + aws_session_token="token", + region_name="us-east-1", + botocore_session=None, + profile_name=None, + localstack_host=None, + ): + self.env = "local" self.aws_access_key_id = aws_access_key_id self.aws_secret_access_key = aws_secret_access_key self.aws_session_token = aws_session_token @@ -23,19 +31,21 @@ def __init__(self, aws_access_key_id='accesskey', aws_secret_access_key='secretk self._service_endpoint_mapping = config.get_service_endpoints(localstack_host) self.common_protected_kwargs = { - 'aws_access_key_id': self.aws_access_key_id, - 'aws_secret_access_key': self.aws_secret_access_key, - 'region_name': self.region_name, - 'verify': False + "aws_access_key_id": self.aws_access_key_id, + "aws_secret_access_key": self.aws_secret_access_key, + "region_name": self.region_name, + "verify": False, } def get_credentials(self): """ Returns botocore.credential.Credential object. """ - return Credentials(access_key=self.aws_access_key_id, - secret_key=self.aws_secret_access_key, - token=self.aws_session_token) + return Credentials( + access_key=self.aws_access_key_id, + secret_key=self.aws_secret_access_key, + token=self.aws_session_token, + ) def client(self, service_name, **kwargs): """ @@ -45,12 +55,15 @@ def client(self, service_name, **kwargs): Returns boto3.resources.factory.s3.ServiceClient object """ if service_name not in self._service_endpoint_mapping: - raise Exception('%s is not supported by this mock session.' % (service_name)) - - protected_kwargs = {**self.common_protected_kwargs, - 'service_name': service_name, - 'endpoint_url': self._service_endpoint_mapping[service_name] - } + raise Exception( + "%s is not supported by this mock session." % (service_name) + ) + + protected_kwargs = { + **self.common_protected_kwargs, + "service_name": service_name, + "endpoint_url": self._service_endpoint_mapping[service_name], + } return boto3_client(**{**kwargs, **protected_kwargs}) @@ -62,12 +75,15 @@ def resource(self, service_name, **kwargs): Returns boto3.resources.factory.s3.ServiceResource object """ if service_name not in self._service_endpoint_mapping: - raise Exception('%s is not supported by this mock session.' % (service_name)) - - protected_kwargs = {**self.common_protected_kwargs, - 'service_name': service_name, - 'endpoint_url': self._service_endpoint_mapping[service_name] - } + raise Exception( + "%s is not supported by this mock session." % (service_name) + ) + + protected_kwargs = { + **self.common_protected_kwargs, + "service_name": service_name, + "endpoint_url": self._service_endpoint_mapping[service_name], + } return boto3_resource(**{**kwargs, **protected_kwargs}) diff --git a/setup.cfg b/setup.cfg index aceab8d..21c52c4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,18 +1,13 @@ [metadata] name = localstack-client -version = 1.37 +version = 1.38 url = https://github.com/localstack/localstack-python-client author = LocalStack Team author_email = info@localstack.cloud description = A lightweight Python client for LocalStack. license = Apache License 2.0 classifiers = - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.6 - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 - Programming Language :: Python :: 3.3 - Programming Language :: Python :: 3.4 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 @@ -30,7 +25,9 @@ install_requires = [options.extras_require] # Dependencies to run the tests test = + black coverage - pycodestyle - nose + flake8 + isort localstack + pytest diff --git a/tests/client/__init__.py b/tests/client/__init__.py index b75e802..e69de29 100644 --- a/tests/client/__init__.py +++ b/tests/client/__init__.py @@ -1,15 +0,0 @@ -import time -import subprocess - -STATE = {} - - -def setup_package(): - if STATE.get('process'): - return - STATE['process'] = subprocess.Popen(['localstack', 'start', '-d']) - subprocess.Popen(['localstack', 'wait']).wait() - - -def teardown_package(): - subprocess.Popen(['localstack', 'stop']).wait() diff --git a/tests/client/conftest.py b/tests/client/conftest.py new file mode 100644 index 0000000..1919695 --- /dev/null +++ b/tests/client/conftest.py @@ -0,0 +1,13 @@ +import subprocess + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def startup_localstack(): + subprocess.check_output(["localstack", "start", "-d"]) + subprocess.check_output(["localstack", "wait"]) + + yield + + subprocess.check_output(["localstack", "stop"]) diff --git a/tests/client/test_patches.py b/tests/client/test_patches.py new file mode 100644 index 0000000..f87f69c --- /dev/null +++ b/tests/client/test_patches.py @@ -0,0 +1,36 @@ +import uuid + +import boto3 +import pytest + +from localstack_client.patch import (disable_local_endpoints, + enable_local_endpoints) + + +def test_enable_local_endpoints(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") + + # create default client, requests should fail + with pytest.raises(Exception): + boto3.client("s3").list_buckets() + with pytest.raises(Exception): + resource = boto3.resource("s3") + bucket_name = str(uuid.uuid4()) + resource.Bucket(bucket_name).create() + + # enable local endpoints, request should pass + enable_local_endpoints() + assert "Buckets" in boto3.client("s3").list_buckets() + resource = boto3.resource("s3") + bucket_name = str(uuid.uuid4()) + resource.Bucket(bucket_name).create() + resource.Bucket(bucket_name).delete() + + # disable local endpoints again, request should fail + disable_local_endpoints() + with pytest.raises(Exception): + boto3.client("s3").list_buckets() + with pytest.raises(Exception): + resource = boto3.resource("s3") + bucket_name = str(uuid.uuid4()) + resource.Bucket(bucket_name).create() diff --git a/tests/client/test_python_client.py b/tests/client/test_python_client.py index 5268d24..83903be 100644 --- a/tests/client/test_python_client.py +++ b/tests/client/test_python_client.py @@ -1,40 +1,41 @@ -import localstack_client.session from botocore.client import Config +import localstack_client.session + def test_session(): session = localstack_client.session.Session() - sqs = session.client('sqs') + sqs = session.client("sqs") assert sqs.list_queues() is not None def test_client_kwargs_passed(): - """ Test kwargs passed through to boto3.client creation """ + """Test kwargs passed through to boto3.client creation""" session = localstack_client.session.Session() - kwargs = {'config': Config(signature_version='s3v4')} - sqs = session.client('sqs', **kwargs) - assert sqs.meta.config.signature_version == 's3v4' + kwargs = {"config": Config(signature_version="s3v4")} + sqs = session.client("sqs", **kwargs) + assert sqs.meta.config.signature_version == "s3v4" def test_protected_client_kwargs_not_passed(): - """ Test protected kwargs not overwritten in boto3.client creation """ + """Test protected kwargs not overwritten in boto3.client creation""" session = localstack_client.session.Session() - kwargs = {'region_name': 'another_region'} - sqs = session.client('sqs', **kwargs) - assert not sqs.meta.region_name == 'another_region' + kwargs = {"region_name": "another_region"} + sqs = session.client("sqs", **kwargs) + assert not sqs.meta.region_name == "another_region" def test_resource_kwargs_passed(): - """ Test kwargs passed through to boto3.resource creation """ + """Test kwargs passed through to boto3.resource creation""" session = localstack_client.session.Session() - kwargs = {'config': Config(signature_version='s3v4')} - sqs = session.resource('sqs', **kwargs) - assert sqs.meta.client.meta.config.signature_version == 's3v4' + kwargs = {"config": Config(signature_version="s3v4")} + sqs = session.resource("sqs", **kwargs) + assert sqs.meta.client.meta.config.signature_version == "s3v4" def test_protected_resource_kwargs_not_passed(): - """ Test protected kwargs not overwritten in boto3.resource creation """ + """Test protected kwargs not overwritten in boto3.resource creation""" session = localstack_client.session.Session() - kwargs = {'region_name': 'another_region'} - sqs = session.resource('sqs', **kwargs) - assert not sqs.meta.client.meta.region_name == 'another_region' + kwargs = {"region_name": "another_region"} + sqs = session.resource("sqs", **kwargs) + assert not sqs.meta.client.meta.region_name == "another_region"