From 71201f2ada86ab2564c066b57bb02e036114b93a Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sat, 20 Dec 2025 13:08:17 -0800 Subject: [PATCH] feat: added numeric strategy handler and Python 3.14 to matrix --- .devcontainer/Dockerfile | 2 +- .github/workflows/master.yml | 8 +- .github/workflows/release.yml | 6 +- .github/workflows/sonar.yml | 8 +- .github/workflows/staging.yml | 2 +- README.md | 2 +- pyproject.toml | 3 +- sonar-project.properties | 2 +- switcher_client/lib/snapshot.py | 32 ++++- tests/strategy-operations/test_numeric.py | 138 ++++++++++++++++++++++ tests/strategy-operations/test_value.py | 7 ++ 11 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 tests/strategy-operations/test_numeric.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 362bd46..42f4888 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-alpine +FROM python:3.14-alpine # Update pip and install dependencies and tools RUN pip install --upgrade pip && \ diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index f794177..550d64a 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -17,10 +17,10 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.13 + - name: Set up Python 3.14 uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: | @@ -31,7 +31,7 @@ jobs: run: pipenv run pytest -v --cov=./switcher_client --cov-report xml - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v6.0.0 + uses: sonarsource/sonarqube-scan-action@v7.0.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] os: [ ubuntu-latest, windows-latest ] runs-on: ${{ matrix.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67e74be..221bb0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.x" + python-version: "3.14" - name: Install pypa/build run: >- python3 -m @@ -41,7 +41,7 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ @@ -60,7 +60,7 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 6f6ae55..439539f 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Get PR details id: pr - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const pr = await github.rest.pulls.get({ @@ -33,10 +33,10 @@ jobs: ref: ${{ steps.pr.outputs.head_sha }} fetch-depth: 0 - - name: Set up Python 3.13 + - name: Set up Python 3.14 uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: | @@ -47,7 +47,7 @@ jobs: run: pipenv run pytest -v --cov=./switcher_client --cov-report xml - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v6.0.0 + uses: sonarsource/sonarqube-scan-action@v7.0.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 221d818..1a02ca3 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -7,7 +7,7 @@ on: python: description: 'Python version' required: true - default: '3.13' + default: '3.14' os: description: 'Operating System (ubuntu-20.04, ubuntu-latest, windows-latest)' required: true diff --git a/README.md b/README.md index 84117ff..991f636 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ pip install switcher-client ``` ### System Requirements -- **Python**: 3.9+ (supports 3.9, 3.10, 3.11, 3.12, 3.13) +- **Python**: 3.9+ (supports 3.9, 3.10, 3.11, 3.12, 3.13, 3.14) - **Operating System**: Cross-platform (Windows, macOS, Linux) ## Configuration diff --git a/pyproject.toml b/pyproject.toml index 8384762..1d55701 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,12 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -requires-python = ">=3.6" +requires-python = ">=3.9" [project.urls] Homepage = "https://github.com/switcherapi" diff --git a/sonar-project.properties b/sonar-project.properties index 32710bd..8a26693 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,5 +7,5 @@ sonar.links.homepage=https://github.com/switcherapi/switcher-client-py sonar.sources=switcher_client sonar.tests=tests sonar.python.coverage.reportPaths=coverage.xml -sonar.python.version=3.13 +sonar.python.version=3.14 sonar.exclusions=**/tests/**, **/switcher_client/version.py \ No newline at end of file diff --git a/switcher_client/lib/snapshot.py b/switcher_client/lib/snapshot.py index 114d123..cc59c23 100644 --- a/switcher_client/lib/snapshot.py +++ b/switcher_client/lib/snapshot.py @@ -4,12 +4,16 @@ class StrategiesType(Enum): VALUE = "VALUE_VALIDATION" + NUMERIC = "NUMERIC_VALIDATION" class OperationsType(Enum): EXIST = "EXIST" NOT_EXIST = "NOT_EXIST" EQUAL = "EQUAL" NOT_EQUAL = "NOT_EQUAL" + GREATER = "GREATER" + LOWER = "LOWER" + BETWEEN = "BETWEEN" def process_operation(strategy_config: dict, input_value: str) -> Optional[bool]: strategy = strategy_config.get('strategy') @@ -19,6 +23,8 @@ def process_operation(strategy_config: dict, input_value: str) -> Optional[bool] match strategy: case StrategiesType.VALUE.value: return process_value(operation, values, input_value) + case StrategiesType.NUMERIC.value: + return process_numeric(operation, values, input_value) def process_value(operation: str, values: list, input_value: str) -> Optional[bool]: match operation: @@ -29,4 +35,28 @@ def process_value(operation: str, values: list, input_value: str) -> Optional[bo case OperationsType.EQUAL.value: return input_value in values case OperationsType.NOT_EQUAL.value: - return input_value not in values \ No newline at end of file + return input_value not in values + +def process_numeric(operation: str, values: list, input_value: str) -> Optional[bool]: + try: + numeric_input = float(input_value) + except ValueError: + return None + + numeric_values = [float(v) for v in values] + + match operation: + case OperationsType.EXIST.value: + return numeric_input in numeric_values + case OperationsType.NOT_EXIST.value: + return numeric_input not in numeric_values + case OperationsType.EQUAL.value: + return numeric_input in numeric_values + case OperationsType.NOT_EQUAL.value: + return numeric_input not in numeric_values + case OperationsType.GREATER.value: + return any(numeric_input > v for v in numeric_values) + case OperationsType.LOWER.value: + return any(numeric_input < v for v in numeric_values) + case OperationsType.BETWEEN.value: + return numeric_input >= numeric_values[0] and numeric_input <= numeric_values[1] diff --git a/tests/strategy-operations/test_numeric.py b/tests/strategy-operations/test_numeric.py new file mode 100644 index 0000000..33e3f50 --- /dev/null +++ b/tests/strategy-operations/test_numeric.py @@ -0,0 +1,138 @@ +import pytest +from typing import Dict, List, Any + +from switcher_client.lib.snapshot import OperationsType, StrategiesType, process_operation + +class TestNumericStrategy: + """Test suite for Strategy [NUMERIC] tests.""" + + @pytest.fixture + def mock_values1(self) -> List[str]: + """Single numeric value mock data.""" + return ['1'] + + @pytest.fixture + def mock_values2(self) -> List[str]: + """Multiple numeric values mock data.""" + return ['1', '3'] + + @pytest.fixture + def mock_values3(self) -> List[str]: + """Decimal numeric value mock data.""" + return ['1.5'] + + def given_strategy_config(self, operation: str, values: List[str]) -> Dict[str, Any]: + return { + 'strategy': StrategiesType.NUMERIC.value, + 'operation': operation, + 'values': values, + 'activated': True + } + + def test_should_agree_when_input_exist_in_values_string_type(self, mock_values2): + """Should agree when input EXIST in values - String type.""" + + strategy_config = self.given_strategy_config(OperationsType.EXIST.value, mock_values2) + result = process_operation(strategy_config, '3') + assert result is True + + def test_should_not_agree_when_input_exist_but_test_as_does_not_exist(self, mock_values2): + """Should NOT agree when input exist but test as DOES NOT EXIST.""" + + strategy_config = self.given_strategy_config(OperationsType.NOT_EXIST.value, mock_values2) + result = process_operation(strategy_config, '1') + assert result is False + + def test_should_agree_when_input_does_not_exist_in_values(self, mock_values2): + """Should agree when input DOES NOT EXIST in values.""" + + strategy_config = self.given_strategy_config(OperationsType.NOT_EXIST.value, mock_values2) + result = process_operation(strategy_config, '2') + assert result is True + + def test_should_agree_when_input_is_equal_to_value(self, mock_values1): + """Should agree when input is EQUAL to value.""" + + strategy_config = self.given_strategy_config(OperationsType.EQUAL.value, mock_values1) + result = process_operation(strategy_config, '1') + assert result is True + + def test_should_not_agree_when_input_is_not_equal_but_test_as_equal(self, mock_values1): + """Should NOT agree when input is not equal but test as EQUAL.""" + + strategy_config = self.given_strategy_config(OperationsType.EQUAL.value, mock_values1) + result = process_operation(strategy_config, '2') + assert result is False + + def test_should_agree_when_input_is_not_equal_to_value(self, mock_values1): + """Should agree when input is NOT EQUAL to value.""" + + strategy_config = self.given_strategy_config(OperationsType.NOT_EQUAL.value, mock_values1) + result = process_operation(strategy_config, '2') + assert result is True + + def test_should_agree_when_input_is_greater_than_value(self, mock_values1, mock_values3): + """Should agree when input is GREATER than value.""" + + strategy_config = self.given_strategy_config(OperationsType.GREATER.value, mock_values1) + result = process_operation(strategy_config, '2') + assert result is True + + # test decimal + result = process_operation(strategy_config, '1.01') + assert result is True + + strategy_config = self.given_strategy_config(OperationsType.GREATER.value, mock_values3) + result = process_operation(strategy_config, '1.55') + assert result is True + + def test_should_not_agree_when_input_is_lower_but_tested_as_greater_than_value(self, mock_values1, mock_values3): + """Should NOT agree when input is lower but tested as GREATER than value.""" + + strategy_config = self.given_strategy_config(OperationsType.GREATER.value, mock_values1) + result = process_operation(strategy_config, '0') + assert result is False + + # test decimal + result = process_operation(strategy_config, '0.99') + assert result is False + + strategy_config = self.given_strategy_config(OperationsType.GREATER.value, mock_values3) + result = process_operation(strategy_config, '1.49') + assert result is False + + def test_should_agree_when_input_is_lower_than_value(self, mock_values1, mock_values3): + """Should agree when input is LOWER than value.""" + + strategy_config = self.given_strategy_config(OperationsType.LOWER.value, mock_values1) + result = process_operation(strategy_config, '0') + assert result is True + + # test decimal + result = process_operation(strategy_config, '0.99') + assert result is True + + strategy_config = self.given_strategy_config(OperationsType.LOWER.value, mock_values3) + result = process_operation(strategy_config, '1.49') + assert result is True + + def test_should_agree_when_input_is_between_values(self, mock_values2): + """Should agree when input is BETWEEN values.""" + + strategy_config = self.given_strategy_config(OperationsType.BETWEEN.value, mock_values2) + result = process_operation(strategy_config, '1') + assert result is True + + # test decimal + result = process_operation(strategy_config, '2.99') + assert result is True + + result = process_operation(strategy_config, '1.001') + assert result is True + + def test_should_not_agree_when_input_is_not_number(self, mock_values2): + """Should NOT agree when input is NOT A NUMBER.""" + + strategy_config = self.given_strategy_config(OperationsType.GREATER.value, mock_values2) + result = process_operation(strategy_config, 'ABC') + assert result is None diff --git a/tests/strategy-operations/test_value.py b/tests/strategy-operations/test_value.py index 94427ba..0a2c39a 100644 --- a/tests/strategy-operations/test_value.py +++ b/tests/strategy-operations/test_value.py @@ -26,42 +26,49 @@ def given_strategy_config(self, operation: str, values: List[str]) -> Dict[str, def test_should_agree_when_input_exist(self, mock_values1): """Should agree when input EXIST.""" + strategy_config = self.given_strategy_config(OperationsType.EXIST.value, mock_values1) result = process_operation(strategy_config, 'USER_1') assert result is True def test_should_not_agree_when_input_does_not_exist(self, mock_values1): """Should NOT agree when input DOES NOT EXIST.""" + strategy_config = self.given_strategy_config(OperationsType.EXIST.value, mock_values1) result = process_operation(strategy_config, 'USER_123') assert result is False def test_should_agree_when_input_does_not_exist_with_not_exist_operation(self, mock_values1): """Should agree when input DOES NOT EXIST with NOT_EXIST operation.""" + strategy_config = self.given_strategy_config(OperationsType.NOT_EXIST.value, mock_values1) result = process_operation(strategy_config, 'USER_123') assert result is True def test_should_agree_when_input_is_equal(self, mock_values1): """Should agree when input is EQUAL.""" + strategy_config = self.given_strategy_config(OperationsType.EQUAL.value, mock_values1) result = process_operation(strategy_config, 'USER_1') assert result is True def test_should_not_agree_when_input_is_not_equal(self, mock_values1): """Should NOT agree when input is NOT EQUAL.""" + strategy_config = self.given_strategy_config(OperationsType.EQUAL.value, mock_values1) result = process_operation(strategy_config, 'USER_2') assert result is False def test_should_agree_when_input_is_not_equal_with_not_equal_operation(self, mock_values2): """Should agree when input is NOT EQUAL with NOT_EQUAL operation.""" + strategy_config = self.given_strategy_config(OperationsType.NOT_EQUAL.value, mock_values2) result = process_operation(strategy_config, 'USER_123') assert result is True def test_should_not_agree_when_input_is_not_equal_but_value_exists(self, mock_values2): """Should NOT agree when input is NOT EQUAL but value exists in list.""" + strategy_config = self.given_strategy_config(OperationsType.NOT_EQUAL.value, mock_values2) result = process_operation(strategy_config, 'USER_2') assert result is False