fast-feature-core 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fast_feature_core-0.0.1/PKG-INFO +16 -0
- fast_feature_core-0.0.1/pyproject.toml +28 -0
- fast_feature_core-0.0.1/src/fast_feature/core/__init__.py +27 -0
- fast_feature_core-0.0.1/src/fast_feature/core/errors.py +25 -0
- fast_feature_core-0.0.1/src/fast_feature/core/evaluation.py +66 -0
- fast_feature_core-0.0.1/src/fast_feature/core/flag.py +58 -0
- fast_feature_core-0.0.1/src/fast_feature/core/py.typed +0 -0
- fast_feature_core-0.0.1/src/fast_feature/core/repository.py +49 -0
- fast_feature_core-0.0.1/src/fast_feature/core/types.py +13 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fast-feature-core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Core domain model for fast-feature: flags, evaluation outcomes, and the storage interface.
|
|
5
|
+
Author: byunjuneseok
|
|
6
|
+
Author-email: byunjuneseok <byunjuneseok@gmail.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fast-feature-core"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Core domain model for fast-feature: flags, evaluation outcomes, and the storage interface."
|
|
5
|
+
license = "Apache-2.0"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "byunjuneseok", email = "byunjuneseok@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: Apache Software License",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
# Pure-Python domain model with no runtime dependencies.
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.11.17,<0.12.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
module-name = "fast_feature.core"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
CoreError,
|
|
5
|
+
FlagAlreadyExistsError,
|
|
6
|
+
FlagNotFoundError,
|
|
7
|
+
InvalidFlagError,
|
|
8
|
+
)
|
|
9
|
+
from .evaluation import ErrorCode, EvaluationOutcome, Reason
|
|
10
|
+
from .flag import Flag, FlagState
|
|
11
|
+
from .repository import FlagRepository
|
|
12
|
+
from .types import EvaluationContext, JsonValue
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"EvaluationContext",
|
|
16
|
+
"JsonValue",
|
|
17
|
+
"Flag",
|
|
18
|
+
"FlagState",
|
|
19
|
+
"FlagRepository",
|
|
20
|
+
"EvaluationOutcome",
|
|
21
|
+
"Reason",
|
|
22
|
+
"ErrorCode",
|
|
23
|
+
"CoreError",
|
|
24
|
+
"FlagNotFoundError",
|
|
25
|
+
"FlagAlreadyExistsError",
|
|
26
|
+
"InvalidFlagError",
|
|
27
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CoreError(Exception):
|
|
5
|
+
"""Base class for all fast-feature-core errors.
|
|
6
|
+
|
|
7
|
+
Dependents catch this at their boundary and re-raise as their own base
|
|
8
|
+
error (with chaining) rather than coupling to the concrete subclasses.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FlagNotFoundError(CoreError):
|
|
13
|
+
def __init__(self, key: str) -> None:
|
|
14
|
+
self.key = key
|
|
15
|
+
super().__init__(f"Flag {key!r} was not found")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FlagAlreadyExistsError(CoreError):
|
|
19
|
+
def __init__(self, key: str) -> None:
|
|
20
|
+
self.key = key
|
|
21
|
+
super().__init__(f"Flag {key!r} already exists")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidFlagError(CoreError):
|
|
25
|
+
"""Raised when a flag definition is structurally invalid."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from .types import JsonValue
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Reason(str, Enum):
|
|
10
|
+
"""OpenFeature resolution reasons.
|
|
11
|
+
|
|
12
|
+
OFREP's core enum is a subset of these; the wider set is kept because the
|
|
13
|
+
OpenFeature specification treats ``reason`` as an open string.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
STATIC = "STATIC"
|
|
17
|
+
DEFAULT = "DEFAULT"
|
|
18
|
+
TARGETING_MATCH = "TARGETING_MATCH"
|
|
19
|
+
SPLIT = "SPLIT"
|
|
20
|
+
DISABLED = "DISABLED"
|
|
21
|
+
CACHED = "CACHED"
|
|
22
|
+
UNKNOWN = "UNKNOWN"
|
|
23
|
+
ERROR = "ERROR"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ErrorCode(str, Enum):
|
|
27
|
+
"""OpenFeature error codes used when an evaluation fails."""
|
|
28
|
+
|
|
29
|
+
FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
|
|
30
|
+
PARSE_ERROR = "PARSE_ERROR"
|
|
31
|
+
TYPE_MISMATCH = "TYPE_MISMATCH"
|
|
32
|
+
INVALID_CONTEXT = "INVALID_CONTEXT"
|
|
33
|
+
TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING"
|
|
34
|
+
PROVIDER_NOT_READY = "PROVIDER_NOT_READY"
|
|
35
|
+
GENERAL = "GENERAL"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class EvaluationOutcome:
|
|
40
|
+
"""The result of evaluating a single flag against a context."""
|
|
41
|
+
|
|
42
|
+
key: str
|
|
43
|
+
reason: Reason
|
|
44
|
+
value: JsonValue = None
|
|
45
|
+
variant: str | None = None
|
|
46
|
+
metadata: dict[str, JsonValue] | None = None
|
|
47
|
+
error_code: ErrorCode | None = None
|
|
48
|
+
error_details: str | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_error(self) -> bool:
|
|
52
|
+
return self.error_code is not None
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def error(
|
|
56
|
+
cls,
|
|
57
|
+
key: str,
|
|
58
|
+
error_code: ErrorCode,
|
|
59
|
+
details: str | None = None,
|
|
60
|
+
) -> EvaluationOutcome:
|
|
61
|
+
return cls(
|
|
62
|
+
key=key,
|
|
63
|
+
reason=Reason.ERROR,
|
|
64
|
+
error_code=error_code,
|
|
65
|
+
error_details=details,
|
|
66
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from .errors import InvalidFlagError
|
|
9
|
+
from .types import JsonValue
|
|
10
|
+
|
|
11
|
+
_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FlagState(str, Enum):
|
|
15
|
+
ENABLED = "ENABLED"
|
|
16
|
+
DISABLED = "DISABLED"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Flag:
|
|
21
|
+
"""A feature flag with named variants and an optional targeting rule.
|
|
22
|
+
|
|
23
|
+
A flag holds a set of named ``variants`` and resolves to exactly one of
|
|
24
|
+
them. Resolution is governed by an optional ``targeting`` rule (JsonLogic);
|
|
25
|
+
in its absence the ``default_variant`` is used.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
key: str
|
|
29
|
+
variants: dict[str, JsonValue]
|
|
30
|
+
default_variant: str
|
|
31
|
+
state: FlagState = FlagState.ENABLED
|
|
32
|
+
targeting: dict[str, JsonValue] | None = None
|
|
33
|
+
metadata: dict[str, JsonValue] = field(default_factory=dict)
|
|
34
|
+
created_at: datetime | None = None
|
|
35
|
+
updated_at: datetime | None = None
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not _KEY_PATTERN.match(self.key):
|
|
39
|
+
raise InvalidFlagError(
|
|
40
|
+
f"Invalid flag key {self.key!r}: must match {_KEY_PATTERN.pattern}"
|
|
41
|
+
)
|
|
42
|
+
if not self.variants:
|
|
43
|
+
raise InvalidFlagError(f"Flag {self.key!r} must define at least one variant")
|
|
44
|
+
if self.default_variant not in self.variants:
|
|
45
|
+
raise InvalidFlagError(
|
|
46
|
+
f"Flag {self.key!r} default variant {self.default_variant!r} "
|
|
47
|
+
"is not among its variants"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def default_value(self) -> JsonValue:
|
|
52
|
+
return self.variants[self.default_variant]
|
|
53
|
+
|
|
54
|
+
def value_of(self, variant: str) -> JsonValue:
|
|
55
|
+
return self.variants[variant]
|
|
56
|
+
|
|
57
|
+
def has_variant(self, variant: str) -> bool:
|
|
58
|
+
return variant in self.variants
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from .flag import Flag
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FlagRepository(ABC):
|
|
9
|
+
"""Storage boundary for flags.
|
|
10
|
+
|
|
11
|
+
Implementations live behind optional extras (``inmemory``, ``postgresql``,
|
|
12
|
+
``mysql``). The domain and the evaluation engine depend only on this
|
|
13
|
+
interface, never on a concrete storage technology.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def get(self, key: str) -> Flag | None:
|
|
18
|
+
"""Return the flag with ``key``, or ``None`` if it does not exist."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def list_all(self) -> list[Flag]:
|
|
22
|
+
"""Return every flag, ordered by key."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def create(self, flag: Flag) -> Flag:
|
|
26
|
+
"""Persist a new flag.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
FlagAlreadyExistsError: if a flag with the same key exists.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def update(self, flag: Flag) -> Flag:
|
|
34
|
+
"""Replace an existing flag.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
FlagNotFoundError: if the flag does not exist.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def delete(self, key: str) -> None:
|
|
42
|
+
"""Remove a flag.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
FlagNotFoundError: if the flag does not exist.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
async def exists(self, key: str) -> bool:
|
|
49
|
+
return await self.get(key) is not None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TypeAlias
|
|
4
|
+
|
|
5
|
+
JsonValue: TypeAlias = "bool | int | float | str | None | list[JsonValue] | dict[str, JsonValue]"
|
|
6
|
+
"""Any value expressible in JSON. Flag variants resolve to one of these."""
|
|
7
|
+
|
|
8
|
+
EvaluationContext: TypeAlias = "dict[str, JsonValue]"
|
|
9
|
+
"""Targeting context supplied by a client at evaluation time.
|
|
10
|
+
|
|
11
|
+
By OpenFeature convention the optional ``targetingKey`` entry identifies the
|
|
12
|
+
subject of the evaluation (a user, account, session, ...).
|
|
13
|
+
"""
|