experimenter/experimenter/features/__init__.py (144 lines of code) (raw):

import json import re from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path from typing import Optional import yaml from django.conf import settings from django.core.checks import Error, register from mozilla_nimbus_schemas.experiments.feature_manifests import ( DesktopFeature, DesktopFeatureManifest, FeatureVariableType, SdkFeature, SdkFeatureManifest, ) from experimenter.experiments.constants import ApplicationConfig, NimbusConstants from manifesttool.version import Version FEATURE_SCHEMA_TYPES = { FeatureVariableType.INT: "integer", FeatureVariableType.STRING: "string", FeatureVariableType.BOOLEAN: "boolean", } FEATURE_PYTHON_TYPES = { FeatureVariableType.INT: int, FeatureVariableType.STRING: str, FeatureVariableType.BOOLEAN: bool, } @dataclass class Feature: slug: str application_slug: str model: SdkFeature | DesktopFeature version: Version | None = None @classmethod def load_remote_jsonschema(cls, application_slug: str, feature_model: DesktopFeature): if feature_model.json_schema is not None: schema_path = ( settings.FEATURE_MANIFESTS_PATH / application_slug / "schemas" / feature_model.json_schema.path ) with schema_path.open() as f: try: return json.dumps(json.load(f), indent=2) except json.JSONDecodeError: return None def generate_jsonschema(self): schema = { "type": "object", "properties": {}, "additionalProperties": False, } for variable_slug, variable in self.model.variables.items(): variable_schema = { "description": variable.description, } if variable.type in FEATURE_SCHEMA_TYPES: variable_schema["type"] = FEATURE_SCHEMA_TYPES[variable.type] if variable.enum: python_type = FEATURE_PYTHON_TYPES[variable.type] variable_schema["enum"] = [python_type(e) for e in variable.enum] schema["properties"][variable_slug] = variable_schema return json.dumps(schema, indent=2) @property def has_remote_schema(self): return ( isinstance(self.model, DesktopFeature) and self.model.json_schema is not None ) def get_jsonschema(self): if self.has_remote_schema: return self.load_remote_jsonschema(self.application_slug, self.model) return self.generate_jsonschema() class Features: _features: Optional[list[Feature]] = None @classmethod def _read_manifest( cls, application: ApplicationConfig, manifest_path: Path, version: Version = None, ): with manifest_path.open() as manifest_file: application_data = yaml.safe_load(manifest_file) if application.slug == NimbusConstants.Application.DESKTOP: manifest_cls = DesktopFeatureManifest else: manifest_cls = SdkFeatureManifest manifest = manifest_cls.parse_obj(application_data) for feature_slug, feature_model in manifest.root.items(): yield Feature( slug=feature_slug, application_slug=application.slug, model=feature_model, version=version, ) @classmethod def _load_features(cls): features = [] version_re = re.compile(r"^v(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)") for application in NimbusConstants.APPLICATION_CONFIGS.values(): application_dir: Path = settings.FEATURE_MANIFESTS_PATH / application.slug application_yaml_path = application_dir / "experimenter.yaml" if application_yaml_path.exists(): features.extend(cls._read_manifest(application, application_yaml_path)) for child in application_dir.iterdir(): if not child.is_dir(): continue if m := version_re.match(child.name): version = Version.from_match(m.groupdict()) application_yaml_path = child / "experimenter.yaml" if application_yaml_path.exists(): features.extend( cls._read_manifest( application, application_yaml_path, version ) ) return features @classmethod def clear_cache(cls): cls._features = None @classmethod def all(cls) -> list[Feature]: if cls._features is None: cls._features = cls._load_features() return cls._features @classmethod def by_application(cls, application) -> list[Feature]: return [f for f in cls.all() if f.application_slug == application] @classmethod def unversioned(cls) -> Iterable[Feature]: return (f for f in cls.all() if f.version is None) @classmethod def versioned(cls) -> Iterable[Feature]: return (f for f in cls.all() if f.version is not None) @register() def check_features(app_configs, **kwargs): errors = [] try: Features.all() except Exception as e: errors.append(Error(f"Error loading feature data {e}")) return errors