Skip to content

Commit 789120f

Browse files
authored
Static Type Checking Intro (#35)
* Starting to add static type check * Adding casts for user input * Updated public methods * untyped defs * warn_return_any * Update _featurefilters.py * Update _send_telemetry.py * formatting * updating kwargs type hint * cspell * cspell fixes * Any * Update _variant.py
1 parent eb159d2 commit 789120f

24 files changed

+372
-241
lines changed

.github/workflows/validate.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,5 @@ jobs:
2727
- name: Test with pytest
2828
run: |
2929
pytest tests --doctest-modules --cov-report=xml --cov-report=html
30+
- name: cspell-action
31+
uses: streetsidesoftware/[email protected]

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,5 @@ docs/_static
411411
docs/_templates
412412
docs/doctrees
413413
docs/html
414+
package-lock.json
415+
package.json

cspell.config.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
$schema: https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json
3+
version: '0.2'
4+
dictionaryDefinitions:
5+
- name: project-words
6+
path: './project-words.txt'
7+
addWords: true
8+
dictionaries:
9+
- project-words
10+
ignorePaths:
11+
- 'env'
12+
- '.*'
13+
- 'build'
14+
- 'docs'
15+
- 'dev_requirements.txt'
16+
- '*.egg-info'
17+
- '*.ini'
18+
- '*.toml'
19+
- 'SECURITY.md'
20+
- 'SUPPORT.md'

featuremanagement/_defaultfilters.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
# -------------------------------------------------------------------------
66
import logging
77
import hashlib
8-
98
from datetime import datetime, timezone
109
from email.utils import parsedate_to_datetime
11-
10+
from typing import cast, List, Mapping, Optional, Dict, Any
1211
from ._featurefilters import FeatureFilter
1312

1413
FEATURE_FLAG_NAME_KEY = "feature_name"
@@ -45,7 +44,7 @@ class TimeWindowFilter(FeatureFilter):
4544
Feature Filter that determines if the current time is within the time window.
4645
"""
4746

48-
def evaluate(self, context, **kwargs):
47+
def evaluate(self, context: Mapping, **kwargs: Dict[str, Any]) -> bool:
4948
"""
5049
Determine if the feature flag is enabled for the given context.
5150
@@ -75,7 +74,7 @@ class TargetingFilter(FeatureFilter):
7574
"""
7675

7776
@staticmethod
78-
def _is_targeted(context_id, rollout_percentage):
77+
def _is_targeted(context_id: str, rollout_percentage: int) -> bool:
7978
"""Determine if the user is targeted for the given context"""
8079
# Always return true if rollout percentage is 100
8180
if rollout_percentage == 100:
@@ -87,24 +86,29 @@ def _is_targeted(context_id, rollout_percentage):
8786
percentage = (context_marker / (2**32 - 1)) * 100
8887
return percentage < rollout_percentage
8988

90-
def _target_group(self, target_user, target_group, group, feature_flag_name):
89+
def _target_group(
90+
self, target_user: Optional[str], target_group: str, group: Mapping, feature_flag_name: str
91+
) -> bool:
9192
group_rollout_percentage = group.get(ROLLOUT_PERCENTAGE_KEY, 0)
9293
if not target_user:
9394
target_user = ""
9495
audience_context_id = target_user + "\n" + feature_flag_name + "\n" + target_group
9596

9697
return self._is_targeted(audience_context_id, group_rollout_percentage)
9798

98-
def evaluate(self, context, **kwargs):
99+
def evaluate(self, context: Mapping, **kwargs: Dict[str, Any]) -> bool:
99100
"""
100101
Determine if the feature flag is enabled for the given context.
101102
102103
:keyword Mapping context: Context for evaluating the user/group.
103104
:return: True if the user is targeted for the feature flag.
104105
:rtype: bool
105106
"""
106-
target_user = kwargs.get(TARGETED_USER_KEY, None)
107-
target_groups = kwargs.get(TARGETED_GROUPS_KEY, [])
107+
target_user: Optional[str] = cast(
108+
str,
109+
kwargs.get(TARGETED_USER_KEY, None),
110+
)
111+
target_groups: List[str] = cast(List[str], kwargs.get(TARGETED_GROUPS_KEY, []))
108112

109113
if not target_user and not (target_groups and len(target_groups) > 0):
110114
logging.warning("%s: Name or Groups are required parameters", TargetingFilter.__name__)
@@ -152,7 +156,7 @@ def evaluate(self, context, **kwargs):
152156
return self._is_targeted(context_id, default_rollout_percentage)
153157

154158
@staticmethod
155-
def _validate(groups, default_rollout_percentage):
159+
def _validate(groups: List, default_rollout_percentage: int) -> None:
156160
# Validate the audience settings
157161
if default_rollout_percentage < 0 or default_rollout_percentage > 100:
158162
raise TargetingException("DefaultRolloutPercentage must be between 0 and 100")

featuremanagement/_featurefilters.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# license information.
55
# -------------------------------------------------------------------------
66
from abc import ABC, abstractmethod
7+
from typing import Mapping, Callable, Dict, Any
78

89

910
class FeatureFilter(ABC):
@@ -12,36 +13,36 @@ class FeatureFilter(ABC):
1213
"""
1314

1415
@abstractmethod
15-
def evaluate(self, context, **kwargs):
16+
def evaluate(self, context: Mapping, **kwargs: Dict[str, Any]) -> bool:
1617
"""
1718
Determine if the feature flag is enabled for the given context.
1819
1920
:param Mapping context: Context for the feature flag.
2021
"""
2122

2223
@property
23-
def name(self):
24+
def name(self) -> str:
2425
"""
2526
Get the name of the filter.
2627
2728
:return: Name of the filter, or alias if it exists.
2829
:rtype: str
2930
"""
3031
if hasattr(self, "_alias"):
31-
return self._alias
32+
return self._alias # type: ignore
3233
return self.__class__.__name__
3334

3435
@staticmethod
35-
def alias(alias):
36+
def alias(alias: str) -> Callable:
3637
"""
3738
Decorator to set the alias for the filter.
3839
3940
:param str alias: Alias for the filter.
4041
:return: Decorator.
41-
:rtype: callable
42+
:rtype: Callable
4243
"""
4344

44-
def wrapper(cls):
45+
def wrapper(cls) -> Any: # type: ignore
4546
cls._alias = alias # pylint: disable=protected-access
4647
return cls
4748

featuremanagement/_featuremanager.py

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
# license information.
55
# -------------------------------------------------------------------------
66
import logging
7-
from typing import overload
7+
from typing import cast, overload, Any, Optional, Dict, Mapping, List
88
from ._defaultfilters import TimeWindowFilter, TargetingFilter
99
from ._featurefilters import FeatureFilter
10-
from ._models import EvaluationEvent
10+
from ._models import EvaluationEvent, Variant, TargetingContext
1111
from ._featuremanagerbase import (
1212
_get_feature_flag,
1313
FeatureManagerBase,
@@ -28,17 +28,17 @@ class FeatureManager(FeatureManagerBase):
2828
evaluated.
2929
"""
3030

31-
def __init__(self, configuration, **kwargs):
31+
def __init__(self, configuration: Mapping, **kwargs: Dict[str, Any]):
3232
super().__init__(configuration, **kwargs)
33-
filters = [TimeWindowFilter(), TargetingFilter()] + kwargs.pop(PROVIDED_FEATURE_FILTERS, [])
33+
filters = [TimeWindowFilter(), TargetingFilter()] + cast(List, kwargs.pop(PROVIDED_FEATURE_FILTERS, []))
3434

3535
for feature_filter in filters:
3636
if not isinstance(feature_filter, FeatureFilter):
3737
raise ValueError("Custom filter must be a subclass of FeatureFilter")
3838
self._filters[feature_filter.name] = feature_filter
3939

40-
@overload
41-
def is_enabled(self, feature_flag_id, user_id, **kwargs):
40+
@overload # type: ignore
41+
def is_enabled(self, feature_flag_id: str, user_id: str, **kwargs: Dict[str, Any]) -> bool:
4242
"""
4343
Determine if the feature flag is enabled for the given context.
4444
@@ -48,7 +48,7 @@ def is_enabled(self, feature_flag_id, user_id, **kwargs):
4848
:rtype: bool
4949
"""
5050

51-
def is_enabled(self, feature_flag_id, *args, **kwargs):
51+
def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Dict[str, Any]) -> bool: # type: ignore
5252
"""
5353
Determine if the feature flag is enabled for the given context.
5454
@@ -59,13 +59,18 @@ def is_enabled(self, feature_flag_id, *args, **kwargs):
5959
targeting_context = self._build_targeting_context(args)
6060

6161
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
62-
if self._on_feature_evaluated and result.feature.telemetry.enabled:
62+
if (
63+
self._on_feature_evaluated
64+
and result.feature
65+
and result.feature.telemetry.enabled
66+
and callable(self._on_feature_evaluated)
67+
):
6368
result.user = targeting_context.user_id
6469
self._on_feature_evaluated(result)
6570
return result.enabled
6671

67-
@overload
68-
def get_variant(self, feature_flag_id, user_id, **kwargs):
72+
@overload # type: ignore
73+
def get_variant(self, feature_flag_id: str, user_id: str, **kwargs: Dict[str, Any]) -> Optional[Variant]:
6974
"""
7075
Determine the variant for the given context.
7176
@@ -75,7 +80,9 @@ def get_variant(self, feature_flag_id, user_id, **kwargs):
7580
:rtype: Variant
7681
"""
7782

78-
def get_variant(self, feature_flag_id, *args, **kwargs):
83+
def get_variant( # type: ignore
84+
self, feature_flag_id: str, *args: Any, **kwargs: Dict[str, Any]
85+
) -> Optional[Variant]:
7986
"""
8087
Determine the variant for the given context.
8188
@@ -87,13 +94,22 @@ def get_variant(self, feature_flag_id, *args, **kwargs):
8794
targeting_context = self._build_targeting_context(args)
8895

8996
result = self._check_feature(feature_flag_id, targeting_context, **kwargs)
90-
if self._on_feature_evaluated and result.feature.telemetry.enabled:
97+
if (
98+
self._on_feature_evaluated
99+
and result.feature
100+
and result.feature.telemetry.enabled
101+
and callable(self._on_feature_evaluated)
102+
):
91103
result.user = targeting_context.user_id
92104
self._on_feature_evaluated(result)
93105
return result.variant
94106

95-
def _check_feature_filters(self, evaluation_event, targeting_context, **kwargs):
107+
def _check_feature_filters(
108+
self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Dict
109+
) -> None:
96110
feature_flag = evaluation_event.feature
111+
if not feature_flag:
112+
return
97113
feature_conditions = feature_flag.conditions
98114
feature_filters = feature_conditions.client_filters
99115

@@ -107,8 +123,8 @@ def _check_feature_filters(self, evaluation_event, targeting_context, **kwargs):
107123

108124
for feature_filter in feature_filters:
109125
filter_name = feature_filter[FEATURE_FILTER_NAME]
110-
kwargs["user"] = targeting_context.user_id
111-
kwargs["groups"] = targeting_context.groups
126+
kwargs["user"] = targeting_context.user_id # type: ignore
127+
kwargs["groups"] = targeting_context.groups # type: ignore
112128
if filter_name not in self._filters:
113129
raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}")
114130
if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL:
@@ -119,7 +135,9 @@ def _check_feature_filters(self, evaluation_event, targeting_context, **kwargs):
119135
evaluation_event.enabled = True
120136
break
121137

122-
def _check_feature(self, feature_flag_id, targeting_context, **kwargs):
138+
def _check_feature(
139+
self, feature_flag_id: str, targeting_context: TargetingContext, **kwargs: Dict[str, Any]
140+
) -> EvaluationEvent:
123141
"""
124142
Determine if the feature flag is enabled for the given context.
125143

0 commit comments

Comments
 (0)