diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f50bf42b..2407beed 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -40,7 +40,7 @@ "ms-azuretools.vscode-docker", "ritwickdey.LiveServer", "ms-vscode.makefile-tools", - "bungcip.better-toml", + "tamasfe.even-better-toml", "ms-python.vscode-pylance", "ms-azuretools.vscode-docker" ] diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml index 21f1bca0..85f6c063 100644 --- a/.github/workflows/release-docs.yml +++ b/.github/workflows/release-docs.yml @@ -7,6 +7,8 @@ on: jobs: Build: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 02fb4507..a6b76613 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -7,6 +7,10 @@ on: jobs: main: runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + environment: pypi steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 @@ -19,9 +23,10 @@ jobs: - name: Build package run: | make build + - name: Publish package to Pypi + uses: pypa/gh-action-pypi-publish@release/v1 env: - TWINE_USERNAME: ${{ secrets.pypi_username }} - TWINE_PASSWORD: ${{ secrets.pypi_password }} + repository-url: https://pypi.org/project/UnleashClient/ - name: Build docs run: | make install-docs diff --git a/Makefile b/Makefile index 5c2acc1a..9ac45091 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ test: lint pytest specification-test precommit: clean generate-requirements -build: clean build-package upload +build: clean build-package build-local: clean build-package @@ -63,9 +63,6 @@ clean: build-package: python -m build -upload: - twine upload dist/* - #----------------------------------------------------------------------- # Docs #----------------------------------------------------------------------- diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index df9bf0b4..2cb7dc68 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -33,6 +33,15 @@ from .utils import LOGGER, InstanceAllowType, InstanceCounter INSTANCES = InstanceCounter() +_BASE_CONTEXT_FIELDS = [ + "userId", + "sessionId", + "environment", + "appName", + "currentTime", + "remoteAddress", + "properties", +] # pylint: disable=dangerous-default-value @@ -438,7 +447,7 @@ def _safe_context(self, context) -> dict: if "currentTime" not in new_context: new_context["currentTime"] = datetime.now(timezone.utc).isoformat() - safe_properties = new_context.get("properties", {}) + safe_properties = self._extract_properties(new_context) safe_properties = { k: self._safe_context_value(v) for k, v in safe_properties.items() } @@ -452,6 +461,14 @@ def _safe_context(self, context) -> dict: return safe_context + def _extract_properties(self, context: dict) -> dict: + properties = context.get("properties", {}) + extracted_fields = { + k: v for k, v in context.items() if k not in _BASE_CONTEXT_FIELDS + } + extracted_fields.update(properties) + return extracted_fields + def _safe_context_value(self, value): if isinstance(value, datetime): return value.isoformat() diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 4cdb4c6f..5bbcec88 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -14,6 +14,7 @@ MOCK_FEATURE_ENABLED_NO_VARIANTS_RESPONSE, MOCK_FEATURE_RESPONSE, MOCK_FEATURE_RESPONSE_PROJECT, + MOCK_FEATURE_WITH_CUSTOM_CONTEXT_REQUIREMENTS, MOCK_FEATURE_WITH_DATE_AFTER_CONSTRAINT, MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE, MOCK_FEATURE_WITH_NUMERIC_CONSTRAINT, @@ -976,3 +977,58 @@ def test_context_adds_current_time_if_not_set(): ) assert unleash_client.is_enabled("DateConstraint") + + +def test_context_moves_properties_fields_to_properties(): + unleash_client = UnleashClient( + URL, + APP_NAME, + disable_metrics=True, + disable_registration=True, + ) + + context = {"myContext": "1234"} + + assert "myContext" in unleash_client._safe_context(context)["properties"] + + +def test_existing_properties_are_retained_when_custom_context_properties_are_in_the_root(): + unleash_client = UnleashClient( + URL, + APP_NAME, + disable_metrics=True, + disable_registration=True, + ) + + context = {"myContext": "1234", "properties": {"yourContext": "1234"}} + + assert "myContext" in unleash_client._safe_context(context)["properties"] + assert "yourContext" in unleash_client._safe_context(context)["properties"] + + +def test_base_context_properties_are_retained_in_root(): + unleash_client = UnleashClient( + URL, + APP_NAME, + disable_metrics=True, + disable_registration=True, + ) + + context = {"userId": "1234"} + + assert "userId" in unleash_client._safe_context(context) + + +def test_is_enabled_works_with_properties_field_in_the_context_root(): + cache = FileCache("MOCK_CACHE") + cache.bootstrap_from_dict(MOCK_FEATURE_WITH_CUSTOM_CONTEXT_REQUIREMENTS) + unleash_client = UnleashClient( + URL, + APP_NAME, + disable_metrics=True, + cache=cache, + disable_registration=True, + ) + + context = {"myContext": "1234"} + assert unleash_client.is_enabled("customContextToggle", context) diff --git a/tests/utilities/mocks/mock_features.py b/tests/utilities/mocks/mock_features.py index c655951c..a603afe0 100644 --- a/tests/utilities/mocks/mock_features.py +++ b/tests/utilities/mocks/mock_features.py @@ -352,3 +352,30 @@ }, ], } + +MOCK_FEATURE_WITH_CUSTOM_CONTEXT_REQUIREMENTS = { + "version": 1, + "features": [ + { + "name": "customContextToggle", + "description": "Feature toggle with custom context constraint", + "enabled": True, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "myContext", + "operator": "IN", + "values": ["1234"], + "inverted": False, + } + ], + } + ], + "createdAt": "2018-10-09T06:04:05.667Z", + "impressionData": False, + }, + ], +}