From 48a461d88feead085012e97af38152a98ce2bf21 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 20:54:58 +0200 Subject: [PATCH 01/14] v3 --- Makefile | 3 +- README.md | 71 +++++++++++-- src/featurevisor/__init__.py | 9 +- src/featurevisor/cli.py | 10 +- src/featurevisor/datafile_reader.py | 13 ++- src/featurevisor/evaluate.py | 29 ++---- src/featurevisor/events.py | 4 +- src/featurevisor/hooks.py | 31 ------ src/featurevisor/instance.py | 142 ++++++++++++++++++++++---- src/featurevisor/modules.py | 153 ++++++++++++++++++++++++++++ src/featurevisor/project.py | 37 ++++--- src/featurevisor/tester.py | 51 ++++------ src/featurevisor/types.py | 24 +++-- tests/test_cli.py | 21 +++- tests/test_instance_parity.py | 8 +- tests/test_sdk.py | 115 ++++++++++++++++++++- 16 files changed, 566 insertions(+), 155 deletions(-) delete mode 100644 src/featurevisor/hooks.py create mode 100644 src/featurevisor/modules.py diff --git a/Makefile b/Makefile index 611558f..8b4ec9d 100644 --- a/Makefile +++ b/Makefile @@ -31,4 +31,5 @@ test: .PHONY: test-example-1 test-example-1: - PYTHONPATH=src python3 -m featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 + PYTHONPATH=src python3 -m unittest discover -s tests -v + PYTHONPATH=src python3 -m featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures diff --git a/README.md b/README.md index 235df47..d3d5182 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This repository ports the latest Featurevisor [JavaScript SDK](https://featurevi The package name is `featurevisor`, and it targets Python 3.10+. -This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 projects and above. +This SDK is compatible with Featurevisor v3 projects and v2 datafiles. @@ -171,6 +171,18 @@ f.set_datafile(datafile_content) f.set_datafile(json.dumps(datafile_content)) ``` +By default, `set_datafile(datafile)` merges incoming content into the SDK's current datafile: + +- top-level metadata such as `schemaVersion`, `revision`, and `featurevisorVersion` comes from the incoming datafile +- `segments` are merged, with incoming entries overriding existing ones +- `features` are merged, with incoming entries overriding existing ones + +To fully replace the stored datafile, pass `True` as the second argument: + +```python +f.set_datafile(datafile_content, True) +``` + ## Logging Supported log levels: @@ -199,22 +211,60 @@ The SDK emits: - `datafile_set` - `context_set` - `sticky_set` +- `error` ```python unsubscribe = f.on("datafile_set", lambda details: print(details)) unsubscribe() ``` -## Hooks +`datafile_set` includes a `replaced` flag. The `error` event is emitted for diagnostics reported with `level` set to `error` or `fatal`. + +## Diagnostics + +Diagnostics provide structured SDK and module events for observability: + +```python +f = create_instance({ + "onDiagnostic": lambda diagnostic: print(diagnostic), +}) +``` + +If `onDiagnostic` is not provided, diagnostics are sent to the SDK logger. + +## Modules -Hooks support: +Modules can intercept evaluation and participate in SDK lifecycle: +- `setup` - `before` - `bucketKey` - `bucketValue` - `after` +- `close` -They can be passed during initialization or added later with `add_hook`. +```python +my_module = { + "name": "my-module", + "setup": lambda api: api["onDiagnostic"](lambda diagnostic: print(diagnostic)), + "before": lambda options: {**options, "context": {**options["context"], "country": "nl"}}, + "bucketKey": lambda options: options["bucketKey"], + "bucketValue": lambda options: options["bucketValue"], + "after": lambda evaluation, options: evaluation, + "close": lambda: None, +} + +f = create_instance({ + "modules": [my_module], +}) + +remove_module = f.add_module(my_module) +remove_module() + +f.remove_module("my-module") +``` + +The module API passed to `setup` exposes `getRevision`, `onDiagnostic`, and `reportDiagnostic`. ## Child Instance @@ -258,10 +308,10 @@ python -m featurevisor test --assertionPattern=variation python -m featurevisor test --onlyFailures python -m featurevisor test --showDatafile python -m featurevisor test --verbose -python -m featurevisor test --with-tags -python -m featurevisor test --with-scopes ``` +The Python test runner builds base datafiles and Target datafiles with `npx featurevisor build --json`. Assertions containing `target` are evaluated against the matching Target datafile. + ### Benchmark Benchmark repeated Python SDK evaluations against a built datafile: @@ -341,7 +391,14 @@ Run the example project integration directly: ```bash PYTHONPATH=src python3 -m featurevisor test \ - --projectDirectoryPath=/path/to/featurevisor-project + --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 \ + --onlyFailures +``` + +Or use: + +```bash +make test-example-1 ``` ## Releasing diff --git a/src/featurevisor/__init__.py b/src/featurevisor/__init__.py index fc2a7b3..e5b6415 100644 --- a/src/featurevisor/__init__.py +++ b/src/featurevisor/__init__.py @@ -3,10 +3,10 @@ from .conditions import condition_is_matched, get_value_from_context from .datafile_reader import DatafileReader from .emitter import Emitter -from .evaluate import EvaluationReason, evaluate, evaluate_with_hooks +from .evaluate import EvaluationReason, evaluate, evaluate_with_modules from .events import get_params_for_datafile_set_event, get_params_for_sticky_set_event from .helpers import get_value_by_type -from .hooks import HooksManager +from .modules import FeaturevisorModule, ModulesManager from .instance import FeaturevisorInstance, create_instance from .logger import Logger, create_logger, default_log_handler, loggerPrefix from .murmurhash import murmurhash_v3 @@ -26,14 +26,15 @@ "EvaluationReason", "FeaturevisorChildInstance", "FeaturevisorInstance", - "HooksManager", + "FeaturevisorModule", + "ModulesManager", "Logger", "condition_is_matched", "create_instance", "create_logger", "default_log_handler", "evaluate", - "evaluate_with_hooks", + "evaluate_with_modules", "get_bucket_key", "get_bucketed_number", "get_params_for_datafile_set_event", diff --git a/src/featurevisor/cli.py b/src/featurevisor/cli.py index 39812b0..c2ddf77 100644 --- a/src/featurevisor/cli.py +++ b/src/featurevisor/cli.py @@ -26,9 +26,9 @@ def build_parser() -> argparse.ArgumentParser: common.add_argument("--variable") common.add_argument("--n", type=int, default=1000) common.add_argument("--inflate", type=int, default=0) - common.add_argument("--with-scopes", dest="with_scopes", action="store_true") - common.add_argument("--with-tags", dest="with_tags", action="store_true") - common.add_argument("--schemaVersion") + common.add_argument("--with-scopes", "--withScopes", dest="with_scopes", action="store_true") + common.add_argument("--with-tags", "--withTags", dest="with_tags", action="store_true") + common.add_argument("--schemaVersion", "--schema-version", dest="schema_version") common.add_argument("--populateUuid", action="append", default=[]) subparsers.add_parser("test", parents=[common]) subparsers.add_parser("benchmark", parents=[common]) @@ -49,7 +49,7 @@ def main(argv: list[str] | None = None) -> int: quiet=args.quiet, show_datafile=args.showDatafile, only_failures=args.onlyFailures, - schema_version=args.schemaVersion, + schema_version=args.schema_version, inflate=args.inflate, with_scopes=args.with_scopes, with_tags=args.with_tags, @@ -66,7 +66,6 @@ def main(argv: list[str] | None = None) -> int: n=args.n, variation=args.variation, variable=args.variable, - schema_version=args.schemaVersion, inflate=args.inflate, verbose=args.verbose, quiet=args.quiet, @@ -81,7 +80,6 @@ def main(argv: list[str] | None = None) -> int: context=context, n=args.n, populate_uuid=args.populateUuid, - schema_version=args.schemaVersion, inflate=args.inflate, verbose=args.verbose, quiet=args.quiet, diff --git a/src/featurevisor/datafile_reader.py b/src/featurevisor/datafile_reader.py index c7fea7b..61b74b8 100644 --- a/src/featurevisor/datafile_reader.py +++ b/src/featurevisor/datafile_reader.py @@ -14,6 +14,7 @@ def __init__(self, *, datafile: DatafileContent, logger: Logger) -> None: self.logger = logger self.schema_version = datafile["schemaVersion"] self.revision = datafile["revision"] + self.featurevisor_version = datafile.get("featurevisorVersion") self.segments = datafile.get("segments", {}) self.features = datafile.get("features", {}) self.regex_cache: dict[str, re.Pattern[str]] = {} @@ -24,6 +25,16 @@ def get_revision(self) -> str: def get_schema_version(self) -> str: return self.schema_version + def get_datafile(self) -> dict[str, Any]: + datafile = { + "schemaVersion": self.schema_version, + "revision": self.revision, + "featurevisorVersion": self.featurevisor_version, + "segments": self.segments, + "features": self.features, + } + return {key: value for key, value in datafile.items() if value is not None} + def get_segment(self, segment_key: str) -> Segment | None: segment = self.segments.get(segment_key) if not segment: @@ -137,6 +148,7 @@ def parse_segments_if_stringified(self, segments: Any) -> Any: getRevision = get_revision getSchemaVersion = get_schema_version + getDatafile = get_datafile getSegment = get_segment getFeatureKeys = get_feature_keys getFeature = get_feature @@ -151,4 +163,3 @@ def parse_segments_if_stringified(self, segments: Any) -> Any: getMatchedForce = get_matched_force parseConditionsIfStringified = parse_conditions_if_stringified parseSegmentsIfStringified = parse_segments_if_stringified - diff --git a/src/featurevisor/evaluate.py b/src/featurevisor/evaluate.py index c1ab493..6f9dd2c 100644 --- a/src/featurevisor/evaluate.py +++ b/src/featurevisor/evaluate.py @@ -27,14 +27,10 @@ class EvaluationReason(StrEnum): ERROR = "error" -def evaluate_with_hooks(options: dict[str, Any]) -> dict[str, Any]: +def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: try: - hooks_manager = options["hooksManager"] - hooks = hooks_manager.get_all() - current_options = options - for hook in hooks: - if hook.get("before"): - current_options = hook["before"](current_options) + modules_manager = options["modulesManager"] + current_options = modules_manager.run_before_modules(options) evaluation = evaluate(current_options) if ( current_options.get("defaultVariationValue") is not None @@ -49,9 +45,7 @@ def evaluate_with_hooks(options: dict[str, Any]) -> dict[str, Any]: and evaluation.get("variableValue") is None ): evaluation["variableValue"] = current_options["defaultVariableValue"] - for hook in hooks: - if hook.get("after"): - evaluation = hook["after"](evaluation, current_options) + evaluation = modules_manager.run_after_modules(evaluation, current_options) return evaluation except Exception as exc: evaluation = { @@ -89,7 +83,7 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: logger = options["logger"] datafile_reader = options["datafileReader"] sticky = options.get("sticky") - hooks = options["hooksManager"].get_all() + modules_manager = options["modulesManager"] try: if type_ != "flag": @@ -231,13 +225,13 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: return evaluation bucket_key = get_bucket_key(featureKey=feature_key, bucketBy=feature["bucketBy"], context=context, logger=logger) - for hook in hooks: - if hook.get("bucketKey"): - bucket_key = hook["bucketKey"]({"featureKey": feature_key, "context": context, "bucketBy": feature["bucketBy"], "bucketKey": bucket_key}) + bucket_key = modules_manager.run_bucket_key_modules( + {"featureKey": feature_key, "context": context, "bucketBy": feature["bucketBy"], "bucketKey": bucket_key} + ) bucket_value = get_bucketed_number(bucket_key) - for hook in hooks: - if hook.get("bucketValue"): - bucket_value = hook["bucketValue"]({"featureKey": feature_key, "bucketKey": bucket_key, "context": context, "bucketValue": bucket_value}) + bucket_value = modules_manager.run_bucket_value_modules( + {"featureKey": feature_key, "bucketKey": bucket_key, "context": context, "bucketValue": bucket_value} + ) matched_traffic = datafile_reader.get_matched_traffic(feature["traffic"], context) matched_allocation = datafile_reader.get_matched_allocation(matched_traffic, bucket_value) if type_ != "flag" and matched_traffic else None @@ -451,4 +445,3 @@ def evaluate(options: dict[str, Any]) -> dict[str, Any]: evaluation = {"type": type_, "featureKey": feature_key, "variableKey": variable_key, "reason": EvaluationReason.ERROR, "error": exc} logger.error("error", evaluation) return evaluation - diff --git a/src/featurevisor/events.py b/src/featurevisor/events.py index c756ee0..28743cb 100644 --- a/src/featurevisor/events.py +++ b/src/featurevisor/events.py @@ -14,7 +14,7 @@ def get_params_for_sticky_set_event(previous_sticky_features: dict | None = None return {"features": features, "replaced": replace} -def get_params_for_datafile_set_event(previous_datafile_reader: DatafileReader, new_datafile_reader: DatafileReader) -> dict: +def get_params_for_datafile_set_event(previous_datafile_reader: DatafileReader, new_datafile_reader: DatafileReader, replace: bool = False) -> dict: previous_revision = previous_datafile_reader.get_revision() previous_feature_keys = previous_datafile_reader.get_feature_keys() new_revision = new_datafile_reader.get_revision() @@ -36,5 +36,5 @@ def get_params_for_datafile_set_event(previous_datafile_reader: DatafileReader, "previousRevision": previous_revision, "revisionChanged": previous_revision != new_revision, "features": features, + "replaced": replace, } - diff --git a/src/featurevisor/hooks.py b/src/featurevisor/hooks.py deleted file mode 100644 index 3f5d141..0000000 --- a/src/featurevisor/hooks.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from .logger import Logger - - -class HooksManager: - def __init__(self, *, hooks: list[dict[str, Any]] | None = None, logger: Logger) -> None: - self.logger = logger - self.hooks: list[dict[str, Any]] = [] - for hook in hooks or []: - self.add(hook) - - def add(self, hook: dict[str, Any]): - if any(existing_hook["name"] == hook["name"] for existing_hook in self.hooks): - self.logger.error(f'Hook with name "{hook["name"]}" already exists.', {"name": hook["name"], "hook": hook}) - return None - self.hooks.append(hook) - - def unsubscribe() -> None: - self.remove(hook["name"]) - - return unsubscribe - - def remove(self, name: str) -> None: - self.hooks = [hook for hook in self.hooks if hook["name"] != name] - - def get_all(self) -> list[dict[str, Any]]: - return list(self.hooks) - diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index f2b6596..9483907 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -1,16 +1,17 @@ from __future__ import annotations import json +import uuid from typing import Any from .child import FeaturevisorChildInstance from .datafile_reader import DatafileReader from .emitter import Emitter -from .evaluate import evaluate_with_hooks +from .evaluate import evaluate_with_modules from .events import get_params_for_datafile_set_event, get_params_for_sticky_set_event from .helpers import get_value_by_type -from .hooks import HooksManager from .logger import Logger, create_logger +from .modules import FeaturevisorModule, ModulesManager empty_datafile = {"schemaVersion": "2", "revision": "unknown", "segments": {}, "features": {}} @@ -20,32 +21,52 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: options = options or {} self.context = options.get("context") or {} self.logger: Logger = options.get("logger") or create_logger({"level": options.get("logLevel") or Logger.default_level}) - self.hooks_manager = HooksManager(hooks=options.get("hooks") or [], logger=self.logger) + self.on_diagnostic = options.get("onDiagnostic") or options.get("on_diagnostic") self.emitter = Emitter() self.sticky = options.get("sticky") + self.closed = False + self.module_diagnostic_subscriptions: list[dict[str, Any]] = [] self.datafile_reader = DatafileReader(datafile=empty_datafile, logger=self.logger) + self.modules_manager = ModulesManager( + modules=options.get("modules") or [], + report_diagnostic=self.report_diagnostic, + module_api_factory=self.create_module_api, + clear_module_diagnostic_subscriptions=self.clear_module_diagnostic_subscriptions, + ) + if options.get("datafile") is not None: - datafile = options["datafile"] - if isinstance(datafile, str): - datafile = json.loads(datafile) - self.datafile_reader = DatafileReader(datafile=datafile, logger=self.logger) - self.logger.info("Featurevisor SDK initialized") + self.set_datafile(options["datafile"], True) + + self.report_diagnostic( + { + "level": "info", + "code": "sdk_initialized", + "message": "Featurevisor SDK initialized", + } + ) def set_log_level(self, level: str) -> None: self.logger.set_level(level) - def set_datafile(self, datafile) -> None: + def set_datafile(self, datafile, replace: bool = False) -> None: + if self.closed: + return + try: parsed = json.loads(datafile) if isinstance(datafile, str) else datafile - new_reader = DatafileReader(datafile=parsed, logger=self.logger) - details = get_params_for_datafile_set_event(self.datafile_reader, new_reader) + next_datafile = parsed if replace else self._merge_datafiles(self.datafile_reader.get_datafile(), parsed) + new_reader = DatafileReader(datafile=next_datafile, logger=self.logger) + details = get_params_for_datafile_set_event(self.datafile_reader, new_reader, replace) self.datafile_reader = new_reader self.logger.info("datafile set", details) self.emitter.trigger("datafile_set", details) + self.report_diagnostic({"level": "info", "code": "datafile_set", "message": "datafile set", "details": details}) except Exception as exc: - self.logger.error("could not parse datafile", {"error": exc}) + self.report_diagnostic({"level": "error", "code": "invalid_datafile", "message": "could not parse datafile", "originalError": exc}) def set_sticky(self, sticky: dict[str, Any], replace: bool = False) -> None: + if self.closed: + return previous = self.sticky or {} self.sticky = dict(sticky) if replace else {**(self.sticky or {}), **sticky} params = get_params_for_sticky_set_event(previous, self.sticky, replace) @@ -58,16 +79,28 @@ def get_revision(self) -> str: def get_feature(self, feature_key: str): return self.datafile_reader.get_feature(feature_key) - def add_hook(self, hook: dict[str, Any]): - return self.hooks_manager.add(hook) + def add_module(self, module: dict[str, Any] | FeaturevisorModule): + if self.closed: + return None + return self.modules_manager.add(module) + + def remove_module(self, name_or_module: str | FeaturevisorModule) -> None: + if self.closed: + return + self.modules_manager.remove(name_or_module) def on(self, event_name, callback): return self.emitter.on(event_name, callback) def close(self) -> None: + self.closed = True + self.modules_manager.close_all() + self.module_diagnostic_subscriptions = [] self.emitter.clear_all() def set_context(self, context: dict[str, Any], replace: bool = False) -> None: + if self.closed: + return self.context = dict(context) if replace else {**self.context, **context} self.emitter.trigger("context_set", {"context": self.context, "replaced": replace}) self.logger.debug("context replaced" if replace else "context updated", {"context": self.context, "replaced": replace}) @@ -84,7 +117,7 @@ def _get_evaluation_dependencies(self, context: dict[str, Any], options: dict[st return { "context": self.get_context(context), "logger": self.logger, - "hooksManager": self.hooks_manager, + "modulesManager": self.modules_manager, "datafileReader": self.datafile_reader, "sticky": {**(self.sticky or {}), **options.get("sticky", {})} if options.get("sticky") else self.sticky, "defaultVariationValue": options.get("defaultVariationValue"), @@ -92,7 +125,7 @@ def _get_evaluation_dependencies(self, context: dict[str, Any], options: dict[st } def evaluate_flag(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return evaluate_with_hooks({**self._get_evaluation_dependencies(context or {}, options), "type": "flag", "featureKey": feature_key}) + return evaluate_with_modules({**self._get_evaluation_dependencies(context or {}, options), "type": "flag", "featureKey": feature_key}) def is_enabled(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None) -> bool: try: @@ -102,7 +135,7 @@ def is_enabled(self, feature_key: str, context: dict[str, Any] | None = None, op return False def evaluate_variation(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return evaluate_with_hooks({**self._get_evaluation_dependencies(context or {}, options), "type": "variation", "featureKey": feature_key}) + return evaluate_with_modules({**self._get_evaluation_dependencies(context or {}, options), "type": "variation", "featureKey": feature_key}) def get_variation(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): try: @@ -117,7 +150,7 @@ def get_variation(self, feature_key: str, context: dict[str, Any] | None = None, return None def evaluate_variable(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): - return evaluate_with_hooks({**self._get_evaluation_dependencies(context or {}, options), "type": "variable", "featureKey": feature_key, "variableKey": variable_key}) + return evaluate_with_modules({**self._get_evaluation_dependencies(context or {}, options), "type": "variable", "featureKey": feature_key, "variableKey": variable_key}) def get_variable(self, feature_key: str, variable_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None): try: @@ -171,12 +204,82 @@ def get_all_evaluations(self, context: dict[str, Any] | None = None, feature_key result[feature_key] = evaluated return result + def create_module_api(self, module: FeaturevisorModule) -> dict[str, Any]: + def on_diagnostic(handler, options: dict[str, Any] | None = None): + options = options or {} + subscription = { + "id": str(uuid.uuid4()), + "moduleId": module.id, + "handler": handler, + "level": options.get("level") or options.get("logLevel") or "info", + } + self.module_diagnostic_subscriptions.append(subscription) + + def unsubscribe() -> None: + self.module_diagnostic_subscriptions = [ + item for item in self.module_diagnostic_subscriptions if item["id"] != subscription["id"] + ] + + return unsubscribe + + return { + "getRevision": self.get_revision, + "get_revision": self.get_revision, + "onDiagnostic": on_diagnostic, + "on_diagnostic": on_diagnostic, + "reportDiagnostic": lambda diagnostic: self.report_diagnostic(diagnostic, module), + "report_diagnostic": lambda diagnostic: self.report_diagnostic(diagnostic, module), + } + + def clear_module_diagnostic_subscriptions(self, module: FeaturevisorModule) -> None: + self.module_diagnostic_subscriptions = [ + item for item in self.module_diagnostic_subscriptions if item["moduleId"] != module.id + ] + + def report_diagnostic(self, diagnostic: dict[str, Any], source_module: FeaturevisorModule | None = None) -> None: + diagnostic = dict(diagnostic or {}) + diagnostic["level"] = diagnostic.get("level") or "info" + if source_module and source_module.name and not diagnostic.get("module"): + diagnostic["module"] = source_module.name + + for subscription in list(self.module_diagnostic_subscriptions): + if source_module and subscription["moduleId"] == source_module.id: + continue + if self._should_report_diagnostic(diagnostic["level"], subscription["level"]): + subscription["handler"](diagnostic) + + if self.on_diagnostic: + if self._should_report_diagnostic(diagnostic["level"], self.logger.level): + self.on_diagnostic(diagnostic) + else: + details = {key: value for key, value in diagnostic.items() if key not in {"level", "message"}} + self.logger.log(diagnostic["level"], diagnostic.get("message", ""), details) + + if diagnostic["level"] in {"error", "fatal"}: + self.emitter.trigger("error", diagnostic) + + def _should_report_diagnostic(self, diagnostic_level: str, subscriber_level: str) -> bool: + try: + return Logger.all_levels.index(subscriber_level) >= Logger.all_levels.index(diagnostic_level) + except ValueError: + return False + + def _merge_datafiles(self, previous: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: + return { + "schemaVersion": incoming.get("schemaVersion"), + "revision": incoming.get("revision"), + "featurevisorVersion": incoming.get("featurevisorVersion"), + "segments": {**(previous.get("segments") or {}), **(incoming.get("segments") or {})}, + "features": {**(previous.get("features") or {}), **(incoming.get("features") or {})}, + } + setLogLevel = set_log_level setDatafile = set_datafile setSticky = set_sticky getRevision = get_revision getFeature = get_feature - addHook = add_hook + addModule = add_module + removeModule = remove_module setContext = set_context getContext = get_context evaluateFlag = evaluate_flag @@ -197,4 +300,3 @@ def get_all_evaluations(self, context: dict[str, Any] | None = None, feature_key def create_instance(options: dict[str, Any] | None = None) -> FeaturevisorInstance: return FeaturevisorInstance(options or {}) - diff --git a/src/featurevisor/modules.py b/src/featurevisor/modules.py new file mode 100644 index 0000000..b4e404a --- /dev/null +++ b/src/featurevisor/modules.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import uuid +from typing import Any, Callable + +DiagnosticHandler = Callable[[dict[str, Any]], None] + + +class FeaturevisorModule: + def __init__(self, options: dict[str, Any] | None = None) -> None: + options = options or {} + self.id = str(uuid.uuid4()) + self.name = options.get("name") + self.setup = options.get("setup") + self.before = options.get("before") + self.bucket_key = options.get("bucketKey") or options.get("bucket_key") + self.bucket_value = options.get("bucketValue") or options.get("bucket_value") + self.after = options.get("after") + self.close = options.get("close") + + def call_setup(self, api: dict[str, Any]) -> None: + if self.setup: + self.setup(api) + + def call_before(self, options: dict[str, Any]) -> dict[str, Any]: + if not self.before: + return options + return self.before(options) + + def call_bucket_key(self, options: dict[str, Any]) -> str: + if not self.bucket_key: + return options["bucketKey"] + return self.bucket_key(options) + + def call_bucket_value(self, options: dict[str, Any]) -> int: + if not self.bucket_value: + return options["bucketValue"] + return self.bucket_value(options) + + def call_after(self, evaluation: dict[str, Any], options: dict[str, Any]) -> dict[str, Any]: + if not self.after: + return evaluation + return self.after(evaluation, options) + + def call_close(self) -> None: + if self.close: + self.close() + + +class ModulesManager: + def __init__( + self, + *, + modules: list[dict[str, Any] | FeaturevisorModule] | None = None, + report_diagnostic: Callable[[dict[str, Any], FeaturevisorModule | None], None] | None = None, + module_api_factory: Callable[[FeaturevisorModule], dict[str, Any]] | None = None, + clear_module_diagnostic_subscriptions: Callable[[FeaturevisorModule], None] | None = None, + ) -> None: + self.modules: list[FeaturevisorModule] = [] + self.report_diagnostic = report_diagnostic + self.module_api_factory = module_api_factory + self.clear_module_diagnostic_subscriptions = clear_module_diagnostic_subscriptions + + for module in modules or []: + self.add(module) + + def add(self, module: dict[str, Any] | FeaturevisorModule): + module = FeaturevisorModule(module) if isinstance(module, dict) else module + if module is None: + return None + + if module.name and any(existing.name == module.name for existing in self.modules): + self._report( + { + "level": "error", + "code": "duplicate_module", + "message": "Duplicate module name", + "moduleName": module.name, + }, + module, + ) + return None + + if self.module_api_factory: + module.call_setup(self.module_api_factory(module)) + + self.modules.append(module) + + def remove() -> None: + self.remove(module) + + return remove + + def remove(self, name_or_module: str | FeaturevisorModule) -> None: + removed: list[FeaturevisorModule] = [] + kept: list[FeaturevisorModule] = [] + for module in self.modules: + matches = module is name_or_module or module.name == name_or_module + if matches: + removed.append(module) + else: + kept.append(module) + self.modules = kept + + for module in removed: + module.call_close() + if self.clear_module_diagnostic_subscriptions: + self.clear_module_diagnostic_subscriptions(module) + + def get_all(self) -> list[FeaturevisorModule]: + return list(self.modules) + + def run_before_modules(self, options: dict[str, Any]) -> dict[str, Any]: + current = options + for module in self.modules: + current = module.call_before(current) + return current + + def run_bucket_key_modules(self, options: dict[str, Any]) -> str: + bucket_key = options["bucketKey"] + for module in self.modules: + bucket_key = module.call_bucket_key({**options, "bucketKey": bucket_key}) + return bucket_key + + def run_bucket_value_modules(self, options: dict[str, Any]) -> int: + bucket_value = options["bucketValue"] + for module in self.modules: + bucket_value = module.call_bucket_value({**options, "bucketValue": bucket_value}) + return bucket_value + + def run_after_modules(self, evaluation: dict[str, Any], options: dict[str, Any]) -> dict[str, Any]: + current = evaluation + for module in self.modules: + current = module.call_after(current, options) + return current + + def close_all(self) -> None: + for module in self.modules: + module.call_close() + if self.clear_module_diagnostic_subscriptions: + self.clear_module_diagnostic_subscriptions(module) + self.modules = [] + + def _report(self, diagnostic: dict[str, Any], module: FeaturevisorModule | None = None) -> None: + if self.report_diagnostic: + self.report_diagnostic(diagnostic, module) + + getAll = get_all + runBeforeModules = run_before_modules + runBucketKeyModules = run_bucket_key_modules + runBucketValueModules = run_bucket_value_modules + runAfterModules = run_after_modules + closeAll = close_all diff --git a/src/featurevisor/project.py b/src/featurevisor/project.py index 1dc6120..cb8a054 100644 --- a/src/featurevisor/project.py +++ b/src/featurevisor/project.py @@ -42,30 +42,28 @@ def list_segments(self) -> list[dict[str, Any]]: def list_features(self) -> list[dict[str, Any]]: return self.run_json("list", "--features", "--json") - def build_datafile_json(self, environment: str | None = None, schema_version: str | None = None, inflate: int | None = None) -> dict[str, Any]: + def list_targets(self) -> list[str]: + targets = self.run_json("list", "--targets", "--json") + if isinstance(targets, list): + return [ + (target.get("name") or target.get("key")) if isinstance(target, dict) else target + for target in targets + if ((target.get("name") or target.get("key")) if isinstance(target, dict) else target) + ] + if isinstance(targets, dict): + return list(targets.keys()) + return [] + + def build_datafile_json(self, environment: str | None = None, inflate: int | None = None, target: str | None = None) -> dict[str, Any]: args = ["build", "--json"] if environment: args.append(f"--environment={environment}") - if schema_version: - args.append(f"--schema-version={schema_version}") if inflate: args.append(f"--inflate={inflate}") + if target: + args.append(f"--target={target}") return self.run_json(*args) - def ensure_built(self, schema_version: str | None = None, inflate: int | None = None) -> None: - args = ["build"] - if schema_version: - args.append(f"--schema-version={schema_version}") - if inflate: - args.append(f"--inflate={inflate}") - self.run(*args) - - def read_generated_datafile(self, config: dict[str, Any], *, environment: str | None, kind: str, value: str) -> dict[str, Any]: - datafiles_dir = pathlib.Path(config["datafilesDirectoryPath"]) - base_dir = datafiles_dir / environment if environment else datafiles_dir - file_name = config.get("datafileNamePattern", "featurevisor-%s.json") % f"{kind}-{value}" - return json.loads((base_dir / file_name).read_text()) - def pretty_duration(seconds: float) -> str: if seconds == 0: @@ -88,8 +86,7 @@ def pretty_duration(seconds: float) -> str: return " ".join(parts) -def timed_build(project: FeaturevisorProject, *, environment: str, schema_version: str | None = None, inflate: int | None = None) -> tuple[dict[str, Any], float]: +def timed_build(project: FeaturevisorProject, *, environment: str, inflate: int | None = None) -> tuple[dict[str, Any], float]: start = time.perf_counter() - datafile = project.build_datafile_json(environment=environment, schema_version=schema_version, inflate=inflate) + datafile = project.build_datafile_json(environment=environment, inflate=inflate) return datafile, time.perf_counter() - start - diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index dc62fba..863aae4 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -1,8 +1,6 @@ from __future__ import annotations -import copy import json -import subprocess import time import uuid from typing import Any @@ -86,14 +84,15 @@ def _get_base_datafile_key(assertion: dict[str, Any]) -> str | bool: return environment if environment is not None else False +def _get_target_datafile_key(environment: str | bool | None, target: str) -> str: + return f"{environment}-target-{target}" if environment else f"false-target-{target}" + + def _get_datafile_for_assertion(assertion: dict[str, Any], cache: dict[Any, dict[str, Any]]) -> dict[str, Any]: key = _get_base_datafile_key(assertion) - scoped_key = f"{assertion.get('environment')}-scope-{assertion.get('scope')}" if assertion.get("environment") else f"scope-{assertion.get('scope')}" - tagged_key = f"{assertion.get('environment')}-tag-{assertion.get('tag')}" if assertion.get("environment") else f"tag-{assertion.get('tag')}" - if assertion.get("scope") and scoped_key in cache: - return cache[scoped_key] - if assertion.get("tag") and tagged_key in cache: - return cache[tagged_key] + target_key = _get_target_datafile_key(key, assertion["target"]) if assertion.get("target") else None + if target_key and target_key in cache: + return cache[target_key] return cache[key] @@ -185,6 +184,7 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N tests = project.list_tests(key_pattern=key_pattern, assertion_pattern=assertion_pattern) features_by_key = {item["key"]: item for item in project.list_features()} segments_by_key = {item["key"]: item for item in project.list_segments()} + targets = project.list_targets() datafile_cache: dict[Any, dict[str, Any]] = {} start = time.perf_counter() passed_tests_count = 0 @@ -197,22 +197,13 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N environments = [None] for environment in environments or [None]: base_key = environment if environment is not None else False - datafile_cache[base_key] = project.build_datafile_json(environment=environment, schema_version=schema_version, inflate=inflate or None) - if with_tags or with_scopes: - try: - project.ensure_built(schema_version=schema_version, inflate=inflate or None) - except subprocess.CalledProcessError: - pass - if with_tags: - for environment in environments or [None]: - for tag in config.get("tags", []): - cache_key = f"{environment}-tag-{tag}" if environment else f"tag-{tag}" - datafile_cache[cache_key] = project.read_generated_datafile(config, environment=environment, kind="tag", value=tag) - if with_scopes: - for environment in environments or [None]: - for scope in config.get("scopes", []): - cache_key = f"{environment}-scope-{scope['name']}" if environment else f"scope-{scope['name']}" - datafile_cache[cache_key] = project.read_generated_datafile(config, environment=environment, kind="scope", value=scope["name"]) + datafile_cache[base_key] = project.build_datafile_json(environment=environment, inflate=inflate or None) + for target in targets: + datafile_cache[_get_target_datafile_key(base_key, target)] = project.build_datafile_json( + environment=environment, + inflate=inflate or None, + target=target, + ) passed = True for test in tests: @@ -231,7 +222,7 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N sdk = create_instance({ "datafile": datafile, "sticky": assertion.get("sticky", {}), - "hooks": [ + "modules": [ { "name": "tester", "bucketValue": lambda opts, at=assertion.get("at"): int(at * 1000) if at is not None else opts["bucketValue"], @@ -239,11 +230,7 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N ], "logLevel": _log_level(verbose, quiet), }) - context = copy.deepcopy(assertion.get("context", {})) - if assertion.get("scope") and not with_scopes: - scope = next((scope for scope in config.get("scopes", []) if scope["name"] == assertion["scope"]), None) - if scope: - context = {**scope.get("context", {}), **context} + context = dict(assertion.get("context", {})) if context: sdk.set_context(context) assertion_result = {"description": assertion.get("description", ""), "duration": 0, "passed": True, "errors": []} @@ -284,7 +271,7 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N def run_benchmark(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, variation: bool = False, variable: str | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False) -> int: project = FeaturevisorProject(project_directory_path) - datafile, build_duration = timed_build(project, environment=environment, schema_version=schema_version, inflate=inflate or None) + datafile, build_duration = timed_build(project, environment=environment, inflate=inflate or None) level = _log_level(verbose, quiet) instance = create_instance({"datafile": datafile, "logLevel": level}) context = context or {} @@ -312,7 +299,7 @@ def run_benchmark(project_directory_path: str, *, environment: str, feature: str def run_assess_distribution(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, populate_uuid: list[str] | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False) -> int: project = FeaturevisorProject(project_directory_path) - datafile = project.build_datafile_json(environment=environment, schema_version=schema_version, inflate=inflate or None) + datafile = project.build_datafile_json(environment=environment, inflate=inflate or None) instance = create_instance({"datafile": datafile, "logLevel": _log_level(verbose, quiet)}) context = context or {} populate_uuid = populate_uuid or [] diff --git a/src/featurevisor/types.py b/src/featurevisor/types.py index 435249a..d7638f5 100644 --- a/src/featurevisor/types.py +++ b/src/featurevisor/types.py @@ -14,7 +14,7 @@ BucketValue = int LogLevel = Literal["fatal", "error", "warn", "info", "debug"] -EventName = Literal["datafile_set", "context_set", "sticky_set"] +EventName = Literal["datafile_set", "context_set", "sticky_set", "error"] class EvaluatedFeature(TypedDict, total=False): @@ -99,8 +99,9 @@ class Segment(TypedDict, total=False): class DatafileContent(TypedDict): - schemaVersion: str + schema_version: str revision: str + featurevisorVersion: NotRequired[str] segments: dict[SegmentKey, Segment] features: dict[FeatureKey, Feature] @@ -127,12 +128,24 @@ class Evaluation(TypedDict, total=False): variableOverrideIndex: int -class Hook(TypedDict, total=False): +class FeaturevisorDiagnostic(TypedDict, total=False): + level: LogLevel + code: str + message: str + module: str + moduleName: str + originalError: Exception + details: dict[str, Any] + + +class FeaturevisorModule(TypedDict, total=False): name: str before: Callable[[dict[str, Any]], dict[str, Any]] bucketKey: Callable[[dict[str, Any]], BucketKey] bucketValue: Callable[[dict[str, Any]], BucketValue] after: Callable[[Evaluation, dict[str, Any]], Evaluation] + setup: Callable[[dict[str, Any]], None] + close: Callable[[], None] class TestResultAssertionError(TypedDict, total=False): @@ -176,17 +189,14 @@ class CLIOptions(TypedDict, total=False): inflate: int with_scopes: bool with_tags: bool - schemaVersion: str + schema_version: str populateUuid: list[str] class ProjectConfig(TypedDict, total=False): environments: list[str] | bool - tags: list[str] - scopes: list[dict[str, Any]] datafilesDirectoryPath: str datafileNamePattern: str testsDirectoryPath: str featuresDirectoryPath: str segmentsDirectoryPath: str - diff --git a/tests/test_cli.py b/tests/test_cli.py index 87dd98e..b84e087 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,16 +7,23 @@ sys.path.insert(0, "src") from featurevisor.cli import build_parser, main +from featurevisor.tester import _get_datafile_for_assertion, _get_target_datafile_key class CLITests(unittest.TestCase): def test_parse_test_flags(self) -> None: parser = build_parser() - args = parser.parse_args(["test", "--with-scopes", "--with-tags", "--projectDirectoryPath=/tmp/project"]) + args = parser.parse_args(["test", "--withScopes", "--withTags", "--schema-version=2", "--projectDirectoryPath=/tmp/project"]) self.assertTrue(args.with_scopes) self.assertTrue(args.with_tags) + self.assertEqual(args.schema_version, "2") self.assertEqual(args.projectDirectoryPath, "/tmp/project") + def test_parse_legacy_schema_camel_alias(self) -> None: + parser = build_parser() + args = parser.parse_args(["test", "--schemaVersion=2"]) + self.assertEqual(args.schema_version, "2") + def test_parse_benchmark_flags(self) -> None: parser = build_parser() args = parser.parse_args(["benchmark", "--environment=production", "--feature=foo", "--n=20", "--variation"]) @@ -30,6 +37,18 @@ def test_main_returns_non_zero_on_failed_tests(self) -> None: with patch("featurevisor.cli.run_test_project", return_value=False): self.assertEqual(main(["test"]), 1) + def test_target_datafile_cache_key(self) -> None: + self.assertEqual(_get_target_datafile_key(False, "checkout"), "false-target-checkout") + self.assertEqual(_get_target_datafile_key("production", "checkout"), "production-target-checkout") + + def test_target_assertion_selects_target_datafile(self) -> None: + cache = { + "production": {"kind": "base"}, + "production-target-checkout": {"kind": "target"}, + } + datafile = _get_datafile_for_assertion({"environment": "production", "target": "checkout"}, cache) + self.assertEqual(datafile["kind"], "target") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_instance_parity.py b/tests/test_instance_parity.py index 39b3f3b..fb5d93b 100644 --- a/tests/test_instance_parity.py +++ b/tests/test_instance_parity.py @@ -40,14 +40,14 @@ def test_should_configure_plain_and_and_or_bucket_by(self) -> None: }, "segments": {}, }, - "hooks": [{"name": "unit-test", "bucketKey": lambda options, captured=captured: captured.update(bucket=options["bucketKey"]) or options["bucketKey"]}], + "modules": [{"name": "unit-test", "bucketKey": lambda options, captured=captured: captured.update(bucket=options["bucketKey"]) or options["bucketKey"]}], "logLevel": "fatal", } ) self.assertEqual(sdk.getVariation("test", context), "control") self.assertEqual(captured["bucket"], expected_bucket) - def test_should_intercept_before_and_after_hooks(self) -> None: + def test_should_intercept_before_and_after_modules(self) -> None: before = {"called": False, "featureKey": "", "variableKey": None} sdk = createInstance( { @@ -57,7 +57,7 @@ def test_should_intercept_before_and_after_hooks(self) -> None: "features": {"test": {"key": "test", "bucketBy": "userId", "variations": [{"value": "control"}, {"value": "treatment"}], "traffic": [{"key": "1", "segments": "*", "percentage": 100000, "allocation": [{"variation": "control", "range": [0, 100000]}, {"variation": "treatment", "range": [0, 0]}]}]}}, "segments": {}, }, - "hooks": [ + "modules": [ {"name": "before", "before": lambda options: before.update(called=True, featureKey=options["featureKey"], variableKey=options.get("variableKey")) or options}, {"name": "after", "after": lambda evaluation, options: {**evaluation, "variationValue": "control_intercepted"}}, ], @@ -120,7 +120,7 @@ def test_mutually_exclusive_variation_and_variable_cases(self) -> None: bucket = {"value": 10000} sdk = createInstance( { - "hooks": [{"name": "unit-test", "bucketValue": lambda options, bucket=bucket: bucket["value"]}], + "modules": [{"name": "unit-test", "bucketValue": lambda options, bucket=bucket: bucket["value"]}], "datafile": { "schemaVersion": "2", "revision": "1.0", diff --git a/tests/test_sdk.py b/tests/test_sdk.py index f55c267..dcd7468 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -72,6 +72,119 @@ def test_instance_basic_flow(self) -> None: self.assertEqual(instance.get_variable("my_feature", "title"), "Hello") self.assertEqual(instance.get_all_evaluations()["my_feature"]["variables"]["title"], "Hello") + def test_set_datafile_merges_by_default_and_replace_opt_in(self) -> None: + instance = create_instance( + { + "datafile": { + "schemaVersion": "2", + "revision": "1", + "featurevisorVersion": "3.0.0", + "segments": {}, + "features": {"first": {"bucketBy": "userId", "traffic": [{"key": "1", "segments": "*", "percentage": 100000}]}}, + }, + "logLevel": "fatal", + } + ) + + instance.set_datafile( + { + "schemaVersion": "2", + "revision": "2", + "featurevisorVersion": "3.1.0", + "segments": {}, + "features": {"second": {"bucketBy": "userId", "traffic": [{"key": "1", "segments": "*", "percentage": 100000}]}}, + } + ) + + self.assertEqual(instance.get_revision(), "2") + self.assertEqual(instance.datafile_reader.featurevisor_version, "3.1.0") + self.assertIsNotNone(instance.get_feature("first")) + self.assertIsNotNone(instance.get_feature("second")) + + instance.set_datafile( + { + "schemaVersion": "2", + "revision": "3", + "segments": {}, + "features": {"third": {"bucketBy": "userId", "traffic": [{"key": "1", "segments": "*", "percentage": 100000}]}}, + }, + True, + ) + + self.assertIsNone(instance.get_feature("first")) + self.assertIsNone(instance.get_feature("second")) + self.assertIsNotNone(instance.get_feature("third")) + + def test_datafile_set_event_includes_replaced(self) -> None: + events = [] + instance = create_instance({"logLevel": "fatal"}) + instance.on("datafile_set", lambda event: events.append(event)) + + instance.set_datafile({"schemaVersion": "2", "revision": "1", "segments": {}, "features": {}}) + instance.set_datafile({"schemaVersion": "2", "revision": "2", "segments": {}, "features": {}}, True) + + self.assertEqual([event["replaced"] for event in events], [False, True]) + + def test_module_lifecycle_duplicate_diagnostics_and_close(self) -> None: + diagnostics = [] + errors = [] + setup_revision = {} + closed = [] + instance = create_instance( + { + "logLevel": "error", + "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic), + "modules": [ + { + "name": "lifecycle", + "setup": lambda api: setup_revision.update(revision=api["getRevision"]()), + "close": lambda: closed.append("lifecycle"), + } + ], + } + ) + instance.on("error", lambda event: errors.append(event)) + + self.assertEqual(setup_revision["revision"], "unknown") + self.assertIsNone(instance.add_module({"name": "lifecycle"})) + self.assertEqual(diagnostics[-1]["code"], "duplicate_module") + self.assertEqual(errors[-1]["code"], "duplicate_module") + + instance.close() + self.assertEqual(closed, ["lifecycle"]) + + def test_module_diagnostic_subscribe_report_and_remove_cleanup(self) -> None: + instance_diagnostics = [] + listener_diagnostics = [] + reporter_api = {} + listener_closed = [] + instance = create_instance( + { + "onDiagnostic": lambda diagnostic: instance_diagnostics.append(diagnostic), + "modules": [ + { + "name": "listener", + "setup": lambda api: api["onDiagnostic"](lambda diagnostic: listener_diagnostics.append(diagnostic)), + "close": lambda: listener_closed.append(True), + }, + { + "name": "reporter", + "setup": lambda api: reporter_api.update(api=api), + }, + ], + } + ) + + reporter_api["api"]["reportDiagnostic"]({"level": "warn", "code": "module_warning", "message": "module warning"}) + self.assertEqual(instance_diagnostics[-1]["module"], "reporter") + self.assertEqual(listener_diagnostics[-1]["code"], "module_warning") + + instance.remove_module("listener") + reporter_api["api"]["reportDiagnostic"]({"level": "warn", "code": "after_remove", "message": "after remove"}) + self.assertEqual(instance_diagnostics[-1]["code"], "after_remove") + self.assertEqual(listener_diagnostics[-1]["code"], "module_warning") + self.assertEqual(listener_closed, [True]) + def test_datafile_reader_parses_stringified_conditions(self) -> None: reader = DatafileReader( datafile={ @@ -95,7 +208,7 @@ def test_events_helpers(self) -> None: current = DatafileReader(datafile={"schemaVersion": "2", "revision": "2", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash2", "traffic": []}, "feature2": {"bucketBy": "userId", "hash": "hash3", "traffic": []}}}, logger=logger) self.assertEqual( get_params_for_datafile_set_event(previous, current), - {"revision": "2", "previousRevision": "1", "revisionChanged": True, "features": ["feature1", "feature2"]}, + {"revision": "2", "previousRevision": "1", "revisionChanged": True, "features": ["feature1", "feature2"], "replaced": False}, ) def test_emitter_subscribe_unsubscribe(self) -> None: From 0156dc4d39e431787da97ad0472ba18da3ffa271 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 26 Jun 2026 19:06:44 +0200 Subject: [PATCH 02/14] updates --- README.md | 207 ++++++++++++++++++++++++--- src/featurevisor/datafile_reader.py | 4 +- src/featurevisor/modules.py | 19 ++- tests/test_conditions_parity.py | 4 + tests/test_datafile_reader_parity.py | 8 ++ tests/test_sdk.py | 86 +++++++++++ 6 files changed, 304 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d3d5182..ec7f181 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# featurevisor-python +# Featurevisor Python SDK This repository ports the latest Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) to Python. @@ -6,6 +6,53 @@ The package name is `featurevisor`, and it targets Python 3.10+. This SDK is compatible with Featurevisor v3 projects and v2 datafiles. +## Table of contents + +- [Installation](#installation) +- [Initialization](#initialization) +- [Evaluation types](#evaluation-types) +- [Context](#context) + - [Setting initial context](#setting-initial-context) + - [Setting after initialization](#setting-after-initialization) + - [Replacing existing context](#replacing-existing-context) + - [Manually passing context](#manually-passing-context) +- [Check if enabled](#check-if-enabled) +- [Getting variation](#getting-variation) +- [Getting variables](#getting-variables) + - [Type specific methods](#type-specific-methods) +- [Getting all evaluations](#getting-all-evaluations) +- [Sticky](#sticky) + - [Initialize with sticky](#initialize-with-sticky) + - [Set sticky afterwards](#set-sticky-afterwards) +- [Setting datafile](#setting-datafile) + - [Merging by default](#merging-by-default) + - [Replacing](#replacing) + - [Loading datafiles on demand](#loading-datafiles-on-demand) + - [Updating datafile](#updating-datafile) + - [Interval-based update](#interval-based-update) +- [Logging](#logging) + - [Levels](#levels) + - [Customizing levels](#customizing-levels) + - [Handler](#handler) +- [Events](#events) + - [`datafile_set`](#datafile_set) + - [`context_set`](#context_set) + - [`sticky_set`](#sticky_set) + - [`error`](#error) +- [Diagnostics](#diagnostics) +- [Modules](#modules) + - [Defining a module](#defining-a-module) + - [Registering modules](#registering-modules) +- [Child instance](#child-instance) +- [Close](#close) +- [CLI usage](#cli-usage) + - [Test](#test) + - [Benchmark](#benchmark) + - [Assess distribution](#assess-distribution) +- [Development](#development) +- [Releasing](#releasing) +- [License](#license) + ## Installation @@ -34,7 +81,7 @@ f = create_instance({ }) ``` -## Evaluation Types +## Evaluation types We can evaluate 3 types of values against a particular [feature](https://featurevisor.com/docs/features/): @@ -53,6 +100,8 @@ context = { } ``` +### Setting initial context + You can provide context at initialization: ```python @@ -64,6 +113,8 @@ f = create_instance({ }) ``` +### Setting after initialization + You can merge more context later: ```python @@ -72,6 +123,8 @@ f.set_context({ }) ``` +### Replacing existing context + Or replace the existing context: ```python @@ -86,6 +139,8 @@ f.set_context( ) ``` +### Manually passing context + You can also pass additional per-evaluation context: ```python @@ -94,14 +149,14 @@ variation = f.get_variation("my_feature", {"country": "nl"}) variable_value = f.get_variable("my_feature", "my_variable", {"country": "nl"}) ``` -## Check If Enabled +## Check if enabled ```python if f.is_enabled("my_feature"): pass ``` -## Getting Variation +## Getting variation ```python variation = f.get_variation("my_feature") @@ -110,12 +165,14 @@ if variation == "treatment": pass ``` -## Getting Variables +## Getting variables ```python bg_color = f.get_variable("my_feature", "bgColor") ``` +### Type specific methods + Typed convenience methods are also available: ```python @@ -128,7 +185,7 @@ f.get_variable_object(feature_key, variable_key, context={}) f.get_variable_json(feature_key, variable_key, context={}) ``` -## Getting All Evaluations +## Getting all evaluations ```python all_evaluations = f.get_all_evaluations() @@ -136,6 +193,8 @@ all_evaluations = f.get_all_evaluations() ## Sticky +### Initialize with sticky + You can pin feature evaluations with sticky values: ```python @@ -152,6 +211,8 @@ f = create_instance({ }) ``` +### Set sticky afterwards + Or update them later: ```python @@ -162,29 +223,89 @@ f.set_sticky({ }) ``` -## Setting Datafile +## Setting datafile -The SDK accepts either parsed JSON content or a JSON string: +You may initialize the SDK without passing `datafile`, and set it later on. The SDK accepts either parsed JSON content or a JSON string: ```python f.set_datafile(datafile_content) f.set_datafile(json.dumps(datafile_content)) ``` +### Merging by default + By default, `set_datafile(datafile)` merges incoming content into the SDK's current datafile: - top-level metadata such as `schemaVersion`, `revision`, and `featurevisorVersion` comes from the incoming datafile - `segments` are merged, with incoming entries overriding existing ones - `features` are merged, with incoming entries overriding existing ones +This means you can call `set_datafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. This is what makes [loading datafiles on demand](#loading-datafiles-on-demand) possible. + +### Replacing + To fully replace the stored datafile, pass `True` as the second argument: ```python f.set_datafile(datafile_content, True) ``` +### Loading datafiles on demand + +Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. + +This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application. You can load the datafile for the current part, and load others only when the user reaches them: + +```python +from urllib.request import urlopen +import json + +from featurevisor import create_instance + +f = create_instance({}) + +def load_datafile(target): + url = f"https://cdn.yoursite.com/production/featurevisor-{target}.json" + with urlopen(url) as response: + datafile = json.load(response) + + # merges into whatever was loaded before + f.set_datafile(datafile) + +load_datafile("products") + +# later, when the user reaches checkout +load_datafile("checkout") +``` + +### Updating datafile + +You can set the datafile as many times as you want in your application, which will emit a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. + +### Interval-based update + +Here's a minimal interval-style example: + +```python +import json +import threading +from urllib.request import urlopen + +def update_datafile(): + with urlopen(datafile_url) as response: + f.set_datafile(json.load(response)) + + threading.Timer(5 * 60, update_datafile).start() + +update_datafile() +``` + ## Logging +By default, Featurevisor SDKs report diagnostics/logs for `info` level and above. + +### Levels + Supported log levels: - `fatal` @@ -193,6 +314,8 @@ Supported log levels: - `info` - `debug` +### Customizing levels + ```python from featurevisor import create_instance @@ -202,23 +325,58 @@ f = create_instance({ }) ``` +You can also set the log level from the SDK instance afterwards: + +```python +f.set_log_level("debug") +``` + +### Handler + You can also provide a custom logger handler via `create_logger`. ## Events -The SDK emits: +Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. -- `datafile_set` -- `context_set` -- `sticky_set` -- `error` +### `datafile_set` + +```python +def handle_datafile_set(event): + revision = event["revision"] + previous_revision = event["previousRevision"] + revision_changed = event["revisionChanged"] + features = event["features"] + replaced = event["replaced"] + +unsubscribe = f.on("datafile_set", handle_datafile_set) +unsubscribe() +``` + +The `features` list will contain keys of features that have either been added, updated, or removed compared to the previous datafile content. + +### `context_set` + +```python +unsubscribe = f.on("context_set", lambda event: print(event["context"])) +unsubscribe() +``` + +### `sticky_set` ```python -unsubscribe = f.on("datafile_set", lambda details: print(details)) +unsubscribe = f.on("sticky_set", lambda event: print(event["features"])) unsubscribe() ``` -`datafile_set` includes a `replaced` flag. The `error` event is emitted for diagnostics reported with `level` set to `error` or `fatal`. +### `error` + +```python +unsubscribe = f.on("error", lambda diagnostic: print(diagnostic["message"])) +unsubscribe() +``` + +The `error` event is emitted for diagnostics reported with `level` set to `error` or `fatal`. ## Diagnostics @@ -243,6 +401,8 @@ Modules can intercept evaluation and participate in SDK lifecycle: - `after` - `close` +### Defining a module + ```python my_module = { "name": "my-module", @@ -253,6 +413,15 @@ my_module = { "after": lambda evaluation, options: evaluation, "close": lambda: None, } +``` + +The module API passed to `setup` exposes `getRevision`, `onDiagnostic`, and `reportDiagnostic`. + +### Registering modules + +Modules can be registered at initialization or afterwards: + +```python f = create_instance({ "modules": [my_module], @@ -264,9 +433,7 @@ remove_module() f.remove_module("my-module") ``` -The module API passed to `setup` exposes `getRevision`, `onDiagnostic`, and `reportDiagnostic`. - -## Child Instance +## Child instance ```python child = f.spawn({"country": "de"}) @@ -279,7 +446,7 @@ child.is_enabled("my_feature") f.close() ``` -## CLI Usage +## CLI usage The Python package also exposes a CLI: @@ -347,7 +514,7 @@ python -m featurevisor benchmark \ --context='{"userId":"123"}' ``` -### Assess Distribution +### Assess distribution Inspect enabled/disabled and variation distribution over repeated evaluations: diff --git a/src/featurevisor/datafile_reader.py b/src/featurevisor/datafile_reader.py index 61b74b8..0532a17 100644 --- a/src/featurevisor/datafile_reader.py +++ b/src/featurevisor/datafile_reader.py @@ -80,7 +80,7 @@ def all_conditions_are_matched(self, conditions: Any, context: Context) -> bool: if isinstance(conditions, dict) and isinstance(conditions.get("or"), list): return any(self.all_conditions_are_matched(item, context) for item in conditions["or"]) if isinstance(conditions, dict) and isinstance(conditions.get("not"), list): - return all(not self.all_conditions_are_matched({"and": conditions["not"]}, context) for _ in conditions["not"]) + return not self.all_conditions_are_matched({"and": conditions["not"]}, context) if isinstance(conditions, list): return all(self.all_conditions_are_matched(item, context) for item in conditions) return False @@ -99,7 +99,7 @@ def all_segments_are_matched(self, group_segments: Any, context: Context) -> boo if isinstance(group_segments, dict) and isinstance(group_segments.get("or"), list): return any(self.all_segments_are_matched(item, context) for item in group_segments["or"]) if isinstance(group_segments, dict) and isinstance(group_segments.get("not"), list): - return all(not self.all_segments_are_matched(item, context) for item in group_segments["not"]) + return not self.all_segments_are_matched({"and": group_segments["not"]}, context) if isinstance(group_segments, list): return all(self.all_segments_are_matched(item, context) for item in group_segments) return False diff --git a/src/featurevisor/modules.py b/src/featurevisor/modules.py index b4e404a..f331efe 100644 --- a/src/featurevisor/modules.py +++ b/src/featurevisor/modules.py @@ -103,9 +103,9 @@ def remove(self, name_or_module: str | FeaturevisorModule) -> None: self.modules = kept for module in removed: - module.call_close() if self.clear_module_diagnostic_subscriptions: self.clear_module_diagnostic_subscriptions(module) + self._close_module(module) def get_all(self) -> list[FeaturevisorModule]: return list(self.modules) @@ -136,11 +136,26 @@ def run_after_modules(self, evaluation: dict[str, Any], options: dict[str, Any]) def close_all(self) -> None: for module in self.modules: - module.call_close() if self.clear_module_diagnostic_subscriptions: self.clear_module_diagnostic_subscriptions(module) + self._close_module(module) self.modules = [] + def _close_module(self, module: FeaturevisorModule) -> None: + try: + module.call_close() + except Exception as exc: + self._report( + { + "level": "error", + "code": "module_close_error", + "message": "Module close failed", + "moduleName": module.name, + "originalError": exc, + }, + None, + ) + def _report(self, diagnostic: dict[str, Any], module: FeaturevisorModule | None = None) -> None: if self.report_diagnostic: self.report_diagnostic(diagnostic, module) diff --git a/tests/test_conditions_parity.py b/tests/test_conditions_parity.py index b04bdbc..25f8c10 100644 --- a/tests/test_conditions_parity.py +++ b/tests/test_conditions_parity.py @@ -96,6 +96,10 @@ def test_simple_and_nested_conditions(self) -> None: ([{"or": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_version", "operator": "equals", "value": "1.0"}]}], {"browser_version": "1.0"}, True), ([{"not": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}]}], {"browser_type": "firefox"}, True), ([{"not": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_version", "operator": "equals", "value": "1.0"}]}], {"browser_type": "chrome", "browser_version": "1.0"}, False), + ([{"not": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_version", "operator": "equals", "value": "1.0"}]}], {"browser_type": "chrome", "browser_version": "2.0"}, True), + ([{"not": [{"or": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_type", "operator": "equals", "value": "firefox"}]}]}], {"browser_type": "chrome"}, False), + ([{"not": [{"or": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_type", "operator": "equals", "value": "firefox"}]}]}], {"browser_type": "edge"}, True), + ([{"not": []}], {}, False), ([{"and": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"or": [{"attribute": "browser_version", "operator": "equals", "value": "1.0"}, {"attribute": "browser_version", "operator": "equals", "value": "2.0"}]}]}], {"browser_type": "chrome", "browser_version": "1.0"}, True), ( [ diff --git a/tests/test_datafile_reader_parity.py b/tests/test_datafile_reader_parity.py index 0fd9870..a431e78 100644 --- a/tests/test_datafile_reader_parity.py +++ b/tests/test_datafile_reader_parity.py @@ -60,6 +60,9 @@ def test_segments_cases(self) -> None: "germanMobileUsers": [{"and": ["mobileUsers", "germany"]}], "germanNonMobileUsers": [{"and": ["germany", {"not": ["mobileUsers"]}]}], "notVersion5.5": [{"not": ["version_5.5"]}], + "notDutchMobileUsers": {"not": ["mobileUsers", "netherlands"]}, + "notMobileOrDesktopUsers": {"not": [{"or": ["mobileUsers", "desktopUsers"]}]}, + "emptyNot": {"not": []}, } datafile = { "schemaVersion": "2", @@ -88,6 +91,11 @@ def test_segments_cases(self) -> None: ("germanNonMobileUsers", {"country": "de", "deviceType": "desktop"}, True), ("notVersion5.5", {"version": "5.5"}, False), ("notVersion5.5", {"version": 5.6}, True), + ("notDutchMobileUsers", {"country": "nl", "deviceType": "mobile"}, False), + ("notDutchMobileUsers", {"country": "nl", "deviceType": "desktop"}, True), + ("notMobileOrDesktopUsers", {"deviceType": "mobile"}, False), + ("notMobileOrDesktopUsers", {"deviceType": "tv"}, True), + ("emptyNot", {}, False), ] for key, context, expected in matches: with self.subTest(key=key, context=context): diff --git a/tests/test_sdk.py b/tests/test_sdk.py index dcd7468..db3a229 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -153,6 +153,72 @@ def test_module_lifecycle_duplicate_diagnostics_and_close(self) -> None: instance.close() self.assertEqual(closed, ["lifecycle"]) + def test_add_module_unsubscribe_closes_module_once(self) -> None: + closed = [] + instance = create_instance({"logLevel": "fatal"}) + + unsubscribe = instance.add_module({"name": "dynamic", "close": lambda: closed.append("dynamic")}) + + unsubscribe() + unsubscribe() + + self.assertEqual(closed, ["dynamic"]) + + def test_module_close_errors_are_reported_and_do_not_stop_cleanup(self) -> None: + diagnostics = [] + errors = [] + closed = [] + + def fail_close() -> None: + closed.append("first") + raise RuntimeError("first close failed") + + instance = create_instance( + { + "logLevel": "error", + "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic), + "modules": [ + {"name": "first", "close": fail_close}, + {"name": "second", "close": lambda: closed.append("second")}, + ], + } + ) + instance.on("error", lambda event: errors.append(event)) + + instance.close() + + self.assertEqual(closed, ["first", "second"]) + self.assertTrue( + any( + diagnostic.get("code") == "module_close_error" + and diagnostic.get("moduleName") == "first" + and diagnostic.get("level") == "error" + and isinstance(diagnostic.get("originalError"), RuntimeError) + for diagnostic in diagnostics + ) + ) + self.assertTrue(any(event.get("code") == "module_close_error" and event.get("moduleName") == "first" for event in errors)) + + def test_module_unsubscribe_reports_close_errors_once(self) -> None: + diagnostics = [] + instance = create_instance({"logLevel": "error", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) + + def fail_close() -> None: + raise RuntimeError("dynamic close failed") + + unsubscribe = instance.add_module({"name": "dynamic", "close": fail_close}) + unsubscribe() + unsubscribe() + + self.assertEqual( + 1, + sum( + 1 + for diagnostic in diagnostics + if diagnostic.get("code") == "module_close_error" and diagnostic.get("moduleName") == "dynamic" + ), + ) + def test_module_diagnostic_subscribe_report_and_remove_cleanup(self) -> None: instance_diagnostics = [] listener_diagnostics = [] @@ -223,6 +289,26 @@ def test_emitter_subscribe_unsubscribe(self) -> None: emitter.clear_all() self.assertEqual(dict(emitter.listeners), {}) + def test_emitter_uses_listener_snapshot(self) -> None: + emitter = Emitter() + calls = [] + unsubscribe_second = None + + def first(_details: dict) -> None: + calls.append("first") + unsubscribe_second() + + def second(_details: dict) -> None: + calls.append("second") + + emitter.on("sticky_set", first) + unsubscribe_second = emitter.on("sticky_set", second) + + emitter.trigger("sticky_set") + emitter.trigger("sticky_set") + + self.assertEqual(calls, ["first", "second", "first"]) + def test_logger_handler_and_filtering(self) -> None: calls = [] logger = create_logger({"level": "warn", "handler": lambda level, message, details=None: calls.append((level, message, details))}) From ba4af927ccd40451b72f1f1db03ecc3a67c8ff0f Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:07:29 +0200 Subject: [PATCH 03/14] updates --- src/featurevisor/__init__.py | 2 -- src/featurevisor/instance.py | 2 +- tests/test_conditions_parity.py | 3 ++- tests/test_datafile_reader_parity.py | 3 ++- tests/test_sdk.py | 3 ++- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/featurevisor/__init__.py b/src/featurevisor/__init__.py index e5b6415..50f5e2e 100644 --- a/src/featurevisor/__init__.py +++ b/src/featurevisor/__init__.py @@ -1,7 +1,6 @@ from .bucketer import MAX_BUCKETED_NUMBER, get_bucket_key, get_bucketed_number from .child import FeaturevisorChildInstance from .conditions import condition_is_matched, get_value_from_context -from .datafile_reader import DatafileReader from .emitter import Emitter from .evaluate import EvaluationReason, evaluate, evaluate_with_modules from .events import get_params_for_datafile_set_event, get_params_for_sticky_set_event @@ -21,7 +20,6 @@ __all__ = [ "MAX_BUCKETED_NUMBER", - "DatafileReader", "Emitter", "EvaluationReason", "FeaturevisorChildInstance", diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index 9483907..89fc8ba 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -62,7 +62,7 @@ def set_datafile(self, datafile, replace: bool = False) -> None: self.emitter.trigger("datafile_set", details) self.report_diagnostic({"level": "info", "code": "datafile_set", "message": "datafile set", "details": details}) except Exception as exc: - self.report_diagnostic({"level": "error", "code": "invalid_datafile", "message": "could not parse datafile", "originalError": exc}) + self.report_diagnostic({"level": "error", "code": "invalid_datafile", "message": "Could not parse datafile", "originalError": exc}) def set_sticky(self, sticky: dict[str, Any], replace: bool = False) -> None: if self.closed: diff --git a/tests/test_conditions_parity.py b/tests/test_conditions_parity.py index 25f8c10..023bd75 100644 --- a/tests/test_conditions_parity.py +++ b/tests/test_conditions_parity.py @@ -6,7 +6,8 @@ sys.path.insert(0, "src") -from featurevisor import DatafileReader, createLogger +from featurevisor import createLogger +from featurevisor.datafile_reader import DatafileReader class ConditionsParityTests(unittest.TestCase): diff --git a/tests/test_datafile_reader_parity.py b/tests/test_datafile_reader_parity.py index a431e78..095afd9 100644 --- a/tests/test_datafile_reader_parity.py +++ b/tests/test_datafile_reader_parity.py @@ -5,7 +5,8 @@ sys.path.insert(0, "src") -from featurevisor import DatafileReader, createLogger +from featurevisor import createLogger +from featurevisor.datafile_reader import DatafileReader class DatafileReaderParityTests(unittest.TestCase): diff --git a/tests/test_sdk.py b/tests/test_sdk.py index db3a229..36727f8 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -7,10 +7,11 @@ sys.path.insert(0, "src") -from featurevisor import DatafileReader, Emitter, FeaturevisorChildInstance, create_instance +from featurevisor import Emitter, FeaturevisorChildInstance, create_instance from featurevisor.bucketer import MAX_BUCKETED_NUMBER, get_bucket_key, get_bucketed_number from featurevisor.compare_versions import compare_versions from featurevisor.conditions import condition_is_matched +from featurevisor.datafile_reader import DatafileReader from featurevisor.events import get_params_for_datafile_set_event, get_params_for_sticky_set_event from featurevisor.logger import create_logger From 2d826ed7cb0921eeae2736c641f6417dab10cfba Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:28:17 +0200 Subject: [PATCH 04/14] updates --- src/featurevisor/datafile_reader.py | 2 +- src/featurevisor/events.py | 4 ++-- src/featurevisor/instance.py | 6 +++--- src/featurevisor/tester.py | 4 ++-- tests/test_conditions_parity.py | 4 ++-- tests/test_datafile_reader_parity.py | 8 ++++---- tests/test_sdk.py | 8 ++++---- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/featurevisor/datafile_reader.py b/src/featurevisor/datafile_reader.py index 0532a17..846f1cc 100644 --- a/src/featurevisor/datafile_reader.py +++ b/src/featurevisor/datafile_reader.py @@ -9,7 +9,7 @@ from .types import Context, DatafileContent, Feature, Force, Segment, Traffic -class DatafileReader: +class _DatafileReader: def __init__(self, *, datafile: DatafileContent, logger: Logger) -> None: self.logger = logger self.schema_version = datafile["schemaVersion"] diff --git a/src/featurevisor/events.py b/src/featurevisor/events.py index 28743cb..fde7cf8 100644 --- a/src/featurevisor/events.py +++ b/src/featurevisor/events.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .datafile_reader import DatafileReader +from .datafile_reader import _DatafileReader def get_params_for_sticky_set_event(previous_sticky_features: dict | None = None, new_sticky_features: dict | None = None, replace: bool = False) -> dict: @@ -14,7 +14,7 @@ def get_params_for_sticky_set_event(previous_sticky_features: dict | None = None return {"features": features, "replaced": replace} -def get_params_for_datafile_set_event(previous_datafile_reader: DatafileReader, new_datafile_reader: DatafileReader, replace: bool = False) -> dict: +def get_params_for_datafile_set_event(previous_datafile_reader: _DatafileReader, new_datafile_reader: _DatafileReader, replace: bool = False) -> dict: previous_revision = previous_datafile_reader.get_revision() previous_feature_keys = previous_datafile_reader.get_feature_keys() new_revision = new_datafile_reader.get_revision() diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index 89fc8ba..d29415a 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -5,7 +5,7 @@ from typing import Any from .child import FeaturevisorChildInstance -from .datafile_reader import DatafileReader +from .datafile_reader import _DatafileReader from .emitter import Emitter from .evaluate import evaluate_with_modules from .events import get_params_for_datafile_set_event, get_params_for_sticky_set_event @@ -26,7 +26,7 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: self.sticky = options.get("sticky") self.closed = False self.module_diagnostic_subscriptions: list[dict[str, Any]] = [] - self.datafile_reader = DatafileReader(datafile=empty_datafile, logger=self.logger) + self.datafile_reader = _DatafileReader(datafile=empty_datafile, logger=self.logger) self.modules_manager = ModulesManager( modules=options.get("modules") or [], report_diagnostic=self.report_diagnostic, @@ -55,7 +55,7 @@ def set_datafile(self, datafile, replace: bool = False) -> None: try: parsed = json.loads(datafile) if isinstance(datafile, str) else datafile next_datafile = parsed if replace else self._merge_datafiles(self.datafile_reader.get_datafile(), parsed) - new_reader = DatafileReader(datafile=next_datafile, logger=self.logger) + new_reader = _DatafileReader(datafile=next_datafile, logger=self.logger) details = get_params_for_datafile_set_event(self.datafile_reader, new_reader, replace) self.datafile_reader = new_reader self.logger.info("datafile set", details) diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index 863aae4..de84b2a 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -5,7 +5,7 @@ import uuid from typing import Any -from .datafile_reader import DatafileReader +from .datafile_reader import _DatafileReader from .instance import create_instance from .logger import create_logger from .project import FeaturevisorProject, pretty_duration, timed_build @@ -161,7 +161,7 @@ def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: def test_segment(segment: dict[str, Any], assertion_options: dict[str, Any] | None = None) -> dict[str, Any]: options = assertion_options or {} logger = create_logger({"level": _log_level(options.get("verbose", False), options.get("quiet", False))}) - reader = DatafileReader(datafile={"schemaVersion": "2", "revision": "tester", "segments": {}, "features": {}}, logger=logger) + reader = _DatafileReader(datafile={"schemaVersion": "2", "revision": "tester", "segments": {}, "features": {}}, logger=logger) result = {"type": "segment", "key": segment["segment"], "notFound": False, "passed": True, "duration": 0, "assertions": []} start = time.perf_counter() for assertion in segment["assertions"]: diff --git a/tests/test_conditions_parity.py b/tests/test_conditions_parity.py index 023bd75..4cf64af 100644 --- a/tests/test_conditions_parity.py +++ b/tests/test_conditions_parity.py @@ -7,13 +7,13 @@ sys.path.insert(0, "src") from featurevisor import createLogger -from featurevisor.datafile_reader import DatafileReader +from featurevisor.datafile_reader import _DatafileReader class ConditionsParityTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.reader = DatafileReader( + cls.reader = _DatafileReader( datafile={"schemaVersion": "2.0", "revision": "1", "segments": {}, "features": {}}, logger=createLogger(), ) diff --git a/tests/test_datafile_reader_parity.py b/tests/test_datafile_reader_parity.py index 095afd9..c6fa52c 100644 --- a/tests/test_datafile_reader_parity.py +++ b/tests/test_datafile_reader_parity.py @@ -6,7 +6,7 @@ sys.path.insert(0, "src") from featurevisor import createLogger -from featurevisor.datafile_reader import DatafileReader +from featurevisor.datafile_reader import _DatafileReader class DatafileReaderParityTests(unittest.TestCase): @@ -15,7 +15,7 @@ def setUpClass(cls) -> None: cls.logger = createLogger() def test_should_be_a_function(self) -> None: - self.assertTrue(callable(DatafileReader)) + self.assertTrue(callable(_DatafileReader)) def test_v2_datafile_schema_should_return_requested_entities(self) -> None: datafile_json = { @@ -35,7 +35,7 @@ def test_v2_datafile_schema_should_return_requested_entities(self) -> None: "testWithNoVariations": {"key": "testWithNoVariations", "bucketBy": "userId", "traffic": [{"key": "1", "segments": "*", "percentage": 100000}]}, }, } - reader = DatafileReader(datafile=datafile_json, logger=self.logger) + reader = _DatafileReader(datafile=datafile_json, logger=self.logger) self.assertEqual(reader.getRevision(), "1") self.assertEqual(reader.getSchemaVersion(), "2") self.assertEqual(reader.getSegment("netherlands"), datafile_json["segments"]["netherlands"]) @@ -79,7 +79,7 @@ def test_segments_cases(self) -> None: "version_5.5": {"key": "version_5.5", "conditions": [{"or": [{"attribute": "version", "operator": "equals", "value": "5.5"}, {"attribute": "version", "operator": "equals", "value": 5.5}]}]}, }, } - reader = DatafileReader(datafile=datafile, logger=self.logger) + reader = _DatafileReader(datafile=datafile, logger=self.logger) matches = [ ("*", {}, True), ("*", {"foo": "foo"}, True), diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 36727f8..37a7013 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -11,7 +11,7 @@ from featurevisor.bucketer import MAX_BUCKETED_NUMBER, get_bucket_key, get_bucketed_number from featurevisor.compare_versions import compare_versions from featurevisor.conditions import condition_is_matched -from featurevisor.datafile_reader import DatafileReader +from featurevisor.datafile_reader import _DatafileReader from featurevisor.events import get_params_for_datafile_set_event, get_params_for_sticky_set_event from featurevisor.logger import create_logger @@ -253,7 +253,7 @@ def test_module_diagnostic_subscribe_report_and_remove_cleanup(self) -> None: self.assertEqual(listener_closed, [True]) def test_datafile_reader_parses_stringified_conditions(self) -> None: - reader = DatafileReader( + reader = _DatafileReader( datafile={ "schemaVersion": "2", "revision": "1", @@ -271,8 +271,8 @@ def test_events_helpers(self) -> None: {"features": ["feature1", "feature2"], "replaced": True}, ) logger = create_logger({"level": "fatal"}) - previous = DatafileReader(datafile={"schemaVersion": "2", "revision": "1", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash1", "traffic": []}}}, logger=logger) - current = DatafileReader(datafile={"schemaVersion": "2", "revision": "2", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash2", "traffic": []}, "feature2": {"bucketBy": "userId", "hash": "hash3", "traffic": []}}}, logger=logger) + previous = _DatafileReader(datafile={"schemaVersion": "2", "revision": "1", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash1", "traffic": []}}}, logger=logger) + current = _DatafileReader(datafile={"schemaVersion": "2", "revision": "2", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash2", "traffic": []}, "feature2": {"bucketBy": "userId", "hash": "hash3", "traffic": []}}}, logger=logger) self.assertEqual( get_params_for_datafile_set_event(previous, current), {"revision": "2", "previousRevision": "1", "revisionChanged": True, "features": ["feature1", "feature2"], "replaced": False}, From 644a6d93683dee8124a2bd462f2c203d45b0ebf6 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:39:07 +0200 Subject: [PATCH 05/14] updates --- README.md | 45 ++++++++++++++++++++++++---- src/featurevisor/__init__.py | 39 ++---------------------- tests/test_conditions_parity.py | 4 +-- tests/test_datafile_reader_parity.py | 4 +-- tests/test_helpers_parity.py | 24 +++++++-------- tests/test_instance_parity.py | 24 +++++++++++++-- tests/test_sdk.py | 3 +- 7 files changed, 81 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index ec7f181..8eb9de2 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,18 @@ f.set_log_level("debug") You can also provide a custom logger handler via `create_logger`. +## Diagnostics + +Diagnostics provide structured SDK and module events for observability: + +```python +f = create_instance({ + "onDiagnostic": lambda diagnostic: print(diagnostic), +}) +``` + +If `onDiagnostic` is not provided, diagnostics are sent to the SDK logger. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -378,17 +390,38 @@ unsubscribe() The `error` event is emitted for diagnostics reported with `level` set to `error` or `fatal`. -## Diagnostics +## Evaluation details -Diagnostics provide structured SDK and module events for observability: +Besides logging with debug level enabled, you can also get more details about how feature variations and variables are evaluated at runtime against a given context: ```python -f = create_instance({ - "onDiagnostic": lambda diagnostic: print(diagnostic), -}) +# flag +evaluation = f.evaluate_flag(feature_key, context={}) + +# variation +evaluation = f.evaluate_variation(feature_key, context={}) + +# variable +evaluation = f.evaluate_variable(feature_key, variable_key, context={}) ``` -If `onDiagnostic` is not provided, diagnostics are sent to the SDK logger. +The returned object will always contain the following properties: + +- `featureKey`: the feature key +- `reason`: the reason how the value was evaluated + +And optionally these properties depending on whether you are evaluating a feature variation or a variable: + +- `bucketValue`: the bucket value between 0 and 100,000 +- `ruleKey`: the rule key +- `error`: the error object +- `enabled`: if feature itself is enabled or not +- `variation`: the variation object +- `variationValue`: the variation value +- `variableKey`: the variable key +- `variableValue`: the variable value +- `variableSchema`: the variable schema +- `variableOverrideIndex`: index of matched variable override when applicable ## Modules diff --git a/src/featurevisor/__init__.py b/src/featurevisor/__init__.py index 50f5e2e..3f7228c 100644 --- a/src/featurevisor/__init__.py +++ b/src/featurevisor/__init__.py @@ -1,51 +1,18 @@ -from .bucketer import MAX_BUCKETED_NUMBER, get_bucket_key, get_bucketed_number from .child import FeaturevisorChildInstance -from .conditions import condition_is_matched, get_value_from_context -from .emitter import Emitter -from .evaluate import EvaluationReason, evaluate, evaluate_with_modules -from .events import get_params_for_datafile_set_event, get_params_for_sticky_set_event -from .helpers import get_value_by_type from .modules import FeaturevisorModule, ModulesManager from .instance import FeaturevisorInstance, create_instance -from .logger import Logger, create_logger, default_log_handler, loggerPrefix -from .murmurhash import murmurhash_v3 +from .logger import Logger createInstance = create_instance -createLogger = create_logger -getBucketKey = get_bucket_key -getBucketedNumber = get_bucketed_number -getValueByType = get_value_by_type -conditionIsMatched = condition_is_matched -getValueFromContext = get_value_from_context +Featurevisor = FeaturevisorInstance __all__ = [ - "MAX_BUCKETED_NUMBER", - "Emitter", - "EvaluationReason", + "Featurevisor", "FeaturevisorChildInstance", "FeaturevisorInstance", "FeaturevisorModule", "ModulesManager", "Logger", - "condition_is_matched", "create_instance", - "create_logger", - "default_log_handler", - "evaluate", - "evaluate_with_modules", - "get_bucket_key", - "get_bucketed_number", - "get_params_for_datafile_set_event", - "get_params_for_sticky_set_event", - "get_value_by_type", - "get_value_from_context", - "loggerPrefix", - "murmurhash_v3", "createInstance", - "createLogger", - "getBucketKey", - "getBucketedNumber", - "getValueByType", - "conditionIsMatched", - "getValueFromContext", ] diff --git a/tests/test_conditions_parity.py b/tests/test_conditions_parity.py index 4cf64af..104ecd6 100644 --- a/tests/test_conditions_parity.py +++ b/tests/test_conditions_parity.py @@ -6,8 +6,8 @@ sys.path.insert(0, "src") -from featurevisor import createLogger from featurevisor.datafile_reader import _DatafileReader +from featurevisor.logger import create_logger class ConditionsParityTests(unittest.TestCase): @@ -15,7 +15,7 @@ class ConditionsParityTests(unittest.TestCase): def setUpClass(cls) -> None: cls.reader = _DatafileReader( datafile={"schemaVersion": "2.0", "revision": "1", "segments": {}, "features": {}}, - logger=createLogger(), + logger=create_logger(), ) def test_should_be_a_function(self) -> None: diff --git a/tests/test_datafile_reader_parity.py b/tests/test_datafile_reader_parity.py index c6fa52c..a4abc37 100644 --- a/tests/test_datafile_reader_parity.py +++ b/tests/test_datafile_reader_parity.py @@ -5,14 +5,14 @@ sys.path.insert(0, "src") -from featurevisor import createLogger from featurevisor.datafile_reader import _DatafileReader +from featurevisor.logger import create_logger class DatafileReaderParityTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.logger = createLogger() + cls.logger = create_logger() def test_should_be_a_function(self) -> None: self.assertTrue(callable(_DatafileReader)) diff --git a/tests/test_helpers_parity.py b/tests/test_helpers_parity.py index ab256b6..dd4f4d7 100644 --- a/tests/test_helpers_parity.py +++ b/tests/test_helpers_parity.py @@ -5,42 +5,42 @@ sys.path.insert(0, "src") -from featurevisor import getValueByType +from featurevisor.helpers import get_value_by_type class HelpersParityTests(unittest.TestCase): def test_should_return_null_for_type_mismatch(self) -> None: - self.assertIsNone(getValueByType(1, "string")) + self.assertIsNone(get_value_by_type(1, "string")) def test_should_return_the_value_as_is_if_it_is_a_string(self) -> None: - self.assertEqual(getValueByType("1", "string"), "1") + self.assertEqual(get_value_by_type("1", "string"), "1") def test_should_return_the_value_as_is_if_it_is_a_boolean(self) -> None: - self.assertEqual(getValueByType(True, "boolean"), True) + self.assertEqual(get_value_by_type(True, "boolean"), True) def test_should_return_the_value_as_is_if_it_is_an_object(self) -> None: - self.assertEqual(getValueByType({"a": 1, "b": 2}, "object"), {"a": 1, "b": 2}) + self.assertEqual(get_value_by_type({"a": 1, "b": 2}, "object"), {"a": 1, "b": 2}) def test_should_return_the_value_as_is_if_it_is_a_json(self) -> None: - self.assertEqual(getValueByType('{"a": 1, "b": 2}', "json"), '{"a": 1, "b": 2}') + self.assertEqual(get_value_by_type('{"a": 1, "b": 2}', "json"), '{"a": 1, "b": 2}') def test_should_return_array_if_the_value_is_an_array(self) -> None: - self.assertEqual(getValueByType(["1", "2", "3"], "array"), ["1", "2", "3"]) + self.assertEqual(get_value_by_type(["1", "2", "3"], "array"), ["1", "2", "3"]) def test_should_return_integer_if_the_value_is_an_integer(self) -> None: - self.assertEqual(getValueByType("1", "integer"), 1) + self.assertEqual(get_value_by_type("1", "integer"), 1) def test_should_return_double_if_the_value_is_a_double(self) -> None: - self.assertEqual(getValueByType("1.1", "double"), 1.1) + self.assertEqual(get_value_by_type("1.1", "double"), 1.1) def test_should_return_null_if_the_value_is_undefined(self) -> None: - self.assertIsNone(getValueByType(None, "string")) + self.assertIsNone(get_value_by_type(None, "string")) def test_should_return_null_if_the_value_is_null(self) -> None: - self.assertIsNone(getValueByType(None, "string")) + self.assertIsNone(get_value_by_type(None, "string")) def test_should_return_null_when_a_function_is_passed(self) -> None: - self.assertIsNone(getValueByType(lambda: None, "string")) + self.assertIsNone(get_value_by_type(lambda: None, "string")) if __name__ == "__main__": diff --git a/tests/test_instance_parity.py b/tests/test_instance_parity.py index fb5d93b..e5b3e84 100644 --- a/tests/test_instance_parity.py +++ b/tests/test_instance_parity.py @@ -6,13 +6,31 @@ sys.path.insert(0, "src") -from featurevisor import createInstance, createLogger - +import featurevisor +from featurevisor import createInstance +from featurevisor.logger import create_logger class InstanceParityTests(unittest.TestCase): def test_should_be_a_function(self) -> None: self.assertTrue(callable(createInstance)) + def test_root_public_surface_is_narrow(self) -> None: + self.assertEqual( + set(featurevisor.__all__), + { + "Featurevisor", + "FeaturevisorChildInstance", + "FeaturevisorInstance", + "FeaturevisorModule", + "ModulesManager", + "Logger", + "create_instance", + "createInstance", + }, + ) + self.assertFalse(hasattr(featurevisor, "DatafileReader")) + self.assertFalse(hasattr(featurevisor, "createLogger")) + def test_should_create_instance_with_datafile_content(self) -> None: sdk = createInstance({"datafile": {"schemaVersion": "2", "revision": "1.0", "features": {}, "segments": {}}}) self.assertTrue(callable(sdk.getVariation)) @@ -107,7 +125,7 @@ def test_required_deprecated_and_rule_override_cases(self) -> None: }, "segments": {"netherlands": {"key": "netherlands", "conditions": '[{"attribute":"country","operator":"equals","value":"nl"}]'}}, }, - "logger": createLogger({"handler": lambda level, message, details=None: logger_calls.append((level, message))}), + "logger": create_logger({"handler": lambda level, message, details=None: logger_calls.append((level, message))}), } ) self.assertTrue(sdk.isEnabled("myKey")) diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 37a7013..ba9618c 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -7,12 +7,13 @@ sys.path.insert(0, "src") -from featurevisor import Emitter, FeaturevisorChildInstance, create_instance +from featurevisor import FeaturevisorChildInstance, create_instance from featurevisor.bucketer import MAX_BUCKETED_NUMBER, get_bucket_key, get_bucketed_number from featurevisor.compare_versions import compare_versions from featurevisor.conditions import condition_is_matched from featurevisor.datafile_reader import _DatafileReader from featurevisor.events import get_params_for_datafile_set_event, get_params_for_sticky_set_event +from featurevisor.emitter import Emitter from featurevisor.logger import create_logger From 75d8985adc4e01d3be33ca4a0b533cf19f9a938c Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 7 Jul 2026 23:17:48 +0200 Subject: [PATCH 06/14] benchmark --- src/featurevisor/tester.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index de84b2a..8a14372 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -275,16 +275,24 @@ def run_benchmark(project_directory_path: str, *, environment: str, feature: str level = _log_level(verbose, quiet) instance = create_instance({"datafile": datafile, "logLevel": level}) context = context or {} - start = time.perf_counter() + total_duration_ns = 0 + min_duration_ns: int | None = None + max_duration_ns = 0 value = None for _ in range(n): + evaluation_start = time.perf_counter_ns() if variation: value = instance.get_variation(feature, context) elif variable: value = instance.get_variable(feature, variable, context) else: value = instance.is_enabled(feature, context) - duration = time.perf_counter() - start + evaluation_duration_ns = time.perf_counter_ns() - evaluation_start + total_duration_ns += evaluation_duration_ns + min_duration_ns = evaluation_duration_ns if min_duration_ns is None else min(min_duration_ns, evaluation_duration_ns) + max_duration_ns = max(max_duration_ns, evaluation_duration_ns) + duration = total_duration_ns / 1_000_000_000 + average_duration_ns = total_duration_ns / n if n else 0 print("") print(f'Running benchmark for feature "{feature}"...') print("") @@ -293,7 +301,9 @@ def run_benchmark(project_directory_path: str, *, environment: str, feature: str print(f"Against context: {json.dumps(context)}") print(f"Evaluated value : {json.dumps(value)}") print(f"Total duration : {pretty_duration(duration)}") - print(f"Average duration: {pretty_duration(duration / n)}") + print(f"Minimum duration: {(min_duration_ns or 0) / 1_000_000:.6f}ms") + print(f"Average duration: {average_duration_ns / 1_000_000:.6f}ms") + print(f"Maximum duration: {max_duration_ns / 1_000_000:.6f}ms") return 0 From 50a8ddd18055823515545f018c95d351507a1d11 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 21:52:42 +0200 Subject: [PATCH 07/14] updates --- src/featurevisor/instance.py | 12 ++++++++---- tests/test_sdk.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index d29415a..21bcc5c 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -58,9 +58,8 @@ def set_datafile(self, datafile, replace: bool = False) -> None: new_reader = _DatafileReader(datafile=next_datafile, logger=self.logger) details = get_params_for_datafile_set_event(self.datafile_reader, new_reader, replace) self.datafile_reader = new_reader - self.logger.info("datafile set", details) self.emitter.trigger("datafile_set", details) - self.report_diagnostic({"level": "info", "code": "datafile_set", "message": "datafile set", "details": details}) + self.report_diagnostic({"level": "info", "code": "datafile_set", "message": "Datafile set", "details": details}) except Exception as exc: self.report_diagnostic({"level": "error", "code": "invalid_datafile", "message": "Could not parse datafile", "originalError": exc}) @@ -70,7 +69,7 @@ def set_sticky(self, sticky: dict[str, Any], replace: bool = False) -> None: previous = self.sticky or {} self.sticky = dict(sticky) if replace else {**(self.sticky or {}), **sticky} params = get_params_for_sticky_set_event(previous, self.sticky, replace) - self.logger.info("sticky features set", params) + self.report_diagnostic({"level": "info", "code": "sticky_set", "message": "Sticky features set", "details": params}) self.emitter.trigger("sticky_set", params) def get_revision(self) -> str: @@ -103,7 +102,12 @@ def set_context(self, context: dict[str, Any], replace: bool = False) -> None: return self.context = dict(context) if replace else {**self.context, **context} self.emitter.trigger("context_set", {"context": self.context, "replaced": replace}) - self.logger.debug("context replaced" if replace else "context updated", {"context": self.context, "replaced": replace}) + self.report_diagnostic({ + "level": "debug", + "code": "context_set", + "message": "Context replaced" if replace else "Context updated", + "details": {"context": self.context, "replaced": replace}, + }) def get_context(self, context: dict[str, Any] | None = None) -> dict[str, Any]: return {**self.context, **context} if context else self.context diff --git a/tests/test_sdk.py b/tests/test_sdk.py index ba9618c..73ceb18 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -127,6 +127,19 @@ def test_datafile_set_event_includes_replaced(self) -> None: self.assertEqual([event["replaced"] for event in events], [False, True]) + def test_lifecycle_mutations_report_diagnostics(self) -> None: + diagnostics = [] + instance = create_instance({"logLevel": "debug", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) + + instance.set_datafile({"schemaVersion": "2", "revision": "1", "segments": {}, "features": {}}) + instance.set_sticky({"test": {"enabled": True}}) + instance.set_context({"country": "nl"}) + + codes = [diagnostic["code"] for diagnostic in diagnostics] + self.assertIn("datafile_set", codes) + self.assertIn("sticky_set", codes) + self.assertIn("context_set", codes) + def test_module_lifecycle_duplicate_diagnostics_and_close(self) -> None: diagnostics = [] errors = [] From c20996d71f7dd35c0250b79dd01f4610b49d7983 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 22:59:20 +0200 Subject: [PATCH 08/14] diagnostics --- README.md | 4 ++++ src/featurevisor/child.py | 6 ++++-- src/featurevisor/instance.py | 12 +++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8eb9de2..8a88279 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,8 @@ all_evaluations = f.get_all_evaluations() You can pin feature evaluations with sticky values: +Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `spawn(context, {"sticky": ...})` when a child needs its own sticky state. + ```python f = create_instance({ "sticky": { @@ -347,6 +349,8 @@ f = create_instance({ If `onDiagnostic` is not provided, diagnostics are sent to the SDK logger. +Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` dictionary. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. diff --git a/src/featurevisor/child.py b/src/featurevisor/child.py index 957a19c..ea2bcea 100644 --- a/src/featurevisor/child.py +++ b/src/featurevisor/child.py @@ -40,7 +40,10 @@ def _merge_context(self, context: dict[str, Any] | None) -> dict[str, Any]: return {**self.context, **(context or {})} def _merge_options(self, options: dict[str, Any] | None) -> dict[str, Any]: - return {"sticky": self.sticky, **(options or {})} + # Sticky assignments belong to an instance. This private value carries + # this child instance's state to its parent without exposing a + # per-evaluation sticky override in the public options API. + return {**(options or {}), "__featurevisor_child_sticky": self.sticky} def is_enabled(self, feature_key: str, context: dict[str, Any] | None = None, options: dict[str, Any] | None = None) -> bool: return self.parent.is_enabled(feature_key, self._merge_context(context), self._merge_options(options)) @@ -97,4 +100,3 @@ def evaluate_variable(self, feature_key: str, variable_key: str, context: dict[s evaluateFlag = evaluate_flag evaluateVariation = evaluate_variation evaluateVariable = evaluate_variable - diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index 21bcc5c..22c6252 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -123,7 +123,7 @@ def _get_evaluation_dependencies(self, context: dict[str, Any], options: dict[st "logger": self.logger, "modulesManager": self.modules_manager, "datafileReader": self.datafile_reader, - "sticky": {**(self.sticky or {}), **options.get("sticky", {})} if options.get("sticky") else self.sticky, + "sticky": options.get("__featurevisor_child_sticky") or self.sticky, "defaultVariationValue": options.get("defaultVariationValue"), "defaultVariableValue": options.get("defaultVariableValue"), } @@ -245,6 +245,13 @@ def report_diagnostic(self, diagnostic: dict[str, Any], source_module: Featurevi diagnostic["level"] = diagnostic.get("level") or "info" if source_module and source_module.name and not diagnostic.get("module"): diagnostic["module"] = source_module.name + details = dict(diagnostic.get("details") or {}) + reserved = {"level", "code", "message", "module", "moduleName", "originalError", "details"} + for key, value in list(diagnostic.items()): + if key not in reserved: + details[key] = value + del diagnostic[key] + diagnostic["details"] = details for subscription in list(self.module_diagnostic_subscriptions): if source_module and subscription["moduleId"] == source_module.id: @@ -256,8 +263,7 @@ def report_diagnostic(self, diagnostic: dict[str, Any], source_module: Featurevi if self._should_report_diagnostic(diagnostic["level"], self.logger.level): self.on_diagnostic(diagnostic) else: - details = {key: value for key, value in diagnostic.items() if key not in {"level", "message"}} - self.logger.log(diagnostic["level"], diagnostic.get("message", ""), details) + self.logger.log(diagnostic["level"], diagnostic.get("message", ""), diagnostic) if diagnostic["level"] in {"error", "fatal"}: self.emitter.trigger("error", diagnostic) From cb108df5b790b41326ac6c4ed1cb6253b394e064 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 11 Jul 2026 18:46:00 +0200 Subject: [PATCH 09/14] improvements --- README.md | 6 +++++ conformance/sdk-v3.json | 49 +++++++++++++++++++++++++++++++++++ src/featurevisor/__init__.py | 7 +---- src/featurevisor/helpers.py | 18 +++++++------ src/featurevisor/instance.py | 10 +++++-- src/featurevisor/modules.py | 18 ++++++++++++- tests/test_conformance.py | 36 +++++++++++++++++++++++++ tests/test_helpers_parity.py | 9 +++++-- tests/test_instance_parity.py | 20 +++++++------- tests/test_sdk.py | 19 ++++++++++++++ 10 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 conformance/sdk-v3.json create mode 100644 tests/test_conformance.py diff --git a/README.md b/README.md index 8a88279..78fe243 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ f.get_variable_object(feature_key, variable_key, context={}) f.get_variable_json(feature_key, variable_key, context={}) ``` +Type specific methods do not coerce values. `get_variable_integer()` returns `None` for the string `"1"`, and boolean getters return `None` for non-boolean values. + ## Getting all evaluations ```python @@ -351,6 +353,8 @@ If `onDiagnostic` is not provided, diagnostics are sent to the SDK logger. Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` dictionary. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. +Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -454,6 +458,8 @@ my_module = { The module API passed to `setup` exposes `getRevision`, `onDiagnostic`, and `reportDiagnostic`. +If `setup` raises an exception, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present. + ### Registering modules Modules can be registered at initialization or afterwards: diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json new file mode 100644 index 0000000..ceae692 --- /dev/null +++ b/conformance/sdk-v3.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "description": "Featurevisor v3 cross SDK compatibility contracts", + "bucketing": { + "minimum": 0, + "maximum": 100000, + "percentage": { + "percentage": 50000, + "enabledAt": [0, 50000], + "disabledAt": [50001, 100000] + }, + "allocations": [ + { "variation": "control", "range": [0, 50000] }, + { "variation": "treatment", "range": [50000, 100000] } + ], + "allocationExpectations": { + "0": "control", + "49999": "control", + "50000": "control", + "50001": "treatment", + "99999": "treatment", + "100000": "treatment" + } + }, + "regularExpressions": { + "pattern": "chrome", + "flags": "g", + "values": ["chrome", "chrome", "firefox", "chrome"], + "matches": [true, true, false, true] + }, + "typedVariables": [ + { "type": "integer", "value": 1, "valid": true }, + { "type": "integer", "value": 1.5, "valid": false }, + { "type": "integer", "value": "1", "valid": false }, + { "type": "double", "value": 1.5, "valid": true }, + { "type": "double", "value": "1.5", "valid": false }, + { "type": "boolean", "value": true, "valid": true }, + { "type": "boolean", "value": "true", "valid": false } + ], + "datafile": { + "schemaVersionIsInformational": true, + "schemaVersionType": "string" + }, + "diagnostics": { + "requiredFields": ["level", "code", "message", "details"], + "detailsType": "object", + "emptyDetailsJson": "{}" + } +} diff --git a/src/featurevisor/__init__.py b/src/featurevisor/__init__.py index 3f7228c..ef5b6e3 100644 --- a/src/featurevisor/__init__.py +++ b/src/featurevisor/__init__.py @@ -1,18 +1,13 @@ from .child import FeaturevisorChildInstance from .modules import FeaturevisorModule, ModulesManager -from .instance import FeaturevisorInstance, create_instance +from .instance import FeaturevisorInstance as Featurevisor, create_instance from .logger import Logger -createInstance = create_instance -Featurevisor = FeaturevisorInstance - __all__ = [ "Featurevisor", "FeaturevisorChildInstance", - "FeaturevisorInstance", "FeaturevisorModule", "ModulesManager", "Logger", "create_instance", - "createInstance", ] diff --git a/src/featurevisor/helpers.py b/src/featurevisor/helpers.py index 05ca9f4..053f2f6 100644 --- a/src/featurevisor/helpers.py +++ b/src/featurevisor/helpers.py @@ -9,20 +9,22 @@ def get_value_by_type(value: Any, field_type: str) -> Any: if field_type == "string": return value if isinstance(value, str) else None if field_type == "integer": - try: - return int(value) - except (TypeError, ValueError): + if isinstance(value, bool): return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None if field_type == "double": - try: - return float(value) - except (TypeError, ValueError): + if isinstance(value, bool) or not isinstance(value, (int, float)): return None + result = float(value) + return result if result not in {float("inf"), float("-inf")} and result == result else None if field_type == "boolean": - return value is True + return value if isinstance(value, bool) else None if field_type == "array": return value if isinstance(value, list) else None if field_type == "object": return value if isinstance(value, dict) else None return value - diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index 22c6252..470061c 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -257,11 +257,17 @@ def report_diagnostic(self, diagnostic: dict[str, Any], source_module: Featurevi if source_module and subscription["moduleId"] == source_module.id: continue if self._should_report_diagnostic(diagnostic["level"], subscription["level"]): - subscription["handler"](diagnostic) + try: + subscription["handler"](diagnostic) + except Exception as exc: + print("[Featurevisor] Diagnostic handler failed:", exc) if self.on_diagnostic: if self._should_report_diagnostic(diagnostic["level"], self.logger.level): - self.on_diagnostic(diagnostic) + try: + self.on_diagnostic(diagnostic) + except Exception as exc: + print("[Featurevisor] Diagnostic handler failed:", exc) else: self.logger.log(diagnostic["level"], diagnostic.get("message", ""), diagnostic) diff --git a/src/featurevisor/modules.py b/src/featurevisor/modules.py index f331efe..a2e85a8 100644 --- a/src/featurevisor/modules.py +++ b/src/featurevisor/modules.py @@ -82,7 +82,23 @@ def add(self, module: dict[str, Any] | FeaturevisorModule): return None if self.module_api_factory: - module.call_setup(self.module_api_factory(module)) + try: + module.call_setup(self.module_api_factory(module)) + except Exception as exc: + if self.clear_module_diagnostic_subscriptions: + self.clear_module_diagnostic_subscriptions(module) + self._report( + { + "level": "error", + "code": "module_setup_error", + "message": "Module setup failed", + "moduleName": module.name, + "originalError": exc, + }, + None, + ) + self._close_module(module) + return None self.modules.append(module) diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..9643e47 --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from featurevisor.datafile_reader import _DatafileReader +from featurevisor.helpers import get_value_by_type +from featurevisor.logger import Logger + + +class SDKV3ConformanceTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.fixture = json.loads(Path("conformance/sdk-v3.json").read_text()) + + def test_allocation_and_typed_value_contracts(self) -> None: + self.assertEqual(self.fixture["version"], 1) + reader = _DatafileReader( + datafile={"schemaVersion": "2", "revision": "conformance", "segments": {}, "features": {}}, + logger=Logger(level="fatal"), + ) + traffic = {"allocation": self.fixture["bucketing"]["allocations"]} + + for bucket, expected in self.fixture["bucketing"]["allocationExpectations"].items(): + allocation = reader.get_matched_allocation(traffic, int(bucket)) + self.assertIsNotNone(allocation) + self.assertEqual(allocation["variation"], expected) + + for item in self.fixture["typedVariables"]: + actual = get_value_by_type(item["value"], item["type"]) + self.assertEqual(actual is not None, item["valid"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_helpers_parity.py b/tests/test_helpers_parity.py index dd4f4d7..72f1274 100644 --- a/tests/test_helpers_parity.py +++ b/tests/test_helpers_parity.py @@ -28,10 +28,15 @@ def test_should_return_array_if_the_value_is_an_array(self) -> None: self.assertEqual(get_value_by_type(["1", "2", "3"], "array"), ["1", "2", "3"]) def test_should_return_integer_if_the_value_is_an_integer(self) -> None: - self.assertEqual(get_value_by_type("1", "integer"), 1) + self.assertIsNone(get_value_by_type("1", "integer")) + self.assertIsNone(get_value_by_type(1.5, "integer")) def test_should_return_double_if_the_value_is_a_double(self) -> None: - self.assertEqual(get_value_by_type("1.1", "double"), 1.1) + self.assertIsNone(get_value_by_type("1.1", "double")) + + def test_should_return_null_for_non_boolean_values(self) -> None: + self.assertIsNone(get_value_by_type("true", "boolean")) + self.assertIsNone(get_value_by_type(1, "boolean")) def test_should_return_null_if_the_value_is_undefined(self) -> None: self.assertIsNone(get_value_by_type(None, "string")) diff --git a/tests/test_instance_parity.py b/tests/test_instance_parity.py index e5b3e84..4f81eda 100644 --- a/tests/test_instance_parity.py +++ b/tests/test_instance_parity.py @@ -7,12 +7,12 @@ sys.path.insert(0, "src") import featurevisor -from featurevisor import createInstance +from featurevisor import create_instance from featurevisor.logger import create_logger class InstanceParityTests(unittest.TestCase): def test_should_be_a_function(self) -> None: - self.assertTrue(callable(createInstance)) + self.assertTrue(callable(create_instance)) def test_root_public_surface_is_narrow(self) -> None: self.assertEqual( @@ -20,19 +20,17 @@ def test_root_public_surface_is_narrow(self) -> None: { "Featurevisor", "FeaturevisorChildInstance", - "FeaturevisorInstance", "FeaturevisorModule", "ModulesManager", "Logger", "create_instance", - "createInstance", }, ) self.assertFalse(hasattr(featurevisor, "DatafileReader")) self.assertFalse(hasattr(featurevisor, "createLogger")) def test_should_create_instance_with_datafile_content(self) -> None: - sdk = createInstance({"datafile": {"schemaVersion": "2", "revision": "1.0", "features": {}, "segments": {}}}) + sdk = create_instance({"datafile": {"schemaVersion": "2", "revision": "1.0", "features": {}, "segments": {}}}) self.assertTrue(callable(sdk.getVariation)) def test_should_configure_plain_and_and_or_bucket_by(self) -> None: @@ -43,7 +41,7 @@ def test_should_configure_plain_and_and_or_bucket_by(self) -> None: ] for _, bucket_by, context, expected_bucket in cases: captured = {"bucket": ""} - sdk = createInstance( + sdk = create_instance( { "datafile": { "schemaVersion": "2", @@ -67,7 +65,7 @@ def test_should_configure_plain_and_and_or_bucket_by(self) -> None: def test_should_intercept_before_and_after_modules(self) -> None: before = {"called": False, "featureKey": "", "variableKey": None} - sdk = createInstance( + sdk = create_instance( { "datafile": { "schemaVersion": "2", @@ -101,7 +99,7 @@ def test_should_initialize_with_sticky_features(self) -> None: }, "segments": {}, } - sdk = createInstance({"sticky": {"test": {"enabled": True, "variation": "control", "variables": {"color": "red"}}}, "logLevel": "fatal"}) + sdk = create_instance({"sticky": {"test": {"enabled": True, "variation": "control", "variables": {"color": "red"}}}, "logLevel": "fatal"}) self.assertEqual(sdk.getVariation("test", {"userId": "123"}), "control") self.assertEqual(sdk.getVariable("test", "color", {"userId": "123"}), "red") sdk.setDatafile(datafile) @@ -112,7 +110,7 @@ def test_should_initialize_with_sticky_features(self) -> None: def test_required_deprecated_and_rule_override_cases(self) -> None: logger_calls = [] - sdk = createInstance( + sdk = create_instance( { "datafile": { "schemaVersion": "2", @@ -136,7 +134,7 @@ def test_required_deprecated_and_rule_override_cases(self) -> None: def test_mutually_exclusive_variation_and_variable_cases(self) -> None: bucket = {"value": 10000} - sdk = createInstance( + sdk = create_instance( { "modules": [{"name": "unit-test", "bucketValue": lambda options, bucket=bucket: bucket["value"]}], "datafile": { @@ -198,7 +196,7 @@ def test_mutually_exclusive_variation_and_variable_cases(self) -> None: self.assertEqual(all_evaluations["test"]["variation"], "treatment") def test_variables_without_variations_rule_overrides_arrays_objects_and_individual_segments(self) -> None: - sdk = createInstance( + sdk = create_instance( { "datafile": { "schemaVersion": "2", diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 73ceb18..e0d4014 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -179,6 +179,25 @@ def test_add_module_unsubscribe_closes_module_once(self) -> None: self.assertEqual(closed, ["dynamic"]) + def test_module_setup_and_diagnostic_handler_failures_are_isolated(self) -> None: + diagnostics = [] + closed = [] + seen = [] + instance = create_instance({"logLevel": "error", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) + + def fail_setup(api): + api["onDiagnostic"](lambda diagnostic: None) + raise RuntimeError("setup failed") + + self.assertIsNone(instance.add_module({"name": "broken-setup", "setup": fail_setup, "close": lambda: closed.append("broken-setup")})) + self.assertEqual(closed, ["broken-setup"]) + self.assertTrue(any(item.get("code") == "module_setup_error" for item in diagnostics)) + + instance.add_module({"name": "broken-handler", "setup": lambda api: api["onDiagnostic"](lambda diagnostic: (_ for _ in ()).throw(RuntimeError("handler failed")), {"logLevel": "debug"})}) + instance.add_module({"name": "working-handler", "setup": lambda api: api["onDiagnostic"](lambda diagnostic: seen.append(diagnostic["code"]), {"logLevel": "debug"})}) + instance.set_context({"country": "nl"}) + self.assertIn("context_set", seen) + def test_module_close_errors_are_reported_and_do_not_stop_cleanup(self) -> None: diagnostics = [] errors = [] From 7958a98f703c311d6aa8237dee1606e70c4d74d8 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 12:34:06 +0200 Subject: [PATCH 10/14] cli alignment --- README.md | 2 ++ src/featurevisor/cli.py | 4 +++ src/featurevisor/project.py | 4 +-- src/featurevisor/tester.py | 63 ++++++++++++++++++++++++++++++------- tests/test_cli.py | 4 +++ 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 78fe243..c1db835 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,8 @@ python -m featurevisor assess-distribution These commands are intended for use from inside a Featurevisor project and rely on `npx featurevisor` being available locally. +All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behavior is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI. + ### Test Run Featurevisor test specs using the Python SDK: diff --git a/src/featurevisor/cli.py b/src/featurevisor/cli.py index c2ddf77..0dfd0b9 100644 --- a/src/featurevisor/cli.py +++ b/src/featurevisor/cli.py @@ -30,6 +30,7 @@ def build_parser() -> argparse.ArgumentParser: common.add_argument("--with-tags", "--withTags", dest="with_tags", action="store_true") common.add_argument("--schemaVersion", "--schema-version", dest="schema_version") common.add_argument("--populateUuid", action="append", default=[]) + common.add_argument("--target", action="append", default=[]) subparsers.add_parser("test", parents=[common]) subparsers.add_parser("benchmark", parents=[common]) subparsers.add_parser("assess-distribution", parents=[common]) @@ -53,6 +54,7 @@ def main(argv: list[str] | None = None) -> int: inflate=args.inflate, with_scopes=args.with_scopes, with_tags=args.with_tags, + targets=args.target, ) return 0 if ok else 1 if args.command == "benchmark": @@ -69,6 +71,7 @@ def main(argv: list[str] | None = None) -> int: inflate=args.inflate, verbose=args.verbose, quiet=args.quiet, + targets=args.target, ) if args.command == "assess-distribution": if not args.environment or not args.feature: @@ -83,6 +86,7 @@ def main(argv: list[str] | None = None) -> int: inflate=args.inflate, verbose=args.verbose, quiet=args.quiet, + targets=args.target, ) return 1 diff --git a/src/featurevisor/project.py b/src/featurevisor/project.py index cb8a054..f8d1242 100644 --- a/src/featurevisor/project.py +++ b/src/featurevisor/project.py @@ -86,7 +86,7 @@ def pretty_duration(seconds: float) -> str: return " ".join(parts) -def timed_build(project: FeaturevisorProject, *, environment: str, inflate: int | None = None) -> tuple[dict[str, Any], float]: +def timed_build(project: FeaturevisorProject, *, environment: str, inflate: int | None = None, target: str | None = None) -> tuple[dict[str, Any], float]: start = time.perf_counter() - datafile = project.build_datafile_json(environment=environment, inflate=inflate) + datafile = project.build_datafile_json(environment=environment, inflate=inflate, target=target) return datafile, time.perf_counter() - start diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index 8a14372..c6ca24e 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -178,13 +178,23 @@ def test_segment(segment: dict[str, Any], assertion_options: dict[str, Any] | No return result -def run_test_project(project_directory_path: str, *, key_pattern: str | None = None, assertion_pattern: str | None = None, verbose: bool = False, quiet: bool = False, show_datafile: bool = False, only_failures: bool = False, schema_version: str | None = None, inflate: int = 0, with_scopes: bool = False, with_tags: bool = False) -> bool: +def _resolve_targets(project: FeaturevisorProject, requested: list[str] | None) -> list[str]: + available = project.list_targets() + selected = list(dict.fromkeys(requested or [])) + unknown = next((target for target in selected if target not in available), None) + if unknown: + raise ValueError(f'Unknown target "{unknown}". Available targets: {", ".join(available) or "none"}.') + return selected + + +def run_test_project(project_directory_path: str, *, key_pattern: str | None = None, assertion_pattern: str | None = None, verbose: bool = False, quiet: bool = False, show_datafile: bool = False, only_failures: bool = False, schema_version: str | None = None, inflate: int = 0, with_scopes: bool = False, with_tags: bool = False, targets: list[str] | None = None) -> bool: project = FeaturevisorProject(project_directory_path) config = project.get_config() tests = project.list_tests(key_pattern=key_pattern, assertion_pattern=assertion_pattern) features_by_key = {item["key"]: item for item in project.list_features()} segments_by_key = {item["key"]: item for item in project.list_segments()} - targets = project.list_targets() + selected_targets = _resolve_targets(project, targets) + targets_to_build = selected_targets or project.list_targets() datafile_cache: dict[Any, dict[str, Any]] = {} start = time.perf_counter() passed_tests_count = 0 @@ -198,7 +208,7 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N for environment in environments or [None]: base_key = environment if environment is not None else False datafile_cache[base_key] = project.build_datafile_json(environment=environment, inflate=inflate or None) - for target in targets: + for target in targets_to_build: datafile_cache[_get_target_datafile_key(base_key, target)] = project.build_datafile_json( environment=environment, inflate=inflate or None, @@ -208,13 +218,19 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N passed = True for test in tests: if test.get("feature"): + assertions = [ + assertion for assertion in test["assertions"] + if not selected_targets or not assertion.get("target") or assertion.get("target") in selected_targets + ] + if not assertions: + continue feature_key = test["feature"] result = {"type": "feature", "key": feature_key, "notFound": False, "passed": True, "duration": 0, "assertions": []} feature_start = time.perf_counter() if not any(item.get("feature") == feature_key for item in tests): result["notFound"] = True result["passed"] = False - for assertion in test["assertions"]: + for assertion in assertions: assertion_start = time.perf_counter() datafile = _get_datafile_for_assertion(assertion, datafile_cache) if show_datafile: @@ -269,9 +285,17 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N return passed -def run_benchmark(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, variation: bool = False, variable: str | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False) -> int: +def run_benchmark(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, variation: bool = False, variable: str | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False, targets: list[str] | None = None) -> int: project = FeaturevisorProject(project_directory_path) - datafile, build_duration = timed_build(project, environment=environment, inflate=inflate or None) + selected_targets = _resolve_targets(project, targets) + entries = selected_targets or [None] + for target in entries: + datafile, build_duration = timed_build(project, environment=environment, inflate=inflate or None, target=target) + _run_benchmark_datafile(datafile, build_duration, environment=environment, target=target, feature=feature, context=context, n=n, variation=variation, variable=variable, verbose=verbose, quiet=quiet) + return 0 + + +def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, environment: str, target: str | None, feature: str, context: dict[str, Any] | None, n: int, variation: bool, variable: str | None, verbose: bool, quiet: bool) -> None: level = _log_level(verbose, quiet) instance = create_instance({"datafile": datafile, "logLevel": level}) context = context or {} @@ -294,7 +318,12 @@ def run_benchmark(project_directory_path: str, *, environment: str, feature: str duration = total_duration_ns / 1_000_000_000 average_duration_ns = total_duration_ns / n if n else 0 print("") - print(f'Running benchmark for feature "{feature}"...') + print("Benchmark Featurevisor feature") + print(f" Feature: {feature}") + print(f" Environment: {environment}") + if target: + print(f" Target: {target}") + print(f" Iterations: {n}") print("") print(f'Datafile build duration: {int(build_duration * 1000)}ms') print(f"Datafile size: {len(json.dumps(datafile).encode()) / 1024:.2f} kB") @@ -304,12 +333,18 @@ def run_benchmark(project_directory_path: str, *, environment: str, feature: str print(f"Minimum duration: {(min_duration_ns or 0) / 1_000_000:.6f}ms") print(f"Average duration: {average_duration_ns / 1_000_000:.6f}ms") print(f"Maximum duration: {max_duration_ns / 1_000_000:.6f}ms") - return 0 -def run_assess_distribution(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, populate_uuid: list[str] | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False) -> int: +def run_assess_distribution(project_directory_path: str, *, environment: str, feature: str, context: dict[str, Any] | None = None, n: int = 1000, populate_uuid: list[str] | None = None, schema_version: str | None = None, inflate: int = 0, verbose: bool = False, quiet: bool = False, targets: list[str] | None = None) -> int: project = FeaturevisorProject(project_directory_path) - datafile = project.build_datafile_json(environment=environment, inflate=inflate or None) + selected_targets = _resolve_targets(project, targets) + for target in selected_targets or [None]: + datafile = project.build_datafile_json(environment=environment, inflate=inflate or None, target=target) + _run_assess_datafile(datafile, environment=environment, target=target, feature=feature, context=context, n=n, populate_uuid=populate_uuid, verbose=verbose, quiet=quiet) + return 0 + + +def _run_assess_datafile(datafile: dict[str, Any], *, environment: str, target: str | None, feature: str, context: dict[str, Any] | None, n: int, populate_uuid: list[str] | None, verbose: bool, quiet: bool) -> None: instance = create_instance({"datafile": datafile, "logLevel": _log_level(verbose, quiet)}) context = context or {} populate_uuid = populate_uuid or [] @@ -317,7 +352,12 @@ def run_assess_distribution(project_directory_path: str, *, environment: str, fe has_variations = bool(feature_definition.get("variations")) flag_counts = {"enabled": 0, "disabled": 0} variation_counts: dict[str, int] = {} - print(f"\nAssessing distribution for feature: {feature} ...") + print("\nAssess Featurevisor distribution") + print(f" Feature: {feature}") + print(f" Environment: {environment}") + if target: + print(f" Target: {target}") + print(f" Iterations: {n}") print(f"Against context: {json.dumps(context)}") print(f"Running {n} times...") for _ in range(n): @@ -337,4 +377,3 @@ def run_assess_distribution(project_directory_path: str, *, environment: str, fe print("\n\nVariation evaluations:") for key, count in sorted(variation_counts.items(), key=lambda item: item[1], reverse=True): print(f" - {key}: {count} {(count / n) * 100:.2f}%") - return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index b84e087..1142216 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,6 +33,10 @@ def test_parse_benchmark_flags(self) -> None: self.assertEqual(args.n, 20) self.assertTrue(args.variation) + def test_parse_repeated_targets(self) -> None: + args = build_parser().parse_args(["benchmark", "--target=web", "--target=mobile"]) + self.assertEqual(args.target, ["web", "mobile"]) + def test_main_returns_non_zero_on_failed_tests(self) -> None: with patch("featurevisor.cli.run_test_project", return_value=False): self.assertEqual(main(["test"]), 1) From fc33f51e6515e73ce9dfcece1d690b8838eb02bd Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 18:30:30 +0200 Subject: [PATCH 11/14] alignment --- README.md | 81 ++++++++++++++-------------- src/featurevisor/__init__.py | 9 ++-- src/featurevisor/child.py | 4 +- src/featurevisor/datafile_reader.py | 4 +- src/featurevisor/instance.py | 61 ++++++++++++++++++--- src/featurevisor/logger.py | 15 +++--- src/featurevisor/tester.py | 12 ++--- tests/test_conditions_parity.py | 4 +- tests/test_conformance.py | 4 +- tests/test_datafile_reader_parity.py | 4 +- tests/test_instance_parity.py | 34 ++++++------ tests/test_sdk.py | 38 ++++++------- 12 files changed, 154 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index c1db835..caa564a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. ## Table of contents - [Installation](#installation) +- [Public API](#public-api) - [Initialization](#initialization) - [Evaluation types](#evaluation-types) - [Context](#context) @@ -30,16 +31,14 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) -- [Logging](#logging) +- [Diagnostics](#diagnostics) - [Levels](#levels) - - [Customizing levels](#customizing-levels) - [Handler](#handler) - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) - [`error`](#error) -- [Diagnostics](#diagnostics) - [Modules](#modules) - [Defining a module](#defining-a-module) - [Registering modules](#registering-modules) @@ -61,6 +60,20 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. pip install featurevisor ``` +## Public API + +The main runtime API is `create_featurevisor()`: + +```python +from featurevisor import Featurevisor, create_featurevisor + +f: Featurevisor = create_featurevisor({ + "datafile": datafile_content, +}) +``` + +Most applications only need `create_featurevisor` and the `Featurevisor` instance type. Public extension and observability APIs include `FeaturevisorModule`, diagnostics, events, and the datafile dictionaries accepted by the factory. + ## Initialization Initialize the SDK with Featurevisor datafile content: @@ -69,14 +82,14 @@ Initialize the SDK with Featurevisor datafile content: from urllib.request import urlopen import json -from featurevisor import create_instance +from featurevisor import create_featurevisor datafile_url = "https://cdn.yoursite.com/datafile.json" with urlopen(datafile_url) as response: datafile_content = json.load(response) -f = create_instance({ +f = create_featurevisor({ "datafile": datafile_content, }) ``` @@ -105,7 +118,7 @@ context = { You can provide context at initialization: ```python -f = create_instance({ +f = create_featurevisor({ "context": { "deviceId": "123", "country": "nl", @@ -202,7 +215,7 @@ You can pin feature evaluations with sticky values: Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `spawn(context, {"sticky": ...})` when a child needs its own sticky state. ```python -f = create_instance({ +f = create_featurevisor({ "sticky": { "myFeatureKey": { "enabled": True, @@ -264,9 +277,9 @@ This pairs well with [targets](https://featurevisor.com/docs/targets/), where ea from urllib.request import urlopen import json -from featurevisor import create_instance +from featurevisor import create_featurevisor -f = create_instance({}) +f = create_featurevisor({}) def load_datafile(target): url = f"https://cdn.yoursite.com/production/featurevisor-{target}.json" @@ -304,57 +317,41 @@ def update_datafile(): update_datafile() ``` -## Logging +## Diagnostics -By default, Featurevisor SDKs report diagnostics/logs for `info` level and above. +By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix. ### Levels -Supported log levels: - -- `fatal` -- `error` -- `warn` -- `info` -- `debug` +Available diagnostic levels are `fatal`, `error`, `warn`, `info`, and `debug`. -### Customizing levels +Set the level during initialization or update it afterwards: ```python -from featurevisor import create_instance - -f = create_instance({ - "datafile": datafile_content, - "logLevel": "debug", -}) -``` - -You can also set the log level from the SDK instance afterwards: - -```python -f.set_log_level("debug") +f = create_featurevisor({"logLevel": "debug"}) +f.set_log_level("info") ``` ### Handler -You can also provide a custom logger handler via `create_logger`. - -## Diagnostics - -Diagnostics provide structured SDK and module events for observability: +Use `onDiagnostic` to send structured diagnostics to your observability system: ```python -f = create_instance({ - "onDiagnostic": lambda diagnostic: print(diagnostic), +f = create_featurevisor({ + "logLevel": "info", + "onDiagnostic": lambda diagnostic: print( + diagnostic["level"], + diagnostic["code"], + diagnostic["message"], + ), }) ``` -If `onDiagnostic` is not provided, diagnostics are sent to the SDK logger. - -Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` dictionary. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. +Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` dictionary. Optional `module`, `moduleName`, and `originalError` fields describe provenance. Evaluation metadata belongs in `details`. Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -466,7 +463,7 @@ Modules can be registered at initialization or afterwards: ```python -f = create_instance({ +f = create_featurevisor({ "modules": [my_module], }) diff --git a/src/featurevisor/__init__.py b/src/featurevisor/__init__.py index ef5b6e3..598867b 100644 --- a/src/featurevisor/__init__.py +++ b/src/featurevisor/__init__.py @@ -1,13 +1,10 @@ from .child import FeaturevisorChildInstance -from .modules import FeaturevisorModule, ModulesManager -from .instance import FeaturevisorInstance as Featurevisor, create_instance -from .logger import Logger +from .modules import FeaturevisorModule +from .instance import Featurevisor, create_featurevisor __all__ = [ "Featurevisor", "FeaturevisorChildInstance", "FeaturevisorModule", - "ModulesManager", - "Logger", - "create_instance", + "create_featurevisor", ] diff --git a/src/featurevisor/child.py b/src/featurevisor/child.py index ea2bcea..9652e72 100644 --- a/src/featurevisor/child.py +++ b/src/featurevisor/child.py @@ -6,11 +6,11 @@ from .events import get_params_for_sticky_set_event if TYPE_CHECKING: - from .instance import FeaturevisorInstance + from .instance import Featurevisor class FeaturevisorChildInstance: - def __init__(self, *, parent: "FeaturevisorInstance", context: dict[str, Any], sticky: dict[str, Any] | None = None) -> None: + def __init__(self, *, parent: "Featurevisor", context: dict[str, Any], sticky: dict[str, Any] | None = None) -> None: self.parent = parent self.context = context self.sticky = sticky or {} diff --git a/src/featurevisor/datafile_reader.py b/src/featurevisor/datafile_reader.py index 846f1cc..99b6764 100644 --- a/src/featurevisor/datafile_reader.py +++ b/src/featurevisor/datafile_reader.py @@ -5,12 +5,12 @@ from typing import Any from .conditions import condition_is_matched -from .logger import Logger +from .logger import _Logger from .types import Context, DatafileContent, Feature, Force, Segment, Traffic class _DatafileReader: - def __init__(self, *, datafile: DatafileContent, logger: Logger) -> None: + def __init__(self, *, datafile: DatafileContent, logger: _Logger) -> None: self.logger = logger self.schema_version = datafile["schemaVersion"] self.revision = datafile["revision"] diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index 470061c..d2ab6a5 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -10,17 +10,20 @@ from .evaluate import evaluate_with_modules from .events import get_params_for_datafile_set_event, get_params_for_sticky_set_event from .helpers import get_value_by_type -from .logger import Logger, create_logger +from .logger import _Logger, _create_logger, _default_log_handler from .modules import FeaturevisorModule, ModulesManager empty_datafile = {"schemaVersion": "2", "revision": "unknown", "segments": {}, "features": {}} -class FeaturevisorInstance: +class Featurevisor: def __init__(self, options: dict[str, Any] | None = None) -> None: options = options or {} self.context = options.get("context") or {} - self.logger: Logger = options.get("logger") or create_logger({"level": options.get("logLevel") or Logger.default_level}) + self.logger: _Logger = _create_logger({ + "level": options.get("logLevel") or _Logger.default_level, + "handler": self._handle_internal_log, + }) self.on_diagnostic = options.get("onDiagnostic") or options.get("on_diagnostic") self.emitter = Emitter() self.sticky = options.get("sticky") @@ -48,6 +51,28 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: def set_log_level(self, level: str) -> None: self.logger.set_level(level) + def _handle_internal_log(self, level: str, message: str, details: dict[str, Any] | None = None) -> None: + details = dict(details or {}) + code = str(details.get("reason") or message) + if message == "feature is deprecated": + code = "deprecated_feature" + elif message == "variable is deprecated": + code = "deprecated_variable" + elif message == "feature not found": + code = "feature_not_found" + elif message == "variable schema not found": + code = "variable_not_found" + elif message == "no variations": + code = "no_variations" + elif message == "invalid bucketBy": + code = "invalid_bucket_by" + self.report_diagnostic({ + "level": level, + "code": code, + "message": message, + "details": details, + }) + def set_datafile(self, datafile, replace: bool = False) -> None: if self.closed: return @@ -75,6 +100,21 @@ def set_sticky(self, sticky: dict[str, Any], replace: bool = False) -> None: def get_revision(self) -> str: return self.datafile_reader.get_revision() + def get_schema_version(self) -> str: + return self.datafile_reader.get_schema_version() + + def get_segment(self, segment_key: str): + return self.datafile_reader.get_segment(segment_key) + + def get_feature_keys(self) -> list[str]: + return self.datafile_reader.get_feature_keys() + + def get_variable_keys(self, feature_key: str) -> list[str]: + return self.datafile_reader.get_variable_keys(feature_key) + + def has_variations(self, feature_key: str) -> bool: + return self.datafile_reader.has_variations(feature_key) + def get_feature(self, feature_key: str): return self.datafile_reader.get_feature(feature_key) @@ -268,15 +308,15 @@ def report_diagnostic(self, diagnostic: dict[str, Any], source_module: Featurevi self.on_diagnostic(diagnostic) except Exception as exc: print("[Featurevisor] Diagnostic handler failed:", exc) - else: - self.logger.log(diagnostic["level"], diagnostic.get("message", ""), diagnostic) + elif self._should_report_diagnostic(diagnostic["level"], self.logger.level): + _default_log_handler(diagnostic["level"], diagnostic.get("message", ""), diagnostic) if diagnostic["level"] in {"error", "fatal"}: self.emitter.trigger("error", diagnostic) def _should_report_diagnostic(self, diagnostic_level: str, subscriber_level: str) -> bool: try: - return Logger.all_levels.index(subscriber_level) >= Logger.all_levels.index(diagnostic_level) + return _Logger.all_levels.index(subscriber_level) >= _Logger.all_levels.index(diagnostic_level) except ValueError: return False @@ -293,6 +333,11 @@ def _merge_datafiles(self, previous: dict[str, Any], incoming: dict[str, Any]) - setDatafile = set_datafile setSticky = set_sticky getRevision = get_revision + getSchemaVersion = get_schema_version + getSegment = get_segment + getFeatureKeys = get_feature_keys + getVariableKeys = get_variable_keys + hasVariations = has_variations getFeature = get_feature addModule = add_module removeModule = remove_module @@ -314,5 +359,5 @@ def _merge_datafiles(self, previous: dict[str, Any], incoming: dict[str, Any]) - getAllEvaluations = get_all_evaluations -def create_instance(options: dict[str, Any] | None = None) -> FeaturevisorInstance: - return FeaturevisorInstance(options or {}) +def create_featurevisor(options: dict[str, Any] | None = None) -> Featurevisor: + return Featurevisor(options or {}) diff --git a/src/featurevisor/logger.py b/src/featurevisor/logger.py index 305cb3d..73e30b5 100644 --- a/src/featurevisor/logger.py +++ b/src/featurevisor/logger.py @@ -4,22 +4,22 @@ from .types import LogLevel -LogHandler = Callable[[LogLevel, str, dict[str, Any] | None], None] +_LogHandler = Callable[[LogLevel, str, dict[str, Any] | None], None] loggerPrefix = "[Featurevisor]" -def default_log_handler(level: LogLevel, message: str, details: dict[str, Any] | None = None) -> None: +def _default_log_handler(level: LogLevel, message: str, details: dict[str, Any] | None = None) -> None: print(loggerPrefix, message, details or {}) -class Logger: +class _Logger: all_levels: list[LogLevel] = ["fatal", "error", "warn", "info", "debug"] default_level: LogLevel = "info" - def __init__(self, level: LogLevel | None = None, handler: LogHandler | None = None) -> None: + def __init__(self, level: LogLevel | None = None, handler: _LogHandler | None = None) -> None: self.level = level or self.default_level - self.handle = handler or default_log_handler + self.handle = handler or _default_log_handler def set_level(self, level: LogLevel) -> None: self.level = level @@ -44,7 +44,6 @@ def error(self, message: str, details: dict[str, Any] | None = None) -> None: setLevel = set_level -def create_logger(options: dict[str, Any] | None = None) -> Logger: +def _create_logger(options: dict[str, Any] | None = None) -> _Logger: options = options or {} - return Logger(level=options.get("level"), handler=options.get("handler")) - + return _Logger(level=options.get("level"), handler=options.get("handler")) diff --git a/src/featurevisor/tester.py b/src/featurevisor/tester.py index c6ca24e..a97555e 100644 --- a/src/featurevisor/tester.py +++ b/src/featurevisor/tester.py @@ -6,8 +6,8 @@ from typing import Any from .datafile_reader import _DatafileReader -from .instance import create_instance -from .logger import create_logger +from .instance import create_featurevisor +from .logger import _create_logger from .project import FeaturevisorProject, pretty_duration, timed_build @@ -160,7 +160,7 @@ def _assert_feature(sdk, feature_key: str, assertion: dict[str, Any], datafile: def test_segment(segment: dict[str, Any], assertion_options: dict[str, Any] | None = None) -> dict[str, Any]: options = assertion_options or {} - logger = create_logger({"level": _log_level(options.get("verbose", False), options.get("quiet", False))}) + logger = _create_logger({"level": _log_level(options.get("verbose", False), options.get("quiet", False))}) reader = _DatafileReader(datafile={"schemaVersion": "2", "revision": "tester", "segments": {}, "features": {}}, logger=logger) result = {"type": "segment", "key": segment["segment"], "notFound": False, "passed": True, "duration": 0, "assertions": []} start = time.perf_counter() @@ -235,7 +235,7 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N datafile = _get_datafile_for_assertion(assertion, datafile_cache) if show_datafile: print(json.dumps(datafile, indent=2)) - sdk = create_instance({ + sdk = create_featurevisor({ "datafile": datafile, "sticky": assertion.get("sticky", {}), "modules": [ @@ -297,7 +297,7 @@ def run_benchmark(project_directory_path: str, *, environment: str, feature: str def _run_benchmark_datafile(datafile: dict[str, Any], build_duration: float, *, environment: str, target: str | None, feature: str, context: dict[str, Any] | None, n: int, variation: bool, variable: str | None, verbose: bool, quiet: bool) -> None: level = _log_level(verbose, quiet) - instance = create_instance({"datafile": datafile, "logLevel": level}) + instance = create_featurevisor({"datafile": datafile, "logLevel": level}) context = context or {} total_duration_ns = 0 min_duration_ns: int | None = None @@ -345,7 +345,7 @@ def run_assess_distribution(project_directory_path: str, *, environment: str, fe def _run_assess_datafile(datafile: dict[str, Any], *, environment: str, target: str | None, feature: str, context: dict[str, Any] | None, n: int, populate_uuid: list[str] | None, verbose: bool, quiet: bool) -> None: - instance = create_instance({"datafile": datafile, "logLevel": _log_level(verbose, quiet)}) + instance = create_featurevisor({"datafile": datafile, "logLevel": _log_level(verbose, quiet)}) context = context or {} populate_uuid = populate_uuid or [] feature_definition = instance.get_feature(feature) or {} diff --git a/tests/test_conditions_parity.py b/tests/test_conditions_parity.py index 104ecd6..7e5c4ba 100644 --- a/tests/test_conditions_parity.py +++ b/tests/test_conditions_parity.py @@ -7,7 +7,7 @@ sys.path.insert(0, "src") from featurevisor.datafile_reader import _DatafileReader -from featurevisor.logger import create_logger +from featurevisor.logger import _create_logger class ConditionsParityTests(unittest.TestCase): @@ -15,7 +15,7 @@ class ConditionsParityTests(unittest.TestCase): def setUpClass(cls) -> None: cls.reader = _DatafileReader( datafile={"schemaVersion": "2.0", "revision": "1", "segments": {}, "features": {}}, - logger=create_logger(), + logger=_create_logger(), ) def test_should_be_a_function(self) -> None: diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 9643e47..d424a5c 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -6,7 +6,7 @@ from featurevisor.datafile_reader import _DatafileReader from featurevisor.helpers import get_value_by_type -from featurevisor.logger import Logger +from featurevisor.logger import _Logger class SDKV3ConformanceTests(unittest.TestCase): @@ -18,7 +18,7 @@ def test_allocation_and_typed_value_contracts(self) -> None: self.assertEqual(self.fixture["version"], 1) reader = _DatafileReader( datafile={"schemaVersion": "2", "revision": "conformance", "segments": {}, "features": {}}, - logger=Logger(level="fatal"), + logger=_Logger(level="fatal"), ) traffic = {"allocation": self.fixture["bucketing"]["allocations"]} diff --git a/tests/test_datafile_reader_parity.py b/tests/test_datafile_reader_parity.py index a4abc37..ab7a76e 100644 --- a/tests/test_datafile_reader_parity.py +++ b/tests/test_datafile_reader_parity.py @@ -6,13 +6,13 @@ sys.path.insert(0, "src") from featurevisor.datafile_reader import _DatafileReader -from featurevisor.logger import create_logger +from featurevisor.logger import _create_logger class DatafileReaderParityTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.logger = create_logger() + cls.logger = _create_logger() def test_should_be_a_function(self) -> None: self.assertTrue(callable(_DatafileReader)) diff --git a/tests/test_instance_parity.py b/tests/test_instance_parity.py index 4f81eda..211e0dd 100644 --- a/tests/test_instance_parity.py +++ b/tests/test_instance_parity.py @@ -7,12 +7,12 @@ sys.path.insert(0, "src") import featurevisor -from featurevisor import create_instance -from featurevisor.logger import create_logger +from featurevisor import create_featurevisor class InstanceParityTests(unittest.TestCase): def test_should_be_a_function(self) -> None: - self.assertTrue(callable(create_instance)) + self.assertTrue(callable(create_featurevisor)) + self.assertFalse(hasattr(featurevisor, "create_instance")) def test_root_public_surface_is_narrow(self) -> None: self.assertEqual( @@ -21,16 +21,16 @@ def test_root_public_surface_is_narrow(self) -> None: "Featurevisor", "FeaturevisorChildInstance", "FeaturevisorModule", - "ModulesManager", - "Logger", - "create_instance", + "create_featurevisor", }, ) self.assertFalse(hasattr(featurevisor, "DatafileReader")) self.assertFalse(hasattr(featurevisor, "createLogger")) - def test_should_create_instance_with_datafile_content(self) -> None: - sdk = create_instance({"datafile": {"schemaVersion": "2", "revision": "1.0", "features": {}, "segments": {}}}) + def test_should_create_featurevisor_with_datafile_content(self) -> None: + sdk = create_featurevisor({"datafile": {"schemaVersion": "2", "revision": "1.0", "features": {}, "segments": {}}}) + self.assertEqual(sdk.get_schema_version(), "2") + self.assertEqual(sdk.get_feature_keys(), []) self.assertTrue(callable(sdk.getVariation)) def test_should_configure_plain_and_and_or_bucket_by(self) -> None: @@ -41,7 +41,7 @@ def test_should_configure_plain_and_and_or_bucket_by(self) -> None: ] for _, bucket_by, context, expected_bucket in cases: captured = {"bucket": ""} - sdk = create_instance( + sdk = create_featurevisor( { "datafile": { "schemaVersion": "2", @@ -65,7 +65,7 @@ def test_should_configure_plain_and_and_or_bucket_by(self) -> None: def test_should_intercept_before_and_after_modules(self) -> None: before = {"called": False, "featureKey": "", "variableKey": None} - sdk = create_instance( + sdk = create_featurevisor( { "datafile": { "schemaVersion": "2", @@ -99,7 +99,7 @@ def test_should_initialize_with_sticky_features(self) -> None: }, "segments": {}, } - sdk = create_instance({"sticky": {"test": {"enabled": True, "variation": "control", "variables": {"color": "red"}}}, "logLevel": "fatal"}) + sdk = create_featurevisor({"sticky": {"test": {"enabled": True, "variation": "control", "variables": {"color": "red"}}}, "logLevel": "fatal"}) self.assertEqual(sdk.getVariation("test", {"userId": "123"}), "control") self.assertEqual(sdk.getVariable("test", "color", {"userId": "123"}), "red") sdk.setDatafile(datafile) @@ -109,8 +109,8 @@ def test_should_initialize_with_sticky_features(self) -> None: self.assertEqual(sdk.getVariation("test", {"userId": "123"}), "treatment") def test_required_deprecated_and_rule_override_cases(self) -> None: - logger_calls = [] - sdk = create_instance( + diagnostics = [] + sdk = create_featurevisor( { "datafile": { "schemaVersion": "2", @@ -123,18 +123,18 @@ def test_required_deprecated_and_rule_override_cases(self) -> None: }, "segments": {"netherlands": {"key": "netherlands", "conditions": '[{"attribute":"country","operator":"equals","value":"nl"}]'}}, }, - "logger": create_logger({"handler": lambda level, message, details=None: logger_calls.append((level, message))}), + "onDiagnostic": diagnostics.append, } ) self.assertTrue(sdk.isEnabled("myKey")) self.assertEqual(sdk.getVariation("deprecatedTest", {"userId": "123"}), "control") - self.assertTrue(any(level == "warn" and "deprecated" in message for level, message in logger_calls)) + self.assertTrue(any(diagnostic["code"] == "deprecated_feature" for diagnostic in diagnostics)) self.assertTrue(sdk.isEnabled("flagOverride", {"userId": "user-123", "country": "de"})) self.assertFalse(sdk.isEnabled("flagOverride", {"userId": "user-123", "country": "nl"})) def test_mutually_exclusive_variation_and_variable_cases(self) -> None: bucket = {"value": 10000} - sdk = create_instance( + sdk = create_featurevisor( { "modules": [{"name": "unit-test", "bucketValue": lambda options, bucket=bucket: bucket["value"]}], "datafile": { @@ -196,7 +196,7 @@ def test_mutually_exclusive_variation_and_variable_cases(self) -> None: self.assertEqual(all_evaluations["test"]["variation"], "treatment") def test_variables_without_variations_rule_overrides_arrays_objects_and_individual_segments(self) -> None: - sdk = create_instance( + sdk = create_featurevisor( { "datafile": { "schemaVersion": "2", diff --git a/tests/test_sdk.py b/tests/test_sdk.py index e0d4014..2fc3767 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -7,14 +7,14 @@ sys.path.insert(0, "src") -from featurevisor import FeaturevisorChildInstance, create_instance +from featurevisor import FeaturevisorChildInstance, create_featurevisor from featurevisor.bucketer import MAX_BUCKETED_NUMBER, get_bucket_key, get_bucketed_number from featurevisor.compare_versions import compare_versions from featurevisor.conditions import condition_is_matched from featurevisor.datafile_reader import _DatafileReader from featurevisor.events import get_params_for_datafile_set_event, get_params_for_sticky_set_event from featurevisor.emitter import Emitter -from featurevisor.logger import create_logger +from featurevisor.logger import _create_logger class SDKTests(unittest.TestCase): @@ -41,7 +41,7 @@ def test_condition_exists_and_not_exists(self) -> None: self.assertTrue(condition_is_matched({"attribute": "country", "operator": "notExists"}, {}, get_regex)) def test_bucket_key_keeps_none_values(self) -> None: - logger = create_logger({"level": "fatal"}) + logger = _create_logger({"level": "fatal"}) bucket_key = get_bucket_key(featureKey="my_feature", bucketBy="userId", context={"userId": None}, logger=logger) self.assertEqual(bucket_key, "None.my_feature") @@ -51,7 +51,7 @@ def test_bucketed_number_range(self) -> None: self.assertLess(value, MAX_BUCKETED_NUMBER) def test_bucket_key_variants(self) -> None: - logger = create_logger({"level": "fatal"}) + logger = _create_logger({"level": "fatal"}) self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy="userId", context={"userId": "123"}, logger=logger), "123.test-feature") self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy=["organizationId", "user.id"], context={"organizationId": "123", "user": {"id": "234"}}, logger=logger), "123.234.test-feature") self.assertEqual(get_bucket_key(featureKey="test-feature", bucketBy={"or": ["userId", "deviceId"]}, context={"deviceId": "deviceIdHere"}, logger=logger), "deviceIdHere.test-feature") @@ -69,13 +69,13 @@ def test_instance_basic_flow(self) -> None: } }, } - instance = create_instance({"datafile": datafile, "context": {"userId": "123"}}) + instance = create_featurevisor({"datafile": datafile, "context": {"userId": "123"}}) self.assertTrue(instance.is_enabled("my_feature")) self.assertEqual(instance.get_variable("my_feature", "title"), "Hello") self.assertEqual(instance.get_all_evaluations()["my_feature"]["variables"]["title"], "Hello") def test_set_datafile_merges_by_default_and_replace_opt_in(self) -> None: - instance = create_instance( + instance = create_featurevisor( { "datafile": { "schemaVersion": "2", @@ -119,7 +119,7 @@ def test_set_datafile_merges_by_default_and_replace_opt_in(self) -> None: def test_datafile_set_event_includes_replaced(self) -> None: events = [] - instance = create_instance({"logLevel": "fatal"}) + instance = create_featurevisor({"logLevel": "fatal"}) instance.on("datafile_set", lambda event: events.append(event)) instance.set_datafile({"schemaVersion": "2", "revision": "1", "segments": {}, "features": {}}) @@ -129,7 +129,7 @@ def test_datafile_set_event_includes_replaced(self) -> None: def test_lifecycle_mutations_report_diagnostics(self) -> None: diagnostics = [] - instance = create_instance({"logLevel": "debug", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) + instance = create_featurevisor({"logLevel": "debug", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) instance.set_datafile({"schemaVersion": "2", "revision": "1", "segments": {}, "features": {}}) instance.set_sticky({"test": {"enabled": True}}) @@ -145,7 +145,7 @@ def test_module_lifecycle_duplicate_diagnostics_and_close(self) -> None: errors = [] setup_revision = {} closed = [] - instance = create_instance( + instance = create_featurevisor( { "logLevel": "error", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic), @@ -170,7 +170,7 @@ def test_module_lifecycle_duplicate_diagnostics_and_close(self) -> None: def test_add_module_unsubscribe_closes_module_once(self) -> None: closed = [] - instance = create_instance({"logLevel": "fatal"}) + instance = create_featurevisor({"logLevel": "fatal"}) unsubscribe = instance.add_module({"name": "dynamic", "close": lambda: closed.append("dynamic")}) @@ -183,7 +183,7 @@ def test_module_setup_and_diagnostic_handler_failures_are_isolated(self) -> None diagnostics = [] closed = [] seen = [] - instance = create_instance({"logLevel": "error", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) + instance = create_featurevisor({"logLevel": "error", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) def fail_setup(api): api["onDiagnostic"](lambda diagnostic: None) @@ -207,7 +207,7 @@ def fail_close() -> None: closed.append("first") raise RuntimeError("first close failed") - instance = create_instance( + instance = create_featurevisor( { "logLevel": "error", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic), @@ -235,7 +235,7 @@ def fail_close() -> None: def test_module_unsubscribe_reports_close_errors_once(self) -> None: diagnostics = [] - instance = create_instance({"logLevel": "error", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) + instance = create_featurevisor({"logLevel": "error", "onDiagnostic": lambda diagnostic: diagnostics.append(diagnostic)}) def fail_close() -> None: raise RuntimeError("dynamic close failed") @@ -258,7 +258,7 @@ def test_module_diagnostic_subscribe_report_and_remove_cleanup(self) -> None: listener_diagnostics = [] reporter_api = {} listener_closed = [] - instance = create_instance( + instance = create_featurevisor( { "onDiagnostic": lambda diagnostic: instance_diagnostics.append(diagnostic), "modules": [ @@ -293,7 +293,7 @@ def test_datafile_reader_parses_stringified_conditions(self) -> None: "segments": {"eu": {"conditions": '[{"attribute":"country","operator":"equals","value":"nl"}]'}}, "features": {}, }, - logger=create_logger({"level": "fatal"}), + logger=_create_logger({"level": "fatal"}), ) segment = reader.get_segment("eu") self.assertTrue(reader.segment_is_matched(segment, {"country": "nl"})) @@ -303,7 +303,7 @@ def test_events_helpers(self) -> None: get_params_for_sticky_set_event({"feature1": {"enabled": True}}, {"feature2": {"enabled": True}}, True), {"features": ["feature1", "feature2"], "replaced": True}, ) - logger = create_logger({"level": "fatal"}) + logger = _create_logger({"level": "fatal"}) previous = _DatafileReader(datafile={"schemaVersion": "2", "revision": "1", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash1", "traffic": []}}}, logger=logger) current = _DatafileReader(datafile={"schemaVersion": "2", "revision": "2", "segments": {}, "features": {"feature1": {"bucketBy": "userId", "hash": "hash2", "traffic": []}, "feature2": {"bucketBy": "userId", "hash": "hash3", "traffic": []}}}, logger=logger) self.assertEqual( @@ -345,7 +345,7 @@ def second(_details: dict) -> None: def test_logger_handler_and_filtering(self) -> None: calls = [] - logger = create_logger({"level": "warn", "handler": lambda level, message, details=None: calls.append((level, message, details))}) + logger = _create_logger({"level": "warn", "handler": lambda level, message, details=None: calls.append((level, message, details))}) logger.info("nope") logger.warn("yes") logger.error("yep") @@ -370,7 +370,7 @@ def test_child_instance_flow(self) -> None: } }, } - instance = create_instance({"datafile": datafile, "context": {"appVersion": "1.0.0"}, "logLevel": "fatal"}) + instance = create_featurevisor({"datafile": datafile, "context": {"appVersion": "1.0.0"}, "logLevel": "fatal"}) child = instance.spawn({"userId": "123", "country": "be"}) changes = [] unsubscribe = child.on("context_set", lambda details: changes.append(details)) @@ -396,7 +396,7 @@ def test_default_variable_json_parsing(self) -> None: } }, } - instance = create_instance({"datafile": datafile, "context": {"userId": "123"}, "logLevel": "fatal"}) + instance = create_featurevisor({"datafile": datafile, "context": {"userId": "123"}, "logLevel": "fatal"}) self.assertEqual(instance.get_variable("test", "config"), {"enabled": True}) From c47da8b518c125b9b5ce7dc50f80fa3c464a20f4 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:40:04 +0200 Subject: [PATCH 12/14] alignment --- README.md | 4 ++-- src/featurevisor/instance.py | 8 ++++---- src/featurevisor/modules.py | 2 +- tests/test_sdk.py | 8 ++++++-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index caa564a..bc1e151 100644 --- a/README.md +++ b/README.md @@ -389,11 +389,11 @@ unsubscribe() ### `error` ```python -unsubscribe = f.on("error", lambda diagnostic: print(diagnostic["message"])) +unsubscribe = f.on("error", lambda event: print(event["diagnostic"]["message"])) unsubscribe() ``` -The `error` event is emitted for diagnostics reported with `level` set to `error` or `fatal`. +The `error` event is emitted for diagnostics reported with `level` set to `error`. ## Evaluation details diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index d2ab6a5..8f3e83c 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -44,7 +44,7 @@ def __init__(self, options: dict[str, Any] | None = None) -> None: { "level": "info", "code": "sdk_initialized", - "message": "Featurevisor SDK initialized", + "message": "SDK initialized", } ) @@ -83,8 +83,8 @@ def set_datafile(self, datafile, replace: bool = False) -> None: new_reader = _DatafileReader(datafile=next_datafile, logger=self.logger) details = get_params_for_datafile_set_event(self.datafile_reader, new_reader, replace) self.datafile_reader = new_reader - self.emitter.trigger("datafile_set", details) self.report_diagnostic({"level": "info", "code": "datafile_set", "message": "Datafile set", "details": details}) + self.emitter.trigger("datafile_set", details) except Exception as exc: self.report_diagnostic({"level": "error", "code": "invalid_datafile", "message": "Could not parse datafile", "originalError": exc}) @@ -311,8 +311,8 @@ def report_diagnostic(self, diagnostic: dict[str, Any], source_module: Featurevi elif self._should_report_diagnostic(diagnostic["level"], self.logger.level): _default_log_handler(diagnostic["level"], diagnostic.get("message", ""), diagnostic) - if diagnostic["level"] in {"error", "fatal"}: - self.emitter.trigger("error", diagnostic) + if diagnostic["level"] == "error": + self.emitter.trigger("error", {"diagnostic": diagnostic}) def _should_report_diagnostic(self, diagnostic_level: str, subscriber_level: str) -> bool: try: diff --git a/src/featurevisor/modules.py b/src/featurevisor/modules.py index a2e85a8..59ea3d6 100644 --- a/src/featurevisor/modules.py +++ b/src/featurevisor/modules.py @@ -77,7 +77,7 @@ def add(self, module: dict[str, Any] | FeaturevisorModule): "message": "Duplicate module name", "moduleName": module.name, }, - module, + None, ) return None diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 2fc3767..4ae9a16 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -163,7 +163,7 @@ def test_module_lifecycle_duplicate_diagnostics_and_close(self) -> None: self.assertEqual(setup_revision["revision"], "unknown") self.assertIsNone(instance.add_module({"name": "lifecycle"})) self.assertEqual(diagnostics[-1]["code"], "duplicate_module") - self.assertEqual(errors[-1]["code"], "duplicate_module") + self.assertEqual(errors[-1]["diagnostic"]["code"], "duplicate_module") instance.close() self.assertEqual(closed, ["lifecycle"]) @@ -231,7 +231,11 @@ def fail_close() -> None: for diagnostic in diagnostics ) ) - self.assertTrue(any(event.get("code") == "module_close_error" and event.get("moduleName") == "first" for event in errors)) + self.assertTrue(any( + event.get("diagnostic", {}).get("code") == "module_close_error" + and event.get("diagnostic", {}).get("moduleName") == "first" + for event in errors + )) def test_module_unsubscribe_reports_close_errors_once(self) -> None: diagnostics = [] From 9dd5dd947dfe8eededfb454842df75a25943a21a Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:49:34 +0200 Subject: [PATCH 13/14] alignment --- src/featurevisor/evaluate.py | 7 +++++-- src/featurevisor/instance.py | 10 +++++++++- tests/test_conformance.py | 7 +++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/featurevisor/evaluate.py b/src/featurevisor/evaluate.py index 6f9dd2c..b7c52a7 100644 --- a/src/featurevisor/evaluate.py +++ b/src/featurevisor/evaluate.py @@ -1,13 +1,13 @@ from __future__ import annotations import json -from enum import StrEnum +from enum import Enum from typing import Any from .bucketer import get_bucket_key, get_bucketed_number -class EvaluationReason(StrEnum): +class EvaluationReason(str, Enum): FEATURE_NOT_FOUND = "feature_not_found" DISABLED = "disabled" REQUIRED = "required" @@ -26,6 +26,9 @@ class EvaluationReason(StrEnum): ALLOCATED = "allocated" ERROR = "error" + def __str__(self) -> str: + return self.value + def evaluate_with_modules(options: dict[str, Any]) -> dict[str, Any]: try: diff --git a/src/featurevisor/instance.py b/src/featurevisor/instance.py index 8f3e83c..36ee53a 100644 --- a/src/featurevisor/instance.py +++ b/src/featurevisor/instance.py @@ -79,6 +79,14 @@ def set_datafile(self, datafile, replace: bool = False) -> None: try: parsed = json.loads(datafile) if isinstance(datafile, str) else datafile + if not ( + isinstance(parsed, dict) + and isinstance(parsed.get("schemaVersion"), str) + and isinstance(parsed.get("revision"), str) + and isinstance(parsed.get("segments"), dict) + and isinstance(parsed.get("features"), dict) + ): + raise ValueError("Invalid datafile") next_datafile = parsed if replace else self._merge_datafiles(self.datafile_reader.get_datafile(), parsed) new_reader = _DatafileReader(datafile=next_datafile, logger=self.logger) details = get_params_for_datafile_set_event(self.datafile_reader, new_reader, replace) @@ -283,7 +291,7 @@ def clear_module_diagnostic_subscriptions(self, module: FeaturevisorModule) -> N def report_diagnostic(self, diagnostic: dict[str, Any], source_module: FeaturevisorModule | None = None) -> None: diagnostic = dict(diagnostic or {}) diagnostic["level"] = diagnostic.get("level") or "info" - if source_module and source_module.name and not diagnostic.get("module"): + if source_module and source_module.name: diagnostic["module"] = source_module.name details = dict(diagnostic.get("details") or {}) reserved = {"level", "code", "message", "module", "moduleName", "originalError", "details"} diff --git a/tests/test_conformance.py b/tests/test_conformance.py index d424a5c..ff0791e 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -5,6 +5,7 @@ from pathlib import Path from featurevisor.datafile_reader import _DatafileReader +from featurevisor.evaluate import EvaluationReason from featurevisor.helpers import get_value_by_type from featurevisor.logger import _Logger @@ -14,6 +15,12 @@ class SDKV3ConformanceTests(unittest.TestCase): def setUpClass(cls) -> None: cls.fixture = json.loads(Path("conformance/sdk-v3.json").read_text()) + def test_evaluation_reason_is_python_310_compatible_and_string_like(self) -> None: + reason = EvaluationReason.FEATURE_NOT_FOUND + self.assertIsInstance(reason, str) + self.assertEqual(str(reason), "feature_not_found") + self.assertEqual(json.dumps({"reason": reason}), '{"reason": "feature_not_found"}') + def test_allocation_and_typed_value_contracts(self) -> None: self.assertEqual(self.fixture["version"], 1) reader = _DatafileReader( From 2debb89f0ce0e14c4f48d409c7df2e2068691a87 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 23:55:20 +0200 Subject: [PATCH 14/14] updates --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8b4ec9d..4ca2334 100644 --- a/Makefile +++ b/Makefile @@ -32,4 +32,4 @@ test: .PHONY: test-example-1 test-example-1: PYTHONPATH=src python3 -m unittest discover -s tests -v - PYTHONPATH=src python3 -m featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures + PYTHONPATH=src python3 -m featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures