Skip to content

Commit 7262b48

Browse files
authored
feat: strict typing (#168)
- add strict mode mypy, fix typing errors - add absolufy-imports, ditch relative imports - add `type: ignore` comment for decorator usage on a property - add `type: ignore` comments for untyped dependencies
1 parent 57ed0af commit 7262b48

36 files changed

+390
-216
lines changed

.github/workflows/pull-request.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,22 @@ jobs:
4040
- name: Check Formatting
4141
run: black --check .
4242

43+
- name: Check Imports
44+
run: |
45+
git ls-files | grep '\.py$' | xargs absolufy-imports
46+
isort . --check
47+
4348
- name: Check flake8 linting
4449
run: flake8 .
4550

51+
- name: Check Typing
52+
run: mypy --strict .
53+
4654
- name: Run Tests
4755
run: pytest -p no:warnings
4856

4957
- name: Check Coverage
5058
uses: 5monkeys/cobertura-action@v13
5159
with:
52-
minimum_coverage: 100
53-
fail_below_threshold: true
60+
minimum_coverage: 100
61+
fail_below_threshold: true

.pre-commit-config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
repos:
2+
- repo: https://github.com/pre-commit/mirrors-mypy
3+
rev: v1.5.1
4+
hooks:
5+
- id: mypy
6+
args: [--strict]
7+
additional_dependencies:
8+
[pydantic, pytest, pytest_mock, types-pytest-lazy-fixture, types-setuptools, semver]
9+
- repo: https://github.com/MarcoGorelli/absolufy-imports
10+
rev: v0.3.1
11+
hooks:
12+
- id: absolufy-imports
213
- repo: https://github.com/PyCQA/isort
314
rev: 5.12.0
415
hooks:

flag_engine/engine.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from flag_engine.utils.exceptions import FeatureStateNotFound
99

1010

11-
def get_environment_feature_states(environment: EnvironmentModel):
11+
def get_environment_feature_states(
12+
environment: EnvironmentModel,
13+
) -> typing.List[FeatureStateModel]:
1214
"""
1315
Get a list of feature states for a given environment
1416
@@ -19,7 +21,9 @@ def get_environment_feature_states(environment: EnvironmentModel):
1921
return environment.feature_states
2022

2123

22-
def get_environment_feature_state(environment: EnvironmentModel, feature_name: str):
24+
def get_environment_feature_state(
25+
environment: EnvironmentModel, feature_name: str
26+
) -> FeatureStateModel:
2327
"""
2428
Get a specific feature state for a given feature_name in a given environment
2529
@@ -38,7 +42,7 @@ def get_environment_feature_state(environment: EnvironmentModel, feature_name: s
3842
def get_identity_feature_states(
3943
environment: EnvironmentModel,
4044
identity: IdentityModel,
41-
override_traits: typing.List[TraitModel] = None,
45+
override_traits: typing.Optional[typing.List[TraitModel]] = None,
4246
) -> typing.List[FeatureStateModel]:
4347
"""
4448
Get a list of feature states for a given identity in a given environment.
@@ -63,8 +67,8 @@ def get_identity_feature_state(
6367
environment: EnvironmentModel,
6468
identity: IdentityModel,
6569
feature_name: str,
66-
override_traits: typing.List[TraitModel] = None,
67-
):
70+
override_traits: typing.Optional[typing.List[TraitModel]] = None,
71+
) -> FeatureStateModel:
6872
"""
6973
Get a specific feature state for a given identity in a given environment.
7074

flag_engine/environments/models.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class EnvironmentAPIKeyModel(BaseModel):
1919
active: bool = True
2020

2121
@property
22-
def is_valid(self):
22+
def is_valid(self) -> bool:
2323
return self.active and (
2424
not self.expires_at or self.expires_at > utcnow_with_tz()
2525
)
@@ -52,7 +52,7 @@ class EnvironmentModel(BaseModel):
5252

5353
webhook_config: typing.Optional[WebhookModel] = None
5454

55-
_INTEGRATION_ATTS = [
55+
_INTEGRATION_ATTRS = [
5656
"amplitude_config",
5757
"heap_config",
5858
"mixpanel_config",
@@ -76,9 +76,9 @@ def integrations_data(self) -> typing.Dict[str, typing.Dict[str, str]]:
7676
"""
7777

7878
integrations_data = {}
79-
for integration_attr in self._INTEGRATION_ATTS:
80-
integration_config: IntegrationModel = getattr(self, integration_attr, None)
81-
if integration_config:
79+
for integration_attr in self._INTEGRATION_ATTRS:
80+
integration_config: typing.Optional[IntegrationModel]
81+
if integration_config := getattr(self, integration_attr, None):
8282
integrations_data[integration_attr] = {
8383
"base_url": integration_config.base_url,
8484
"api_key": integration_config.api_key,

flag_engine/features/models.py

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import typing
33
import uuid
44

5-
from annotated_types import Ge, Le
5+
from annotated_types import Ge, Le, SupportsLt
66
from pydantic import UUID4, BaseModel, Field, model_validator
77
from pydantic_collections import BaseCollectionModel
88
from typing_extensions import Annotated
@@ -16,16 +16,16 @@ class FeatureModel(BaseModel):
1616
name: str
1717
type: str
1818

19-
def __eq__(self, other):
20-
return self.id == other.id
19+
def __eq__(self, other: object) -> bool:
20+
return isinstance(other, FeatureModel) and self.id == other.id
2121

22-
def __hash__(self):
22+
def __hash__(self) -> int:
2323
return hash(self.id)
2424

2525

2626
class MultivariateFeatureOptionModel(BaseModel):
2727
value: typing.Any
28-
id: int = None
28+
id: typing.Optional[int] = None
2929

3030

3131
class MultivariateFeatureStateValueModel(BaseModel):
@@ -40,7 +40,7 @@ class FeatureSegmentModel(BaseModel):
4040

4141

4242
class MultivariateFeatureStateValueList(
43-
BaseCollectionModel[MultivariateFeatureStateValueModel]
43+
BaseCollectionModel[MultivariateFeatureStateValueModel] # type: ignore[misc,no-any-unimported]
4444
):
4545
@staticmethod
4646
def _ensure_correct_percentage_allocations(
@@ -75,15 +75,15 @@ def append(
7575
class FeatureStateModel(BaseModel, validate_assignment=True):
7676
feature: FeatureModel
7777
enabled: bool
78-
django_id: int = None
79-
feature_segment: FeatureSegmentModel = None
78+
django_id: typing.Optional[int] = None
79+
feature_segment: typing.Optional[FeatureSegmentModel] = None
8080
featurestate_uuid: UUID4 = Field(default_factory=uuid.uuid4)
8181
feature_state_value: typing.Any = None
8282
multivariate_feature_state_values: MultivariateFeatureStateValueList = Field(
8383
default_factory=MultivariateFeatureStateValueList
8484
)
8585

86-
def set_value(self, value: typing.Any):
86+
def set_value(self, value: typing.Any) -> None:
8787
self.feature_state_value = value
8888

8989
def get_value(self, identity_id: typing.Union[None, int, str] = None) -> typing.Any:
@@ -113,18 +113,19 @@ def is_higher_segment_priority(self, other: "FeatureStateModel") -> bool:
113113
114114
"""
115115

116-
try:
117-
return (
118-
getattr(
119-
self.feature_segment,
120-
"priority",
121-
math.inf,
116+
if other_feature_segment := other.feature_segment:
117+
if (
118+
other_feature_segment_priority := other_feature_segment.priority
119+
) is not None:
120+
return (
121+
getattr(
122+
self.feature_segment,
123+
"priority",
124+
math.inf,
125+
)
126+
< other_feature_segment_priority
122127
)
123-
< other.feature_segment.priority
124-
)
125-
126-
except (TypeError, AttributeError):
127-
return False
128+
return False
128129

129130
def _get_multivariate_value(
130131
self, identity_id: typing.Union[int, str]
@@ -138,10 +139,14 @@ def _get_multivariate_value(
138139
# the percentage allocations of the multivariate options. This gives us a
139140
# way to ensure that the same value is returned every time we use the same
140141
# percentage value.
141-
start_percentage = 0
142+
start_percentage = 0.0
143+
144+
def _mv_fs_sort_key(mv_value: MultivariateFeatureStateValueModel) -> SupportsLt:
145+
return mv_value.id or mv_value.mv_fs_value_uuid
146+
142147
for mv_value in sorted(
143148
self.multivariate_feature_state_values,
144-
key=lambda v: v.id or v.mv_fs_value_uuid,
149+
key=_mv_fs_sort_key,
145150
):
146151
limit = mv_value.percentage_allocation + start_percentage
147152
if start_percentage <= percentage_value < limit:

flag_engine/identities/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from flag_engine.utils.exceptions import DuplicateFeatureState
1212

1313

14-
class IdentityFeaturesList(BaseCollectionModel[FeatureStateModel]):
14+
class IdentityFeaturesList(BaseCollectionModel[FeatureStateModel]): # type: ignore[misc,no-any-unimported]
1515
@staticmethod
1616
def _ensure_unique_feature_ids(
1717
value: typing.MutableSequence[FeatureStateModel],
@@ -45,7 +45,7 @@ class IdentityModel(BaseModel):
4545
identity_uuid: UUID4 = Field(default_factory=uuid.uuid4)
4646
django_id: typing.Optional[int] = None
4747

48-
@computed_field
48+
@computed_field # type: ignore[misc]
4949
@property
5050
def composite_key(self) -> str:
5151
return self.generate_composite_key(self.environment_api_key, self.identifier)

flag_engine/identities/traits/types.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
import re
22
from decimal import Decimal
3+
from typing import Any, Union, get_args
34

4-
from typing import Union, Any, get_args
5-
from typing_extensions import TypeGuard
6-
7-
from pydantic.types import (
8-
AllowInfNan,
9-
StringConstraints,
10-
StrictBool,
11-
)
125
from pydantic import BeforeValidator
13-
from typing_extensions import Annotated
6+
from pydantic.types import AllowInfNan, StrictBool, StringConstraints
7+
from typing_extensions import Annotated, TypeGuard
148

159
from flag_engine.identities.traits.constants import TRAIT_STRING_VALUE_MAX_LENGTH
1610

flag_engine/organisations/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ class OrganisationModel(BaseModel):
99
persist_trait_data: bool
1010

1111
@property
12-
def unique_slug(self):
12+
def unique_slug(self) -> str:
1313
return str(self.id) + "-" + self.name

flag_engine/py.typed

Whitespace-only changes.

flag_engine/segments/constants.py

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,22 @@
1-
# Segment Rules
2-
ALL_RULE = "ALL"
3-
ANY_RULE = "ANY"
4-
NONE_RULE = "NONE"
1+
from flag_engine.segments.types import ConditionOperator, RuleType
52

6-
RULE_TYPES = [ALL_RULE, ANY_RULE, NONE_RULE]
3+
# Segment Rules
4+
ALL_RULE: RuleType = "ALL"
5+
ANY_RULE: RuleType = "ANY"
6+
NONE_RULE: RuleType = "NONE"
77

88
# Segment Condition Operators
9-
EQUAL = "EQUAL"
10-
GREATER_THAN = "GREATER_THAN"
11-
LESS_THAN = "LESS_THAN"
12-
LESS_THAN_INCLUSIVE = "LESS_THAN_INCLUSIVE"
13-
CONTAINS = "CONTAINS"
14-
GREATER_THAN_INCLUSIVE = "GREATER_THAN_INCLUSIVE"
15-
NOT_CONTAINS = "NOT_CONTAINS"
16-
NOT_EQUAL = "NOT_EQUAL"
17-
REGEX = "REGEX"
18-
PERCENTAGE_SPLIT = "PERCENTAGE_SPLIT"
19-
MODULO = "MODULO"
20-
IS_SET = "IS_SET"
21-
IS_NOT_SET = "IS_NOT_SET"
22-
IN = "IN"
23-
24-
CONDITION_OPERATORS = [
25-
EQUAL,
26-
GREATER_THAN,
27-
LESS_THAN,
28-
LESS_THAN_INCLUSIVE,
29-
CONTAINS,
30-
GREATER_THAN_INCLUSIVE,
31-
NOT_CONTAINS,
32-
NOT_EQUAL,
33-
REGEX,
34-
PERCENTAGE_SPLIT,
35-
MODULO,
36-
IS_SET,
37-
IS_NOT_SET,
38-
IN,
39-
]
9+
EQUAL: ConditionOperator = "EQUAL"
10+
GREATER_THAN: ConditionOperator = "GREATER_THAN"
11+
LESS_THAN: ConditionOperator = "LESS_THAN"
12+
LESS_THAN_INCLUSIVE: ConditionOperator = "LESS_THAN_INCLUSIVE"
13+
CONTAINS: ConditionOperator = "CONTAINS"
14+
GREATER_THAN_INCLUSIVE: ConditionOperator = "GREATER_THAN_INCLUSIVE"
15+
NOT_CONTAINS: ConditionOperator = "NOT_CONTAINS"
16+
NOT_EQUAL: ConditionOperator = "NOT_EQUAL"
17+
REGEX: ConditionOperator = "REGEX"
18+
PERCENTAGE_SPLIT: ConditionOperator = "PERCENTAGE_SPLIT"
19+
MODULO: ConditionOperator = "MODULO"
20+
IS_SET: ConditionOperator = "IS_SET"
21+
IS_NOT_SET: ConditionOperator = "IS_NOT_SET"
22+
IN: ConditionOperator = "IN"

0 commit comments

Comments
 (0)