diff --git a/.circleci/config.yml b/.circleci/config.yml index 007b5fb2..075baf29 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -60,6 +60,7 @@ jobs: - run: name: install requirements command: | + pip install --upgrade pip pip install -r test-requirements.txt; pip install -r test-filesource-optional-requirements.txt; pip install -r consul-requirements.txt; diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 9021210c..cc14b358 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -12,7 +12,8 @@ publications: branches: - name: main - description: 7.x + description: 8.x + - name: 7.x - name: 6.x jobs: diff --git a/contract-tests/client_entity.py b/contract-tests/client_entity.py index 5d2d5220..e835e4f4 100644 --- a/contract-tests/client_entity.py +++ b/contract-tests/client_entity.py @@ -12,6 +12,13 @@ def __init__(self, tag, config): self.log = logging.getLogger(tag) opts = {"sdk_key": config["credential"]} + tags = config.get('tags', {}) + if tags: + opts['application'] = { + 'id': tags.get('applicationId', ''), + 'version': tags.get('applicationVersion', ''), + } + if config.get("streaming") is not None: streaming = config["streaming"] if streaming.get("baseUri") is not None: diff --git a/contract-tests/service.py b/contract-tests/service.py index d9f8e0a5..79b0e621 100644 --- a/contract-tests/service.py +++ b/contract-tests/service.py @@ -63,6 +63,7 @@ def status(): 'all-flags-with-reasons', 'all-flags-client-side-only', 'all-flags-details-only-for-tracked-flags', + 'tags', ] } return (json.dumps(body), 200, {'Content-type': 'application/json'}) diff --git a/ldclient/config.py b/ldclient/config.py index dfe1a29a..faf6fcc1 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -7,7 +7,7 @@ from typing import Optional, Callable, List, Any, Set from ldclient.feature_store import InMemoryFeatureStore -from ldclient.util import log +from ldclient.util import log, validate_application_info from ldclient.interfaces import BigSegmentStore, EventProcessor, FeatureStore, UpdateProcessor, FeatureRequester GET_LATEST_FEATURES_PATH = '/sdk/latest-flags' @@ -173,7 +173,8 @@ def __init__(self, wrapper_name: Optional[str]=None, wrapper_version: Optional[str]=None, http: HTTPConfig=HTTPConfig(), - big_segments: Optional[BigSegmentsConfig]=None): + big_segments: Optional[BigSegmentsConfig]=None, + application: Optional[dict]=None): """ :param sdk_key: The SDK key for your LaunchDarkly account. This is always required. :param base_uri: The base URL for the LaunchDarkly server. Most users should use the default @@ -239,6 +240,7 @@ def __init__(self, servers. :param http: Optional properties for customizing the client's HTTP/HTTPS behavior. See :class:`HTTPConfig`. + :param application: Optional properties for setting application metadata. See :py:attr:`~application` """ self.__sdk_key = sdk_key @@ -271,6 +273,7 @@ def __init__(self, self.__wrapper_version = wrapper_version self.__http = http self.__big_segments = BigSegmentsConfig() if not big_segments else big_segments + self.__application = validate_application_info(application or {}, log) def copy_with_new_sdk_key(self, new_sdk_key: str) -> 'Config': """Returns a new ``Config`` instance that is the same as this one, except for having a different SDK key. @@ -441,3 +444,16 @@ def big_segments(self) -> BigSegmentsConfig: def _validate(self): if self.offline is False and self.sdk_key is None or self.sdk_key == '': log.warning("Missing or blank sdk_key.") + + @property + def application(self) -> dict: + """ + An object that allows configuration of application metadata. + + Application metadata may be used in LaunchDarkly analytics or other + product features, but does not affect feature flag evaluations. + + If you want to set non-default values for any of these fields, provide + the appropriately configured dict to the {Config} object. + """ + return self.__application diff --git a/ldclient/impl/big_segments.py b/ldclient/impl/big_segments.py index b6a013d3..bcd6e2b8 100644 --- a/ldclient/impl/big_segments.py +++ b/ldclient/impl/big_segments.py @@ -81,7 +81,6 @@ def get_user_membership(self, user_key: str) -> Tuple[Optional[dict], str]: membership = self.__cache.get(user_key) if membership is None: user_hash = _hash_for_user_key(user_key) - log.warn("*** querying Big Segments for user hash: %s" % user_hash) try: membership = self.__store.get_membership(user_hash) if membership is None: diff --git a/ldclient/impl/http.py b/ldclient/impl/http.py index ef36c8ba..858fd371 100644 --- a/ldclient/impl/http.py +++ b/ldclient/impl/http.py @@ -3,14 +3,34 @@ from os import environ import urllib3 +def _application_header_value(application: dict) -> str: + parts = [] + id = application.get('id', '') + version = application.get('version', '') + + if id: + parts.append("application-id/%s" % id) + + if version: + parts.append("application-version/%s" % version) + + return " ".join(parts) + + def _base_headers(config): headers = {'Authorization': config.sdk_key or '', 'User-Agent': 'PythonClient/' + VERSION} + + app_value = _application_header_value(config.application) + if app_value: + headers['X-LaunchDarkly-Tags'] = app_value + if isinstance(config.wrapper_name, str) and config.wrapper_name != "": wrapper_version = "" if isinstance(config.wrapper_version, str) and config.wrapper_version != "": wrapper_version = "/" + config.wrapper_version headers.update({'X-LaunchDarkly-Wrapper': config.wrapper_name + wrapper_version}) + return headers def _http_factory(config): diff --git a/ldclient/util.py b/ldclient/util.py index 66c0c70b..042f33dc 100644 --- a/ldclient/util.py +++ b/ldclient/util.py @@ -4,10 +4,12 @@ # currently excluded from documentation - see docs/README.md import logging +import re from os import environ import sys import urllib3 +from typing import Any from ldclient.impl.http import HTTPFactory, _base_headers log = logging.getLogger(sys.modules[__name__].__name__) @@ -25,6 +27,26 @@ _retryable_statuses = [400, 408, 429] +def validate_application_info(application: dict, logger: logging.Logger) -> dict: + return { + "id": validate_application_value(application.get("id", ""), "id", logger), + "version": validate_application_value(application.get("version", ""), "version", logger), + } + +def validate_application_value(value: Any, name: str, logger: logging.Logger) -> str: + if not isinstance(value, str): + return "" + + if len(value) > 64: + logger.warning('Value of application[%s] was longer than 64 characters and was discarded' % name) + return "" + + if re.search(r"[^a-zA-Z0-9._-]", value): + logger.warning('Value of application[%s] contained invalid characters and was discarded' % name) + return "" + + return value + def _headers(config): base_headers = _base_headers(config) base_headers.update({'Content-Type': "application/json"}) diff --git a/testing/test_config.py b/testing/test_config.py index 701e70e5..7c5e342d 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1,4 +1,5 @@ from ldclient.config import Config +import pytest def test_copy_config(): @@ -40,3 +41,29 @@ def test_trims_trailing_slashes_on_uris(): assert config.base_uri == "https://launchdarkly.com" assert config.events_uri == "https://docs.launchdarkly.com/bulk" assert config.stream_base_uri == "https://blog.launchdarkly.com" + +def application_can_be_set_and_read(): + application = {"id": "my-id", "version": "abcdef"} + config = Config(sdk_key = "SDK_KEY", application = application) + assert config.application == {"id": "my-id", "version": "abcdef"} + +def application_can_handle_non_string_values(): + application = {"id": 1, "version": 2} + config = Config(sdk_key = "SDK_KEY", application = application) + assert config.application == {"id": "1", "version": "2"} + +def application_will_ignore_invalid_keys(): + application = {"invalid": 1, "key": 2} + config = Config(sdk_key = "SDK_KEY", application = application) + assert config.application == {"id": "", "version": ""} + +@pytest.fixture(params = [ + " ", + "@", + ":", + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-a" +]) +def application_will_drop_invalid_values(value): + application = {"id": value, "version": value} + config = Config(sdk_key = "SDK_KEY", application = application) + assert config.application == {"id": "", "version": ""} diff --git a/testing/test_event_processor.py b/testing/test_event_processor.py index 363d980e..ec777812 100644 --- a/testing/test_event_processor.py +++ b/testing/test_event_processor.py @@ -484,6 +484,22 @@ def test_wrapper_header_sent_without_version(): assert mock_http.request_headers.get('X-LaunchDarkly-Wrapper') == "Flask" +def test_application_header_not_sent_when_not_set(): + with DefaultTestProcessor() as ep: + ep.send_event({ 'kind': 'identify', 'user': user }) + ep.flush() + ep._wait_until_inactive() + + assert mock_http.request_headers.get('X-LaunchDarkly-Tags') is None + +def test_application_header_sent_when_set(): + with DefaultTestProcessor(wrapper_name = "Flask", application = {"id": "my-id", "version": "my-version"}) as ep: + ep.send_event({ 'kind': 'identify', 'user': user }) + ep.flush() + ep._wait_until_inactive() + + assert mock_http.request_headers.get('X-LaunchDarkly-Tags') == "application-id/my-id application-version/my-version" + def test_event_schema_set_on_event_send(): with DefaultTestProcessor() as ep: ep.send_event({ 'kind': 'identify', 'user': user }) diff --git a/testing/test_feature_requester.py b/testing/test_feature_requester.py index db18f555..031167dc 100644 --- a/testing/test_feature_requester.py +++ b/testing/test_feature_requester.py @@ -35,6 +35,7 @@ def test_get_all_data_sends_headers(): assert req.headers['Authorization'] == 'sdk-key' assert req.headers['User-Agent'] == 'PythonClient/' + VERSION assert req.headers.get('X-LaunchDarkly-Wrapper') is None + assert req.headers.get('X-LaunchDarkly-Tags') is None def test_get_all_data_sends_wrapper_header(): with start_server() as server: @@ -62,6 +63,19 @@ def test_get_all_data_sends_wrapper_header_without_version(): req = server.require_request() assert req.headers.get('X-LaunchDarkly-Wrapper') == 'Flask' +def test_get_all_data_sends_tags_header(): + with start_server() as server: + config = Config(sdk_key = 'sdk-key', base_uri = server.uri, + application = {"id": "my-id", "version": "my-version"}) + fr = FeatureRequesterImpl(config) + + resp_data = { 'flags': {}, 'segments': {} } + server.for_path('/sdk/latest-all', JsonResponse(resp_data)) + + fr.get_all_data() + req = server.require_request() + assert req.headers.get('X-LaunchDarkly-Tags') == 'application-id/my-id application-version/my-version' + def test_get_all_data_can_use_cached_data(): with start_server() as server: config = Config(sdk_key = 'sdk-key', base_uri = server.uri) diff --git a/testing/test_ldclient_end_to_end.py b/testing/test_ldclient_end_to_end.py index 7003805a..3f550d0f 100644 --- a/testing/test_ldclient_end_to_end.py +++ b/testing/test_ldclient_end_to_end.py @@ -102,12 +102,12 @@ def test_client_sends_diagnostics(): data = json.loads(r.body) assert data['kind'] == 'diagnostic-init' -# The TLS tests are skipped in Python 3.3 because the embedded HTTPS server does not work correctly, causing +# The TLS tests are skipped in Python 3.7 because the embedded HTTPS server does not work correctly, causing # a TLS handshake failure on the client side. It's unclear whether this is a problem with the self-signed # certificate we are using or with some other server settings, but it does not appear to be a client-side -# problem. +# problem since we know that the SDK is able to connect to secure LD endpoints. -@pytest.mark.skipif(sys.version_info.major == 3 and sys.version_info.minor == 3, reason = "test is skipped in Python 3.3") +@pytest.mark.skipif(sys.version_info.major == 3 and sys.version_info.minor == 7, reason = "test is skipped in Python 3.7") def test_cannot_connect_with_selfsigned_cert_by_default(): with start_secure_server() as server: server.for_path('/sdk/latest-all', poll_content()) @@ -120,7 +120,7 @@ def test_cannot_connect_with_selfsigned_cert_by_default(): with LDClient(config = config, start_wait = 1.5) as client: assert not client.is_initialized() -@pytest.mark.skipif(sys.version_info.major == 3 and sys.version_info.minor == 3, reason = "test is skipped in Python 3.3") +@pytest.mark.skipif(sys.version_info.major == 3 and sys.version_info.minor == 7, reason = "test is skipped in Python 3.7") def test_can_connect_with_selfsigned_cert_if_ssl_verify_is_false(): with start_secure_server() as server: server.for_path('/sdk/latest-all', poll_content()) @@ -134,7 +134,7 @@ def test_can_connect_with_selfsigned_cert_if_ssl_verify_is_false(): with LDClient(config = config) as client: assert client.is_initialized() -@pytest.mark.skipif(sys.version_info.major == 3 and sys.version_info.minor == 3, reason = "test is skipped in Python 3.3") +@pytest.mark.skipif(sys.version_info.major == 3 and sys.version_info.minor == 7, reason = "test is skipped in Python 3.7") def test_can_connect_with_selfsigned_cert_if_disable_ssl_verification_is_true(): with start_secure_server() as server: server.for_path('/sdk/latest-all', poll_content()) @@ -148,7 +148,7 @@ def test_can_connect_with_selfsigned_cert_if_disable_ssl_verification_is_true(): with LDClient(config = config) as client: assert client.is_initialized() -@pytest.mark.skipif(sys.version_info.major == 3 and sys.version_info.minor == 3, reason = "test is skipped in Python 3.3") +@pytest.mark.skipif(sys.version_info.major == 3 and sys.version_info.minor == 7, reason = "test is skipped in Python 3.7") def test_can_connect_with_selfsigned_cert_by_setting_ca_certs(): with start_secure_server() as server: server.for_path('/sdk/latest-all', poll_content()) diff --git a/testing/test_streaming.py b/testing/test_streaming.py index 1838e500..82700b4d 100644 --- a/testing/test_streaming.py +++ b/testing/test_streaming.py @@ -38,6 +38,7 @@ def test_request_properties(): assert req.headers.get('Authorization') == 'sdk-key' assert req.headers.get('User-Agent') == 'PythonClient/' + VERSION assert req.headers.get('X-LaunchDarkly-Wrapper') is None + assert req.headers.get('X-LaunchDarkly-Tags') is None def test_sends_wrapper_header(): store = InMemoryFeatureStore() @@ -69,6 +70,21 @@ def test_sends_wrapper_header_without_version(): req = server.await_request() assert req.headers.get('X-LaunchDarkly-Wrapper') == 'Flask' +def test_sends_tag_header(): + store = InMemoryFeatureStore() + ready = Event() + + with start_server() as server: + with stream_content(make_put_event()) as stream: + config = Config(sdk_key = 'sdk-key', stream_uri = server.uri, + application = {"id": "my-id", "version": "my-version"}) + server.for_path('/all', stream) + + with StreamingUpdateProcessor(config, store, ready, None) as sp: + sp.start() + req = server.await_request() + assert req.headers.get('X-LaunchDarkly-Tags') == 'application-id/my-id application-version/my-version' + def test_receives_put_event(): store = InMemoryFeatureStore() ready = Event()