diff --git a/Makefile b/Makefile index deb4253742..2a51f582c8 100644 --- a/Makefile +++ b/Makefile @@ -292,8 +292,8 @@ SCHEMAS_DIFF_PYDANTIC = \ echo 'Done. No problems found in schemas.' SCHEMAS_TEST = pytest SCHEMAS_FORMAT = ruff check --fix . && black . +SCHEMAS_GENERATE = poetry run python generate_json_schema.py SCHEMAS_DIST_PYPI = poetry build -SCHEMAS_DIST_NPM = poetry run python generate_json_schema.py --output index.d.ts SCHEMAS_DEPLOY_PYPI = twine upload --skip-existing dist/*; SCHEMAS_DEPLOY_NPM = echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc;yarn publish --new-version ${SCHEMAS_VERSION} --access public; SCHEMAS_VERSION_PYPI = poetry version ${SCHEMAS_VERSION}; @@ -302,7 +302,7 @@ SCHEMAS_VERSION_NPM = npm version --allow-same-version ${SCHEMAS_VERSION}; schemas_docker_build: ## Build schemas docker image $(DOCKER_BUILD) --target dev -f schemas/Dockerfile -t schemas:dev schemas/ -schemas_build: schemas_docker_build schemas_dist ## Build schemas +schemas_build: schemas_docker_build schemas_dist_pypi ## Build schemas schemas_bash: schemas_docker_build $(SCHEMAS_RUN) "bash" @@ -312,20 +312,22 @@ schemas_format: schemas_docker_build ## Format schemas source tree schemas_lint: schemas_docker_build ## Lint schemas source tree $(SCHEMAS_RUN) "$(SCHEMAS_BLACK)&&$(SCHEMAS_RUFF)&&$(SCHEMAS_DIFF_PYDANTIC)&&$(SCHEMAS_TEST)" + schemas_check: schemas_lint schemas_dist_pypi: schemas_docker_build $(SCHEMAS_RUN) "$(SCHEMAS_DIST_PYPI)" -schemas_dist_npm: schemas_docker_build schemas_dist_pypi - $(SCHEMAS_RUN) "$(SCHEMAS_DIST_NPM)" +schemas_generate: schemas_docker_build + $(SCHEMAS_RUN) "$(SCHEMAS_GENERATE)" -schemas_dist: schemas_docker_build schemas_dist_pypi schemas_dist_npm +schemas_dist: schemas_docker_build + $(SCHEMAS_RUN) "$(SCHEMAS_GENERATE) && $(SCHEMAS_DIST_PYPI)" -schemas_deploy_pypi: schemas_docker_build +schemas_deploy_pypi: schemas_dist $(SCHEMAS_RUN) "$(SCHEMAS_DEPLOY_PYPI)" -schemas_deploy_npm: schemas_docker_build +schemas_deploy_npm: schemas_dist $(SCHEMAS_RUN) "$(SCHEMAS_DEPLOY_NPM)" schemas_version_pypi: schemas_docker_build diff --git a/schemas/.gitignore b/schemas/.gitignore new file mode 100644 index 0000000000..aef824aaa9 --- /dev/null +++ b/schemas/.gitignore @@ -0,0 +1,2 @@ + +mozilla_nimbus_schemas/schemas/ diff --git a/schemas/VERSION b/schemas/VERSION index bd859435c0..142bb53d9d 100644 --- a/schemas/VERSION +++ b/schemas/VERSION @@ -1 +1 @@ -2024.9.3 +2024.10.1 diff --git a/schemas/generate_json_schema.py b/schemas/generate_json_schema.py index 2f20c1b8ce..28047c1790 100644 --- a/schemas/generate_json_schema.py +++ b/schemas/generate_json_schema.py @@ -4,10 +4,12 @@ """ import json +import re +import shutil import subprocess from pathlib import Path from tempfile import TemporaryDirectory -from typing import Any +from typing import Any, Iterable import click from polyfactory.factories.pydantic_factory import ModelFactory @@ -15,6 +17,8 @@ from mozilla_nimbus_schemas import experiments, jetstream +NEWLINES_RE = re.compile("\n+") + def clean_output_file(ts_path: Path) -> None: """Clean up the output file typescript definitions were written to by: @@ -98,6 +102,123 @@ def iterate_models() -> dict[str, Any]: return schema +def prettify_json_schema(schema: dict[str, Any]) -> dict[str, Any]: + # Add a $schema field. + pretty_schema = { + "$schema": "https://json-schema.org/draft/2019-09/schema", + } + + # Re-order the properties in the dict so that they are in a sensible order + # for humans consuming these schemas. + + # Use this order for top-level keys. + key_order = [ + "title", + "description", + "type", + "properties", + "required", + "additionalProperties", + "if", + "then", + "$defs", + ] + + # If there are any other keys not listed above, splice them in before $defs. + key_order = [ + *key_order[:-1], + *(set(schema.keys()) - set(key_order)), + key_order[-1], + ] + + pretty_schema.update({key: schema[key] for key in key_order if key in schema}) + + # Assert that the schemas have not structurally changed. + # + # We have to add the $schema field back to the original schema for comparison. + schema["$schema"] = pretty_schema["$schema"] + assert schema == pretty_schema + + # Next, lets walk the schema and remove attributes we don't care about. + def _walk_objects(objs: Iterable[dict[str, Any]]): + for obj in objs: + _walk_object(obj) + + def _walk_object(obj: dict[str, Any], top_level: bool = False): + # All but the top-level title will be auto-generated base on field names. They are + # not useful. + if not top_level: + obj.pop("title", None) + + # We don't support defaults. + obj.pop("default", None) + + # This is an OpenAPI extension and it leads to incorrect code generation in our + # case (due to using a boolean discriminator). + obj.pop("discriminator", None) + + # Strip newlines from descriptions. + if description := obj.get("description"): + obj["description"] = NEWLINES_RE.sub(" ", description) + + # Remove redundant enum entries for constants. + if obj.get("const") is not None: + obj.pop("enum", None) + + match obj.get("type"): + case "object": + if properties := obj.get("properties"): + _walk_objects(properties.values()) + + case "array": + if items := obj.get("items"): + _walk_object(items) + + for group_key in ("allOf", "anyOf", "oneOf"): + if group := obj.get(group_key): + _walk_objects(group) + + _walk_object(pretty_schema, top_level=True) + if defs := pretty_schema.get("$defs"): + _walk_objects(defs.values()) + + return pretty_schema + + +def write_json_schemas(json_schemas_path: Path, python_package_dir: Path): + json_schemas_path.mkdir(exist_ok=True) + + models = { + model_name: getattr(experiments, model_name) + for model_name in experiments.__all__ + if issubclass(getattr(experiments, model_name), BaseModel) + } + + written_paths = set() + + for model_name, model in models.items(): + model_schema_path = json_schemas_path / f"{model_name}.schema.json" + written_paths.add(model_schema_path) + + json_schema = prettify_json_schema(model.model_json_schema()) + with model_schema_path.open("w") as f: + json.dump(json_schema, f, indent=2) + f.write("\n") + + # Ensure we don't include any files in schemas/ that we did not generate (e.g., if a + # model gets removed). + for path in list(json_schemas_path.iterdir()): + if path not in written_paths: + path.unlink() + + # Copy schemas into the python package. + schemas_dist_dir = python_package_dir / "schemas" + if schemas_dist_dir.exists(): + shutil.rmtree(schemas_dist_dir) + + shutil.copytree(json_schemas_path, schemas_dist_dir) + + @click.command() @click.option( "--output", @@ -106,7 +227,25 @@ def iterate_models() -> dict[str, Any]: default=Path("index.d.ts"), help="Output typescript file.", ) -def main(*, ts_output_path: Path): +@click.option( + "--json-schemas", + "json_schemas_path", + type=Path, + default=Path("schemas"), + help="Output JSON Schemas to this directory.", +) +@click.option( + "--python-package-dir", + "python_package_dir", + type=Path, + default=Path("mozilla_nimbus_schemas"), + help=( + "The directory to the mozilla-nimbus-schemas python package.\n" + "\n" + "Schemas will be installed inside this package at the schemas dir." + ), +) +def main(*, ts_output_path: Path, json_schemas_path: Path, python_package_dir: Path): json_schema = iterate_models() with TemporaryDirectory() as tmp_dir: @@ -132,6 +271,8 @@ def main(*, ts_output_path: Path): clean_output_file(ts_output_path) + write_json_schemas(json_schemas_path, python_package_dir) + if __name__ == "__main__": main() diff --git a/schemas/index.d.ts b/schemas/index.d.ts index 217d4f5df6..a5bce0e377 100644 --- a/schemas/index.d.ts +++ b/schemas/index.d.ts @@ -5,13 +5,13 @@ /* Do not modify by hand - update the pydantic models and re-run the script */ +export type DesktopApplication = "firefox-desktop" | "firefox-desktop-background-task"; +export type FeatureVariableType = "int" | "string" | "boolean" | "json"; +export type PrefBranch = "default" | "user"; /** * A unique, stable indentifier for the user used as an input to bucket hashing. */ export type RandomizationUnit = "normandy_id" | "nimbus_id" | "user_id" | "group_id"; -export type Feature = FeatureWithExposure | FeatureWithoutExposure; -export type FeatureVariableType = "int" | "string" | "boolean" | "json"; -export type PrefBranch = "default" | "user"; export type AnalysisBasis = "enrollments" | "exposures"; export type LogSource = "jetstream" | "sizing" | "jetstream-preview"; export type AnalysisErrors = AnalysisError[]; @@ -31,6 +31,107 @@ export type SizingMetricName = "active_hours" | "search_count" | "days_of_use" | export type StatisticIngestEnum = "percentage" | "binomial" | "mean" | "count"; export type Statistics = Statistic[]; +/** + * A feature. + */ +export interface DesktopFeature { + /** + * The description of the feature. + */ + description: string; + /** + * Whether or not this feature records exposure telemetry. + */ + hasExposure: boolean; + /** + * A description of the exposure telemetry collected by this feature. + * + * Only required if hasExposure is true. + */ + exposureDescription?: string; + /** + * The owner of the feature. + */ + owner: string; + /** + * If true, the feature values will be cached in prefs so that they can be read before Nimbus is initialized during Firefox startup. + */ + isEarlyStartup?: boolean; + /** + * The applications that can enroll in experiments for this feature. + * + * Defaults to "firefox-desktop". + */ + applications?: DesktopApplication[]; + /** + * The variables that this feature can set. + */ + variables: { + [k: string]: DesktopFeatureVariable; + }; + schema?: NimbusFeatureSchema; +} +/** + * A feature variable. + */ +export interface DesktopFeatureVariable { + /** + * A description of the feature. + */ + description: string; + type: FeatureVariableType; + /** + * An optional list of possible string or integer values. + * + * Only allowed when type is string or int. + * + * The types in the enum must match the type of the field. + */ + enum?: string[] | number[]; + /** + * A pref that provides the default value for a feature when none is present. + */ + fallbackPref?: string; + /** + * A pref that should be set to the value of this variable when enrolling in experiments. + * + * Using a string is deprecated and unsupported in Firefox 124+. + */ + setPref?: string | SetPref; +} +export interface SetPref { + branch: PrefBranch; + /** + * The name of the pref to set. + */ + pref: string; +} +/** + * Information about a JSON schema. + */ +export interface NimbusFeatureSchema { + /** + * The resource:// or chrome:// URI that can be loaded at runtime within Firefox. + * + * Required by Firefox so that Nimbus can import the schema for validation. + */ + uri: string; + /** + * The path to the schema file in the source checkout. + * + * Required by Experimenter so that it can find schema files in source checkouts. + */ + path: string; +} +/** + * The Firefox Desktop-specific feature manifest. + * + * Firefox Desktop requires different fields for its features compared to the general + * Nimbus feature manifest. + */ +export interface DesktopFeatureManifest { + [k: string]: DesktopFeature; +} /** * The experiment definition accessible to: * @@ -106,6 +207,9 @@ export interface NimbusExperiment { | ExperimentSingleFeatureBranch[] | ExperimentMultiFeatureDesktopBranch[] | ExperimentMultiFeatureMobileBranch[]; + /** + * A JEXL targeting expression used to filter out experiments. + */ targeting?: string | null; /** * Actual publish date of the experiment. @@ -282,48 +386,52 @@ export interface ExperimentMultiFeatureMobileBranch { */ features: ExperimentFeatureConfig[]; } -export interface FeatureManifest { - [k: string]: Feature; +/** + * The SDK-specific feature manifest. + */ +export interface SdkFeatureManifest { + [k: string]: SdkFeature; } /** - * A feature that has exposure. + * A feature. */ -export interface FeatureWithExposure { - description?: string | null; - isEarlyStartup?: boolean | null; +export interface SdkFeature { + /** + * The description of the feature. + */ + description: string; + /** + * Whether or not this feature records exposure telemetry. + */ + hasExposure: boolean; + /** + * A description of the exposure telemetry collected by this feature. + * + * Only required if hasExposure is true. + */ + exposureDescription?: string; + /** + * The variables that this feature can set. + */ variables: { - [k: string]: FeatureVariable; + [k: string]: SdkFeatureVariable; }; - schema?: NimbusFeatureSchema | null; - hasExposure: true; - exposureDescription: string; -} -export interface FeatureVariable { - description?: string | null; - enum?: string[] | null; - fallbackPref?: string | null; - type?: FeatureVariableType | null; - setPref?: string | SetPref | null; -} -export interface SetPref { - branch: PrefBranch; - pref: string; -} -export interface NimbusFeatureSchema { - uri: string; - path: string; } /** - * A feature without exposure. + * A feature variable. */ -export interface FeatureWithoutExposure { - description?: string | null; - isEarlyStartup?: boolean | null; - variables: { - [k: string]: FeatureVariable; - }; - schema?: NimbusFeatureSchema | null; - hasExposure: false; +export interface SdkFeatureVariable { + /** + * A description of the feature. + */ + description: string; + type: FeatureVariableType; + /** + * An optional list of possible string values. + * + * Only allowed when type is string. + */ + enum?: string[]; } export interface AnalysisError { analysis_basis?: AnalysisBasis | null; diff --git a/schemas/mozilla_nimbus_schemas/experiments/__init__.py b/schemas/mozilla_nimbus_schemas/experiments/__init__.py index 5b4c6583f9..4dcfeb4a30 100644 --- a/schemas/mozilla_nimbus_schemas/experiments/__init__.py +++ b/schemas/mozilla_nimbus_schemas/experiments/__init__.py @@ -1,4 +1,10 @@ from .experiments import NimbusExperiment, RandomizationUnit -from .feature_manifests import FeatureManifest +from .feature_manifests import DesktopFeature, DesktopFeatureManifest, SdkFeatureManifest -__all__ = ("NimbusExperiment", "RandomizationUnit", "FeatureManifest") +__all__ = ( + "DesktopFeature", + "DesktopFeatureManifest", + "NimbusExperiment", + "RandomizationUnit", + "SdkFeatureManifest", +) diff --git a/schemas/mozilla_nimbus_schemas/experiments/experiments.py b/schemas/mozilla_nimbus_schemas/experiments/experiments.py index 2f98d3f4c9..3847999146 100644 --- a/schemas/mozilla_nimbus_schemas/experiments/experiments.py +++ b/schemas/mozilla_nimbus_schemas/experiments/experiments.py @@ -193,15 +193,18 @@ class NimbusExperiment(BaseModel): list[ExperimentMultiFeatureDesktopBranch], list[ExperimentMultiFeatureMobileBranch], ] = Field(description="Branch configuration for the experiment.") - targeting: str | None = None - startDate: str | None = Field( + targeting: str | None = Field( + description="A JEXL targeting expression used to filter out experiments.", + default=None, + ) + startDate: datetime.date | None = Field( description=( "Actual publish date of the experiment.\n" "\n" "Note that this value is expected to be null in Remote Settings." ), ) - enrollmentEndDate: Optional[str] = Field( + enrollmentEndDate: Optional[datetime.date] = Field( description=( "Actual enrollment end date of the experiment.\n" "\n" @@ -209,7 +212,7 @@ class NimbusExperiment(BaseModel): ), default=None, ) - endDate: str | None = Field( + endDate: datetime.date | None = Field( description=( "Actual end date of this experiment.\n" "\n" @@ -262,7 +265,7 @@ class NimbusExperiment(BaseModel): ), default=None, ) - publishedDate: datetime.datetime | SkipJsonSchema[None] = Field( + publishedDate: datetime.datetime = Field( description=( "The date that this experiment was first published to Remote Settings.\n" "\n" diff --git a/schemas/mozilla_nimbus_schemas/experiments/feature_manifests.py b/schemas/mozilla_nimbus_schemas/experiments/feature_manifests.py index c07c46e11c..4e6d35f5da 100644 --- a/schemas/mozilla_nimbus_schemas/experiments/feature_manifests.py +++ b/schemas/mozilla_nimbus_schemas/experiments/feature_manifests.py @@ -1,77 +1,354 @@ from enum import Enum -from typing import Literal, Optional, Union -from pydantic import BaseModel, Field, RootModel +from pydantic import ( + BaseModel, + ConfigDict, + Field, + RootModel, + model_validator, +) +from pydantic.json_schema import SkipJsonSchema +from pydantic.types import StrictInt, StrictStr +from typing_extensions import Self -class FeatureVariableType(Enum): +class FeatureVariableType(str, Enum): INT = "int" STRING = "string" BOOLEAN = "boolean" JSON = "json" -class PrefBranch(Enum): +class PrefBranch(str, Enum): DEFAULT = "default" USER = "user" +class DesktopApplication(str, Enum): + FIREFOX_DESKTOP = "firefox-desktop" + BACKGROUND_TASK = "firefox-desktop-background-task" + + class SetPref(BaseModel): - branch: PrefBranch - pref: str + branch: PrefBranch = Field( + description=( + "The branch the pref will be set on.\n" + "\n" + "Prefs set on the user branch persists through restarts." + ), + ) + pref: str = Field(description="The name of the pref to set.") + + +class BaseFeatureVariable(BaseModel): + description: str = Field(description="A description of the feature.") + type: FeatureVariableType = Field(description="The field type.") + + +class SdkFeatureVariable(BaseFeatureVariable): + """A feature variable.""" + + enum: list[str] | SkipJsonSchema[None] = Field( + description=( + "An optional list of possible string values.\n" + "\n" + f"Only allowed when type is {FeatureVariableType.STRING.value}." + ), + default=None, + ) + + model_config = ConfigDict( + json_schema_extra={ + "dependentSchemas": { + "enum": { + "properties": { + "type": {"const": FeatureVariableType.STRING.value}, + } + } + } + } + ) + + @model_validator(mode="after") + @classmethod + def validate_enum(cls, data: Self) -> Self: + if data.enum is not None: + if data.type != FeatureVariableType.STRING: + raise ValueError("only string enums are supported") + + # The other cases are handled by regular model validation. + + return data + + +class DesktopFeatureVariable(BaseFeatureVariable): + """A feature variable.""" + + enum: list[StrictStr] | list[StrictInt] | SkipJsonSchema[None] = Field( + description=( + "An optional list of possible string or integer values.\n" + "\n" + f"Only allowed when type is {FeatureVariableType.STRING.value} or " + f"{FeatureVariableType.INT.value}.\n" + "\n" + "The types in the enum must match the type of the field." + ), + default=None, + ) + + fallback_pref: str | SkipJsonSchema[None] = Field( + alias="fallbackPref", + description=( + "A pref that provides the default value for a feature when none is present." + ), + default=None, + ) + + set_pref: str | SetPref | SkipJsonSchema[None] = Field( + alias="setPref", + description=( + "A pref that should be set to the value of this variable when enrolling in " + "experiments.\n" + "\n" + "Using a string is deprecated and unsupported in Firefox 124+." + ), + default=None, + ) + + model_config = ConfigDict( + json_schema_extra={ + "dependentSchemas": { + # This is the equivalent of the `validate_enums` validator. + # + # This could also be done declaratively by specializing FeatureVariable + # into specifically typed child classes and using a union in the parent + # class, but that is much more verbose and generates a bunch of + # boilerplate types. + # + # From a JSON Schema perspective, don't have to tuck this away in + # dependentSchemas and the allOf clause could live at the top-level, but + # then json-schema-to-typescript gets confused and generates an empty type + # for `DesktopFeatureVariable`. + "enum": { + "allOf": [ + *( + { + "if": { + "properties": { + "type": {"const": ty}, + }, + }, + "then": { + "properties": { + "enum": { + "items": {"type": json_schema_ty}, + }, + }, + }, + } + for ty, json_schema_ty in ( + (FeatureVariableType.STRING, "string"), + (FeatureVariableType.INT, "integer"), + ) + ), + *( + { + "if": { + "properties": { + "type": {"const": ty}, + }, + }, + "then": { + "properties": { + "enum": {"const": None}, + }, + }, + } + for ty in ( + FeatureVariableType.BOOLEAN, + FeatureVariableType.JSON, + ) + ), + ], + }, + # These are the the equivalent of the + # `validate_set_pref_fallback_pref_mutually_exclusive` validator. + # + # Pydantic does not have a way to encode this relationship outside custom + # validation. + "fallbackPref": { + "description": "setPref is mutually exclusive with fallbackPref", + "properties": { + "setPref": { + "const": None, + } + }, + }, + "setPref": { + "description": "fallbackPref is mutually exclusive with setPref", + "properties": { + "fallbackPref": { + "const": None, + } + }, + }, + }, + }, + ) + + @model_validator(mode="after") + @classmethod + def validate_set_pref_fallback_pref_mutually_exclusive(cls, data: Self) -> Self: + has_set_pref = data.set_pref is not None + has_fallback_pref = data.fallback_pref is not None + + if has_set_pref and has_fallback_pref: + raise ValueError("fallback_pref and set_pref are mutually exclusive") + + return data + @model_validator(mode="after") + @classmethod + def validate_enum(cls, data: Self) -> Self: + if data.enum is not None: + if data.type in (FeatureVariableType.STRING, FeatureVariableType.INT): + expected_cls = str if data.type == FeatureVariableType.STRING else int -class FeatureVariable(BaseModel): - description: Optional[str] = None - enum: Optional[list[str]] = None - fallback_pref: Optional[str] = Field(None, alias="fallbackPref") - type: Optional[FeatureVariableType] = None - set_pref: Optional[Union[str, SetPref]] = Field(None, alias="setPref") + if not all(isinstance(variant, expected_cls) for variant in data.enum): + raise ValueError("enum values do not match variable type") + + # The other cases are handled by regular model validation. + + return data class NimbusFeatureSchema(BaseModel): - uri: str - path: str + """Information about a JSON schema.""" + + uri: str = Field( + description=( + "The resource:// or chrome:// URI that can be loaded at runtime within " + "Firefox.\n" + "\n" + "Required by Firefox so that Nimbus can import the schema for validation." + ), + ) + + path: str = Field( + description=( + "The path to the schema file in the source checkout.\n" + "\n" + "Required by Experimenter so that it can find schema files in source " + "checkouts." + ) + ) class BaseFeature(BaseModel): - """The base Feature type. + """The Feature type.""" - The real Feature type has conditionally required fields (if the feature has - exposure, then the exposure description is required), so this class includes - the fields common in both cases. - """ + description: str = Field(description="The description of the feature.") + + has_exposure: bool = Field( + alias="hasExposure", + description="Whether or not this feature records exposure telemetry.", + ) + + exposure_description: str = Field( + alias="exposureDescription", + description=( + "A description of the exposure telemetry collected by this feature.\n" + "\n" + "Only required if hasExposure is true." + ), + default=None, + ) + + # This could be done declaratively by splitting the feature into + model_config = ConfigDict( + json_schema_extra={ + "if": { + "properties": { + "hasExposure": { + "const": True, + }, + }, + }, + "then": { + "required": ["exposureDescription"], + }, + } + ) - description: Optional[str] = None - is_early_startup: Optional[bool] = Field(None, alias="isEarlyStartup") - variables: dict[str, FeatureVariable] + @model_validator(mode="after") + @classmethod + def validate_exposure_description(cls, data: Self) -> Self: + if data.has_exposure and data.exposure_description is None: + raise TypeError("exposure_description is required if has_exposure is True") - #: Only used by Firefox Desktop. - json_schema: Optional[NimbusFeatureSchema] = Field(None, alias="schema") + return data -class FeatureWithExposure(BaseFeature): - """A feature that has exposure.""" +class SdkFeature(BaseFeature): + """A feature.""" - has_exposure: Literal[True] = Field(alias="hasExposure") - exposure_description: str = Field(alias="exposureDescription") + # The Nimbus SDK requires different fields for its features compared to the Desktop + # client. + + variables: dict[str, SdkFeatureVariable] = Field( + description="The variables that this feature can set." + ) -class FeatureWithoutExposure(BaseFeature): - """A feature without exposure.""" +class DesktopFeature(BaseFeature): + """A feature.""" - has_exposure: Literal[False] = Field(alias="hasExposure") + # The Firefox Desktop Nimbus client requires different fields for its features + # compared to the SDK. - @property - def exposure_description(self): - return None + owner: str = Field(description="The owner of the feature.") + + is_early_startup: bool = Field( + alias="isEarlyStartup", + description=( + "If true, the feature values will be cached in prefs so that they can be " + "read before Nimbus is initialized during Firefox startup." + ), + default=False, + ) + applications: list[DesktopApplication] | SkipJsonSchema[None] = Field( + description=( + "The applications that can enroll in experiments for this feature.\n" + "\n" + 'Defaults to "firefox-desktop".' + ), + default_factory=lambda: [DesktopApplication.FIREFOX_DESKTOP.value], + min_length=1, + ) + + variables: dict[str, DesktopFeatureVariable] = Field( + description="The variables that this feature can set.", + ) -class Feature(RootModel): - root: Union[FeatureWithExposure, FeatureWithoutExposure] = Field( - discriminator="has_exposure" + json_schema: NimbusFeatureSchema | SkipJsonSchema[None] = Field( + alias="schema", + description="An optional JSON schema that describes the feature variables.", + default=None, ) -class FeatureManifest(RootModel): - root: dict[str, Feature] +class DesktopFeatureManifest(RootModel): + """The Firefox Desktop-specific feature manifest. + + Firefox Desktop requires different fields for its features compared to the general + Nimbus feature manifest. + """ + + root: dict[str, DesktopFeature] + + +class SdkFeatureManifest(RootModel): + """The SDK-specific feature manifest.""" + + root: dict[str, SdkFeature] diff --git a/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/firefox-desktop-setPref-str.yaml b/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/desktop/firefox-desktop-setPref-str.yaml similarity index 94% rename from schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/firefox-desktop-setPref-str.yaml rename to schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/desktop/firefox-desktop-setPref-str.yaml index f2924087b7..4aa22347da 100644 --- a/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/firefox-desktop-setPref-str.yaml +++ b/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/desktop/firefox-desktop-setPref-str.yaml @@ -50,15 +50,18 @@ nimbus-qa-2: description: The value to set for the pref. feature-with-exposure: + description: A feature with exposure. hasExposure: true owner: nobody@example.com exposureDescription: A description of the exposure event. variables: {} feature-with-schema: + description: A feature with a schema. variables: {} owner: nobody@example.com hasExposure: false + variables: {} schema: uri: "resource://gre/foo/schema.json" path: "/toolkit/components/foo/schema.json" diff --git a/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/firefox-desktop.yaml b/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/desktop/firefox-desktop.yaml similarity index 94% rename from schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/firefox-desktop.yaml rename to schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/desktop/firefox-desktop.yaml index 296f29e1b2..f0de4865b3 100644 --- a/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/firefox-desktop.yaml +++ b/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/desktop/firefox-desktop.yaml @@ -56,15 +56,18 @@ nimbus-qa-2: description: The value to set for the pref. feature-with-exposure: + description: A feature with exposure. hasExposure: true owner: nobody@example.com exposureDescription: A description of the exposure event. variables: {} feature-with-schema: + description: A feature with a schema. variables: {} owner: nobody@example.com hasExposure: false + variables: {} schema: uri: "resource://gre/foo/schema.json" path: "/toolkit/components/foo/schema.json" diff --git a/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/sdk/sdk.yaml b/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/sdk/sdk.yaml new file mode 100644 index 0000000000..00076a00b3 --- /dev/null +++ b/schemas/mozilla_nimbus_schemas/tests/experiments/fixtures/feature_manifests/sdk/sdk.yaml @@ -0,0 +1,313 @@ +--- +account-settings-redux-feature: + description: "This feature is for managing the roll out of the Account Settings Redux implementation\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "Enables the feature\n" +address-autofill-edit: + description: This property defines if the address editing is enabled in Settings + hasExposure: true + exposureDescription: "" + variables: + status: + type: boolean + description: "If true, we will allow user to edit the address" +bookmark-refactor-feature: + description: "The Feature for managing the roll out of the Bookmark refactor feature\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "Enables the bookmark refactor feature\n" +contextual-hint-feature: + description: This set holds all features pertaining to contextual hints. + hasExposure: true + exposureDescription: "" + variables: + features-enabled: + type: json + description: "This property provides a lookup table of whether specific contextual hints are enabled.\n" +credit-card-autofill: + description: This property defines the credit card autofill feature + hasExposure: true + exposureDescription: "" + variables: + credit-card-autofill-status: + type: boolean + description: "If true, we will allow user to use the credit autofill feature" +felt-privacy-feature: + description: The feature that enhances private browsing mode + hasExposure: true + exposureDescription: "" + variables: + felt-deletion-enabled: + type: boolean + description: "If true, enable Felt Deletion part of Felt Privacy" + simplified-ui-enabled: + type: boolean + description: "If true, enable simplified UI part of Felt Privacy" +firefox-suggest-feature: + description: Configuration for the Firefox Suggest feature. + hasExposure: true + exposureDescription: "" + variables: + available-suggestions-types: + type: json + description: "A map of suggestion types to booleans that indicate whether or not the provider should return suggestions of those types.\n" + status: + type: boolean + description: "Whether the feature is enabled. When Firefox Suggest is enabled, Firefox will download and store new search suggestions in the background, and show additional Search settings to control which suggestions appear in the awesomebar. When Firefox Suggest is disabled, Firefox will not download new suggestions, and hide the additional Search settings.\n" +general-app-features: + description: The feature that contains feature flags for the entire application + hasExposure: true + exposureDescription: "" + variables: + report-site-issue: + type: json + description: This property defines whether or not the feature is enabled +glean-server-knobs: + description: A feature that provides server-side configurations for Glean metrics (aka Server Knobs). + hasExposure: true + exposureDescription: "" + variables: + metrics-enabled: + type: json + description: "A map of metric base-identifiers to booleans representing the state of the 'enabled' flag for that metric." +homepage-rebuild-feature: + description: "This feature is for managing the roll out of the Homepage rebuild feature\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "If true, enables the feature\n" +homescreenFeature: + description: The homescreen that the user goes to when they press home or new tab. + hasExposure: true + exposureDescription: "" + variables: + prefer-switch-to-open-tab: + type: boolean + description: "Enables the feature to automatically switch to an existing tab with the same content instead of opening a new one.\n" + sections-enabled: + type: json + description: "This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default.\n" +login-autofill: + description: This property defines the login autofill feature for automatically filling in usernames and passwords. + hasExposure: true + exposureDescription: "" + variables: + login-autofill-status: + type: boolean + description: "If true, allows the user to use the login autofill feature for usernames and passwords." +menu-refactor-feature: + description: "Controls the menu refactor feature\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "Controls which menu users will see\n" + menu-hint: + type: boolean + description: "If true, enables the menu contextual hint.\n" +messaging: + description: "The in-app messaging system\n" + hasExposure: true + exposureDescription: "" + variables: + actions: + type: json + description: A growable map of action URLs. + message-under-experiment: + type: string + description: "Deprecated. Please use \"experiment\": \"{experiment}\" instead." + messages: + type: json + description: "A growable collection of messages, where the Key is the message identifier and the value is its associated MessageData.\n" + on-control: + type: string + description: What should be displayed when a control message is selected. + enum: + - show-next-message + - show-none + styles: + type: json + description: "A map of styles to configure message appearance.\n" + triggers: + type: json + description: "A collection of out the box trigger expressions. Each entry maps to a valid JEXL expression.\n" + ~~experiment: + type: string + description: Not to be set by experiment. +microsurvey-feature: + description: "A feature that shows the microsurvey for users to interact with and submit responses.\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "If true, the feature is active.\n" +native-error-page-feature: + description: "This feature is for managing the roll out of the native error page feature\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "If true, the feature is active.\n" +night-mode-feature: + description: "Describes the night mode feature's configuration\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "Whether night mode is available for users or not\n" +onboarding-framework-feature: + description: "The new onboarding framework feature that will allow onboarding to be experimentable through initial experiments.\n" + hasExposure: true + exposureDescription: "" + variables: + cards: + type: json + description: "The list of available cards for onboarding.\n" + conditions: + type: json + description: "A collection of out the box conditional expressions to be used in determining whether a card should show or not. Each entry maps to a valid JEXL expression.\n" + dismissable: + type: boolean + description: "Whether or not the entire onboarding is dismissable by pressing an X at the top right corner of the screen.\n" +password-generator-feature: + description: Password Generator Feature + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "If true, the password generator feature is enabled" +redux-search-settings-feature: + description: "This feature is for managing the roll out of redux on the search settings screen\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "Enables the feature\n" +remote-tab-management: + description: "Features that let users manage tabs on other devices that are connected to the same Mozilla account.\n" + hasExposure: true + exposureDescription: "" + variables: + close-tabs-enabled: + type: boolean + description: "Whether the feature to close synced tabs is enabled. When enabled, this device will allow other devices to close tabs that are open on this device, and show a \"close\" button for tabs that are currently open on other supported devices in the synced tabs tray.\n" +search: + description: "Configuring the functionality to do with search. This will be separated into smaller sub-features in later releases.\n" + hasExposure: true + exposureDescription: "" + variables: + awesome-bar: + type: json + description: Configuring the awesome bar. +shopping2023: + description: "The configuration setting for the status of the Fakespot feature\n" + hasExposure: true + exposureDescription: "" + variables: + back_in_stock_reporting: + type: boolean + description: "If true, enables for users the reporting feature for products back in stock.\n" + config: + type: json + description: "A Map of website configurations\n" + product_ads: + type: boolean + description: "If true, enables the product advertisement feature, allowing users to see and interact with ads for various products.\n" + relay: + type: string + description: "Configurable relay URL for production environment\n" + status: + type: boolean + description: "Whether the Fakespot feature is enabled or disabled\n" +splash-screen: + description: "A feature that extends splash screen duration, allowing additional data fetching time for the app's initial run.\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "If true, the feature is active.\n" + maximum_duration_ms: + type: int + description: "The maximum amount of time in milliseconds the splashscreen will be visible while waiting for initialization calls to complete.\n" +spotlight-search: + description: Add pages as items findable with Spotlight. + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "If this is true, then on each page load adds a new item to Spotlight." + icon-type: + type: string + description: "The icon that is displayed next to the item in the search results. If this is `null`, then no icon is displayed.\n" + keep-for-days: + type: int + description: "Number of days to keep the item before automatic deletion. If this is left `null`, then it is left to iOS's default.\n" + searchable-content: + type: string + description: "The text content that is made searchable. If this is `null` then no additional content is used, and only the title and URL will be used.\n" +tab-tray-refactor-feature: + description: "This feature is for managing the roll out of the Tab Tray refactor feature\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "Enables the feature\n" +tabTrayFeature: + description: The tab tray screen that the user goes to when they open the tab tray. + hasExposure: true + exposureDescription: "" + variables: + sections-enabled: + type: json + description: "This property provides a lookup table of whether or not the given section should be enabled. If the section is enabled, it should be toggleable in the settings screen, and on by default." +toolbar-refactor-feature: + description: "This feature is for managing the roll out of the Toolbar refactor feature\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "Enables the feature\n" + navigation_hint: + type: boolean + description: "If true, enables the navigation contextual hint.\n" + one_tap_new_tab: + type: boolean + description: "If true, enables the one tap new tab feature for users.\n" + unified_search: + type: boolean + description: "Enables the unified search feature\n" +tracking-protection-refactor: + description: "The Enhanced Tracking Protection refactor\n" + hasExposure: true + exposureDescription: "" + variables: + enabled: + type: boolean + description: "Whether the Enhanced Tracking Protection refactor is enabled or not\n" +zoom-feature: + description: "The configuration for the status of the zoom feature\n" + hasExposure: true + exposureDescription: "" + variables: + status: + type: boolean + description: "Whether the page zoom feature is enabled or not\n" diff --git a/schemas/mozilla_nimbus_schemas/tests/experiments/test_feature_manifests.py b/schemas/mozilla_nimbus_schemas/tests/experiments/test_feature_manifests.py index 6ce5b18852..bdd0536057 100644 --- a/schemas/mozilla_nimbus_schemas/tests/experiments/test_feature_manifests.py +++ b/schemas/mozilla_nimbus_schemas/tests/experiments/test_feature_manifests.py @@ -1,16 +1,337 @@ +import importlib.resources +import json +from functools import cache from pathlib import Path +from typing import Any +import pydantic import pytest import yaml +from jsonschema.protocols import Validator +from jsonschema.validators import validator_for -from mozilla_nimbus_schemas.experiments import FeatureManifest +from mozilla_nimbus_schemas.experiments.feature_manifests import ( + DesktopFeatureManifest, + DesktopFeatureVariable, + SdkFeatureManifest, + SdkFeatureVariable, +) FIXTURE_DIR = Path(__file__).parent / "fixtures" / "feature_manifests" +PACKAGE_DIR = importlib.resources.files("mozilla_nimbus_schemas") +SCHEMAS_DIR = PACKAGE_DIR / "schemas" -@pytest.mark.parametrize("manifest_file", FIXTURE_DIR.iterdir()) -def test_manifest_fixtures_are_valid(manifest_file): + +def load_schema(name: str) -> Validator: + with SCHEMAS_DIR.joinpath(name).open() as f: + schema = json.load(f) + + validator = validator_for(schema) + validator.check_schema(schema) + + return validator(schema) + + +@pytest.fixture +@cache +def desktop_feature_schema_validator() -> Validator: + return load_schema("DesktopFeature.schema.json") + + +@pytest.fixture +@cache +def desktop_feature_manifest_schema_validator() -> Validator: + return load_schema("DesktopFeatureManifest.schema.json") + + +@pytest.fixture +@cache +def sdk_feature_manifest_schema_validator() -> Validator: + return load_schema("SdkFeatureManifest.schema.json") + + +@pytest.mark.parametrize("manifest_file", FIXTURE_DIR.joinpath("desktop").iterdir()) +def test_desktop_manifest_fixtures_are_valid( + manifest_file, desktop_feature_manifest_schema_validator +): with manifest_file.open() as f: contents = yaml.safe_load(f) - FeatureManifest.model_validate(contents) + DesktopFeatureManifest.model_validate(contents) + + assert desktop_feature_manifest_schema_validator.is_valid(contents) + + +@pytest.mark.parametrize("manifest_file", FIXTURE_DIR.joinpath("sdk").iterdir()) +def test_sdk_manifest_fixtures_are_valid( + manifest_file, sdk_feature_manifest_schema_validator +): + with manifest_file.open() as f: + contents = yaml.safe_load(f) + + SdkFeatureManifest.model_validate(contents) + + assert sdk_feature_manifest_schema_validator.is_valid(contents) + + +def test_desktop_feature_exposure_description_conditionally_required( + desktop_feature_schema_validator, +): + assert desktop_feature_schema_validator.is_valid( + { + "owner": "owner@example.com", + "description": "placeholder", + "hasExposure": False, + "variables": {}, + } + ) + + errors = list( + desktop_feature_schema_validator.iter_errors( + { + "owner": "owner@example.com", + "description": "placeholder", + "hasExposure": True, + "variables": {}, + } + ) + ) + + assert [e.message for e in errors] == ["'exposureDescription' is a required property"] + assert tuple(errors[0].path) == () + + +def test_sdk_feature_manifest_feature_exposure_description_conditionally_required( + sdk_feature_manifest_schema_validator, +): + manifest = { + "feature": { + "description": "placeholder", + "hasExposure": False, + "variables": {}, + }, + } + assert sdk_feature_manifest_schema_validator.is_valid(manifest) + + manifest["feature"]["hasExposure"] = True + + errors = list(sdk_feature_manifest_schema_validator.iter_errors(manifest)) + + assert [e.message for e in errors] == ["'exposureDescription' is a required property"] + assert tuple(errors[0].path) == ("feature",) + + +def test_sdk_feature_variable_valid_enum(): + SdkFeatureVariable.model_validate( + {"description": "valid enum", "type": "string", "enum": ["hello", "world"]}, + ) + + +@pytest.mark.parametrize( + "model_json", + [ + { + "description": "invalid enum (int not supported)", + "type": "int", + "enum": [1, 2, 3], + }, + { + "description": "invalid enum (boolean not supported)", + "type": "boolean", + "enum": [True], + }, + { + "description": "invalid enum (json not supported)", + "type": "json", + "enum": [{}, {}], + }, + ], +) +def test_sdk_feature_variable_invalid_enum_unsupported_type(model_json): + with pytest.raises(pydantic.ValidationError, match="Input should be a valid string"): + SdkFeatureVariable.model_validate(model_json) + + +@pytest.mark.parametrize( + "model_json,expected_pydantic_error,expected_jsonschema_error,expected_jsonschema_error_path", + [ + ( + { + "description": "invalid enum (string options for int type)", + "type": "int", + "enum": ["hello"], + }, + "only string enums are supported", + "'string' was expected", + ("feature", "variables", "variable", "type"), + ), + ( + { + "description": "invalid enum (int options for string type)", + "type": "string", + "enum": [1], + }, + "Input should be a valid string", + "1 is not of type 'string'", + ("feature", "variables", "variable", "enum", 0), + ), + ], +) +def test_sdk_feature_variable_invalid_enum_type_mismatch( + model_json, + expected_pydantic_error, + expected_jsonschema_error, + expected_jsonschema_error_path, + sdk_feature_manifest_schema_validator, +): + with pytest.raises(pydantic.ValidationError, match=expected_pydantic_error): + SdkFeatureVariable.model_validate(model_json) + + manifest = { + "feature": { + "description": "description", + "hasExposure": False, + "variables": { + "variable": model_json, + }, + } + } + + errors = list(sdk_feature_manifest_schema_validator.iter_errors(manifest)) + assert [e.message for e in errors] == [expected_jsonschema_error] + assert tuple(errors[0].path) == expected_jsonschema_error_path + + +@pytest.mark.parametrize( + "model_json", + [ + { + "description": "valid enum (string)", + "type": "string", + "enum": ["foo", "bar"], + }, + { + "description": "valid enum (int)", + "type": "int", + "enum": [1, 2, 10], + }, + ], +) +def test_desktop_feature_variable_valid_enum(model_json): + DesktopFeatureVariable.model_validate(model_json) + + +def _desktop_feature_with_variable(variable: dict[str, Any]) -> dict[str, Any]: + return { + "description": "description", + "hasExposure": False, + "owner": "placeholder@example.com", + "variables": { + "variable": variable, + }, + } + + +@pytest.mark.parametrize( + "model_json", + [ + { + "description": "invalid enum (boolean)", + "type": "boolean", + "enum": [True], + }, + { + "description": "invalid enum (json)", + "type": "json", + "enum": [{}], + }, + ], +) +def test_desktop_feature_variable_invalid_enum_types( + model_json, desktop_feature_schema_validator +): + with pytest.raises(pydantic.ValidationError): + DesktopFeatureVariable.model_validate(model_json) + + errors = list( + desktop_feature_schema_validator.iter_errors( + _desktop_feature_with_variable(model_json) + ) + ) + + assert [e.message for e in errors] == [ + "None was expected", + f"{str(model_json['enum'])} is not valid under any of the given schemas", + ] + + assert tuple(errors[0].path) == ("variables", "variable", "enum") + + +@pytest.mark.parametrize( + "model_json,expected_error", + [ + ( + { + "description": "invalid enum (string options for int type)", + "type": "int", + "enum": ["hello"], + }, + "'hello' is not of type 'integer'", + ), + ( + { + "description": "invalid enum (int options for string type)", + "type": "string", + "enum": [1], + }, + "1 is not of type 'string'", + ), + ], +) +def test_desktop_feature_variable_invalid_enum_type_mismatch( + model_json, expected_error, desktop_feature_schema_validator +): + with pytest.raises( + pydantic.ValidationError, match="enum values do not match variable type" + ): + DesktopFeatureVariable.model_validate(model_json) + + errors = list( + desktop_feature_schema_validator.iter_errors( + _desktop_feature_with_variable(model_json) + ) + ) + + assert [e.message for e in errors] == [expected_error] + assert tuple(errors[0].path) == ("variables", "variable", "enum", 0) + + +def test_desktop_feature_variable_invalid_fallback_pref_set_pref_mutually_exclusive( + desktop_feature_schema_validator, +): + model_json = { + "description": "invalid variable (fallbackPref and setPref mutually exclusive)", + "type": "string", + "setPref": { + "branch": "user", + "pref": "foo.bar", + }, + "fallbackPref": "baz.qux", + } + + with pytest.raises( + pydantic.ValidationError, + match="fallback_pref and set_pref are mutually exclusive", + ): + DesktopFeatureVariable.model_validate(model_json) + + errors = list( + desktop_feature_schema_validator.iter_errors( + _desktop_feature_with_variable(model_json) + ) + ) + + assert [e.message for e in errors] == ["None was expected", "None was expected"] + assert tuple(errors[0].path) == ("variables", "variable", "setPref") + assert tuple(errors[1].path) == ("variables", "variable", "fallbackPref") diff --git a/schemas/package.json b/schemas/package.json index ff034cdff3..72412578e3 100644 --- a/schemas/package.json +++ b/schemas/package.json @@ -1,6 +1,6 @@ { "name": "@mozilla/nimbus-schemas", - "version": "2024.9.3", + "version": "2024.10.1", "description": "Schemas used by Mozilla Nimbus and related projects.", "main": "index.d.ts", "repository": { @@ -18,6 +18,7 @@ "typescript": "^5.1.6" }, "files": [ - "index.d.ts" + "index.d.ts", + "schemas" ] } diff --git a/schemas/poetry.lock b/schemas/poetry.lock index a9dcf2d3a1..d07e7248de 100644 --- a/schemas/poetry.lock +++ b/schemas/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.0 and should not be changed by hand. [[package]] name = "annotated-types" @@ -11,6 +11,25 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -484,6 +503,41 @@ files = [ test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] trio = ["async_generator", "trio"] +[[package]] +name = "jsonschema" +version = "4.23.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +files = [ + {file = "jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"}, + {file = "jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "keyring" version = "25.3.0" @@ -959,6 +1013,21 @@ Pygments = ">=2.5.1" [package.extras] md = ["cmarkgfm (>=0.8.0)"] +[[package]] +name = "referencing" +version = "0.35.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, + {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + [[package]] name = "requests" version = "2.32.3" @@ -1026,6 +1095,118 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "rpds-py" +version = "0.20.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, + {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"}, + {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"}, + {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"}, + {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"}, + {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"}, + {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"}, + {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"}, + {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"}, + {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"}, + {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"}, + {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"}, + {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"}, + {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"}, + {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"}, + {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"}, + {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"}, + {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"}, + {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"}, + {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"}, + {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"}, + {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"}, + {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"}, + {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"}, + {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, +] + [[package]] name = "ruff" version = "0.6.1" @@ -1173,4 +1354,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "9204c714a20e1d21cc566ac6df2dc43bdf410d72b5dae44afef822dda2dddd66" +content-hash = "0c9c045c39541914844c794ec731c3f52efba328be29b68d50056668cee3de37" diff --git a/schemas/pyproject.toml b/schemas/pyproject.toml index 46a36b1058..e45abdfee9 100644 --- a/schemas/pyproject.toml +++ b/schemas/pyproject.toml @@ -1,16 +1,19 @@ [tool.poetry] name = "mozilla-nimbus-schemas" -version = "2024.9.3" +version = "2024.10.1" description = "Schemas used by Mozilla Nimbus and related projects." authors = ["mikewilli"] license = "MPL 2.0" readme = "README.md" packages = [{ include = "mozilla_nimbus_schemas" }] +include = [{ path = "mozilla_nimbus_schemas/schemas", format = ["sdist", "wheel"] }] [tool.poetry.dependencies] python = "^3.10" pydantic = "^2" polyfactory = "^2.7.2" +typing-extensions = ">=4.0.1" # Required until Python 3.11 +jsonschema = "^4.23.0" [tool.poetry.group.dev.dependencies] ruff = ">=0.5.0,<0.6.2" @@ -19,6 +22,7 @@ pytest = "^7.3.1" twine = "^5.1.1" PyYAML = "^6.0" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/schemas/schemas/DesktopFeature.schema.json b/schemas/schemas/DesktopFeature.schema.json new file mode 100644 index 0000000000..59e9ad35b6 --- /dev/null +++ b/schemas/schemas/DesktopFeature.schema.json @@ -0,0 +1,265 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "DesktopFeature", + "description": "A feature.", + "type": "object", + "properties": { + "description": { + "description": "The description of the feature.", + "type": "string" + }, + "hasExposure": { + "description": "Whether or not this feature records exposure telemetry.", + "type": "boolean" + }, + "exposureDescription": { + "description": "A description of the exposure telemetry collected by this feature. Only required if hasExposure is true.", + "type": "string" + }, + "owner": { + "description": "The owner of the feature.", + "type": "string" + }, + "isEarlyStartup": { + "description": "If true, the feature values will be cached in prefs so that they can be read before Nimbus is initialized during Firefox startup.", + "type": "boolean" + }, + "applications": { + "description": "The applications that can enroll in experiments for this feature. Defaults to \"firefox-desktop\".", + "items": { + "$ref": "#/$defs/DesktopApplication" + }, + "minLength": 1, + "type": "array" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/DesktopFeatureVariable" + }, + "description": "The variables that this feature can set.", + "type": "object" + }, + "schema": { + "$ref": "#/$defs/NimbusFeatureSchema", + "description": "An optional JSON schema that describes the feature variables." + } + }, + "required": [ + "description", + "hasExposure", + "owner", + "variables" + ], + "if": { + "properties": { + "hasExposure": { + "const": true + } + } + }, + "then": { + "required": [ + "exposureDescription" + ] + }, + "$defs": { + "DesktopApplication": { + "enum": [ + "firefox-desktop", + "firefox-desktop-background-task" + ], + "type": "string" + }, + "DesktopFeatureVariable": { + "dependentSchemas": { + "enum": { + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "string" + } + } + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "string" + } + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "int" + } + } + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "integer" + } + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "boolean" + } + } + }, + "then": { + "properties": { + "enum": { + "const": null + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "json" + } + } + }, + "then": { + "properties": { + "enum": { + "const": null + } + } + } + } + ] + }, + "fallbackPref": { + "description": "setPref is mutually exclusive with fallbackPref", + "properties": { + "setPref": { + "const": null + } + } + }, + "setPref": { + "description": "fallbackPref is mutually exclusive with setPref", + "properties": { + "fallbackPref": { + "const": null + } + } + } + }, + "description": "A feature variable.", + "properties": { + "description": { + "description": "A description of the feature.", + "type": "string" + }, + "type": { + "$ref": "#/$defs/FeatureVariableType", + "description": "The field type." + }, + "enum": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "description": "An optional list of possible string or integer values. Only allowed when type is string or int. The types in the enum must match the type of the field." + }, + "fallbackPref": { + "description": "A pref that provides the default value for a feature when none is present.", + "type": "string" + }, + "setPref": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/SetPref" + } + ], + "description": "A pref that should be set to the value of this variable when enrolling in experiments. Using a string is deprecated and unsupported in Firefox 124+." + } + }, + "required": [ + "description", + "type" + ], + "type": "object" + }, + "FeatureVariableType": { + "enum": [ + "int", + "string", + "boolean", + "json" + ], + "type": "string" + }, + "NimbusFeatureSchema": { + "description": "Information about a JSON schema.", + "properties": { + "uri": { + "description": "The resource:// or chrome:// URI that can be loaded at runtime within Firefox. Required by Firefox so that Nimbus can import the schema for validation.", + "type": "string" + }, + "path": { + "description": "The path to the schema file in the source checkout. Required by Experimenter so that it can find schema files in source checkouts.", + "type": "string" + } + }, + "required": [ + "uri", + "path" + ], + "type": "object" + }, + "PrefBranch": { + "enum": [ + "default", + "user" + ], + "type": "string" + }, + "SetPref": { + "properties": { + "branch": { + "$ref": "#/$defs/PrefBranch", + "description": "The branch the pref will be set on. Prefs set on the user branch persists through restarts." + }, + "pref": { + "description": "The name of the pref to set.", + "type": "string" + } + }, + "required": [ + "branch", + "pref" + ], + "type": "object" + } + } +} diff --git a/schemas/schemas/DesktopFeatureManifest.schema.json b/schemas/schemas/DesktopFeatureManifest.schema.json new file mode 100644 index 0000000000..cb8ee01a1d --- /dev/null +++ b/schemas/schemas/DesktopFeatureManifest.schema.json @@ -0,0 +1,272 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "DesktopFeatureManifest", + "description": "The Firefox Desktop-specific feature manifest. Firefox Desktop requires different fields for its features compared to the general Nimbus feature manifest.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/DesktopFeature" + }, + "$defs": { + "DesktopApplication": { + "enum": [ + "firefox-desktop", + "firefox-desktop-background-task" + ], + "type": "string" + }, + "DesktopFeature": { + "description": "A feature.", + "if": { + "properties": { + "hasExposure": { + "const": true + } + } + }, + "properties": { + "description": { + "description": "The description of the feature.", + "type": "string" + }, + "hasExposure": { + "description": "Whether or not this feature records exposure telemetry.", + "type": "boolean" + }, + "exposureDescription": { + "description": "A description of the exposure telemetry collected by this feature. Only required if hasExposure is true.", + "type": "string" + }, + "owner": { + "description": "The owner of the feature.", + "type": "string" + }, + "isEarlyStartup": { + "description": "If true, the feature values will be cached in prefs so that they can be read before Nimbus is initialized during Firefox startup.", + "type": "boolean" + }, + "applications": { + "description": "The applications that can enroll in experiments for this feature. Defaults to \"firefox-desktop\".", + "items": { + "$ref": "#/$defs/DesktopApplication" + }, + "minLength": 1, + "type": "array" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/DesktopFeatureVariable" + }, + "description": "The variables that this feature can set.", + "type": "object" + }, + "schema": { + "$ref": "#/$defs/NimbusFeatureSchema", + "description": "An optional JSON schema that describes the feature variables." + } + }, + "required": [ + "description", + "hasExposure", + "owner", + "variables" + ], + "then": { + "required": [ + "exposureDescription" + ] + }, + "type": "object" + }, + "DesktopFeatureVariable": { + "dependentSchemas": { + "enum": { + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "string" + } + } + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "string" + } + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "int" + } + } + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "integer" + } + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "boolean" + } + } + }, + "then": { + "properties": { + "enum": { + "const": null + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "json" + } + } + }, + "then": { + "properties": { + "enum": { + "const": null + } + } + } + } + ] + }, + "fallbackPref": { + "description": "setPref is mutually exclusive with fallbackPref", + "properties": { + "setPref": { + "const": null + } + } + }, + "setPref": { + "description": "fallbackPref is mutually exclusive with setPref", + "properties": { + "fallbackPref": { + "const": null + } + } + } + }, + "description": "A feature variable.", + "properties": { + "description": { + "description": "A description of the feature.", + "type": "string" + }, + "type": { + "$ref": "#/$defs/FeatureVariableType", + "description": "The field type." + }, + "enum": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "description": "An optional list of possible string or integer values. Only allowed when type is string or int. The types in the enum must match the type of the field." + }, + "fallbackPref": { + "description": "A pref that provides the default value for a feature when none is present.", + "type": "string" + }, + "setPref": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/SetPref" + } + ], + "description": "A pref that should be set to the value of this variable when enrolling in experiments. Using a string is deprecated and unsupported in Firefox 124+." + } + }, + "required": [ + "description", + "type" + ], + "type": "object" + }, + "FeatureVariableType": { + "enum": [ + "int", + "string", + "boolean", + "json" + ], + "type": "string" + }, + "NimbusFeatureSchema": { + "description": "Information about a JSON schema.", + "properties": { + "uri": { + "description": "The resource:// or chrome:// URI that can be loaded at runtime within Firefox. Required by Firefox so that Nimbus can import the schema for validation.", + "type": "string" + }, + "path": { + "description": "The path to the schema file in the source checkout. Required by Experimenter so that it can find schema files in source checkouts.", + "type": "string" + } + }, + "required": [ + "uri", + "path" + ], + "type": "object" + }, + "PrefBranch": { + "enum": [ + "default", + "user" + ], + "type": "string" + }, + "SetPref": { + "properties": { + "branch": { + "$ref": "#/$defs/PrefBranch", + "description": "The branch the pref will be set on. Prefs set on the user branch persists through restarts." + }, + "pref": { + "description": "The name of the pref to set.", + "type": "string" + } + }, + "required": [ + "branch", + "pref" + ], + "type": "object" + } + } +} diff --git a/schemas/schemas/NimbusExperiment.schema.json b/schemas/schemas/NimbusExperiment.schema.json new file mode 100644 index 0000000000..4027b1ca0b --- /dev/null +++ b/schemas/schemas/NimbusExperiment.schema.json @@ -0,0 +1,390 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "NimbusExperiment", + "description": "The experiment definition accessible to: 1. The Nimbus SDK via Remote Settings 2. Jetstream via the Experimenter API", + "type": "object", + "properties": { + "schemaVersion": { + "description": "Version of the NimbusExperiment schema this experiment refers to", + "type": "string" + }, + "slug": { + "description": "Unique identifier for the experiment", + "type": "string" + }, + "id": { + "description": "Unique identifier for the experiiment. This is a duplicate of slug, but is required field for all Remote Settings records.", + "type": "string" + }, + "appName": { + "description": "A slug identifying the targeted product of this experiment. It should be a lowercased_with_underscores name that is short and unambiguous and it should match the app_name found in https://probeinfo.telemetry.mozilla.org/glean/repositories. Examples are \"fenix\" and \"firefox_desktop\".", + "type": "string" + }, + "appId": { + "description": "The platform identifier for the targeted app. This should match app's identifier exactly as it appears in the relevant app store listing (for relevant platforms) or the app's Glean initialization (for other platforms). Examples are \"org.mozilla.firefox_beta\" and \"firefox-desktop\".", + "type": "string" + }, + "channel": { + "description": "A specific channel of an application such as \"nightly\", \"beta\", or \"release\".", + "type": "string" + }, + "userFacingName": { + "description": "Public name of the experiment that will be displayed on \"about:studies\".", + "type": "string" + }, + "userFacingDescription": { + "description": "Short public description of the experiment that will be displayed on \"about:studies\".", + "type": "string" + }, + "isEnrollmentPaused": { + "description": "When this property is set to true, the SDK should not enroll new users into the experiment that have not already been enrolled.", + "type": "boolean" + }, + "isRollout": { + "description": "When this property is set to true, treat this experiment as a rollout. Rollouts are currently handled as single-branch experiments separated from the bucketing namespace for normal experiments. See-also: https://mozilla-hub.atlassian.net/browse/SDK-405", + "type": "boolean" + }, + "bucketConfig": { + "$ref": "#/$defs/ExperimentBucketConfig", + "description": "Bucketing configuration." + }, + "outcomes": { + "description": "A list of outcomes relevant to the experiment analysis.", + "items": { + "$ref": "#/$defs/ExperimentOutcome" + }, + "type": "array" + }, + "featureIds": { + "description": "A list of featureIds the experiment contains configurations for.", + "items": { + "type": "string" + }, + "type": "array" + }, + "branches": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/ExperimentSingleFeatureBranch" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/$defs/ExperimentMultiFeatureDesktopBranch" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/$defs/ExperimentMultiFeatureMobileBranch" + }, + "type": "array" + } + ], + "description": "Branch configuration for the experiment." + }, + "targeting": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A JEXL targeting expression used to filter out experiments." + }, + "startDate": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Actual publish date of the experiment. Note that this value is expected to be null in Remote Settings." + }, + "enrollmentEndDate": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Actual enrollment end date of the experiment. Note that this value is expected to be null in Remote Settings." + }, + "endDate": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Actual end date of this experiment. Note that this field is expected to be null in Remote Settings." + }, + "proposedDuration": { + "description": "Duration of the experiment from the start date in days. Note that this property is only used during the analysis phase (i.e., not by the SDK).", + "type": "integer" + }, + "proposedEnrollment": { + "description": "This represents the number of days that we expect to enroll new users. Note that this property is only used during the analysis phase (i.e., not by the SDK).", + "type": "integer" + }, + "referenceBranch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The slug of the reference branch (i.e., the branch we consider \"control\")." + }, + "featureValidationOptOut": { + "description": "Opt out of feature schema validation. Only supported on desktop.", + "type": "boolean" + }, + "localizations": { + "anyOf": [ + { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Per-locale localization substitutions. The top level key is the locale (e.g., \"en-US\" or \"fr\"). Each entry is a mapping of string IDs to their localized equivalents. Only supported on desktop." + }, + "locales": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The list of locale codes (e.g., \"en-US\" or \"fr\") that this experiment is targeting. If null, all locales are targeted." + }, + "publishedDate": { + "description": "The date that this experiment was first published to Remote Settings. If null, it has not yet been published.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "slug", + "id", + "appName", + "appId", + "channel", + "userFacingName", + "userFacingDescription", + "isEnrollmentPaused", + "bucketConfig", + "branches", + "startDate", + "endDate", + "proposedEnrollment", + "referenceBranch" + ], + "$defs": { + "DesktopTombstoneFeatureConfig": { + "properties": { + "featureId": { + "const": "unused-feature-id-for-legacy-support", + "type": "string" + }, + "value": { + "type": "object" + }, + "enabled": { + "const": false, + "type": "boolean" + } + }, + "required": [ + "featureId", + "value", + "enabled" + ], + "type": "object" + }, + "ExperimentBucketConfig": { + "properties": { + "randomizationUnit": { + "$ref": "#/$defs/RandomizationUnit" + }, + "namespace": { + "description": "Additional inputs to the hashing function.", + "type": "string" + }, + "start": { + "description": "Index of the starting bucket of the range.", + "type": "integer" + }, + "count": { + "description": "Number of buckets in the range.", + "type": "integer" + }, + "total": { + "description": "The total number of buckets. You can assume this will always be 10000", + "type": "integer" + } + }, + "required": [ + "randomizationUnit", + "namespace", + "start", + "count", + "total" + ], + "type": "object" + }, + "ExperimentFeatureConfig": { + "properties": { + "featureId": { + "description": "The identifier for the feature flag.", + "type": "string" + }, + "value": { + "description": "The values that define the feature configuration. This should be validated against a schema.", + "type": "object" + } + }, + "required": [ + "featureId", + "value" + ], + "type": "object" + }, + "ExperimentMultiFeatureDesktopBranch": { + "description": "The branch definition supported on Firefox Desktop 95+.", + "properties": { + "slug": { + "description": "Identifier for the branch.", + "type": "string" + }, + "ratio": { + "description": "Relative ratio of population for the branch. e.g., if branch A=1 and branch B=3, then branch A would get 25% of the population.", + "type": "integer" + }, + "features": { + "description": "An array of feature configurations.", + "items": { + "$ref": "#/$defs/ExperimentFeatureConfig" + }, + "type": "array" + }, + "feature": { + "$ref": "#/$defs/DesktopTombstoneFeatureConfig", + "description": "The feature key must be provided with values to prevent crashes if the is encountered by Desktop clients earlier than version 95." + } + }, + "required": [ + "slug", + "ratio", + "features", + "feature" + ], + "type": "object" + }, + "ExperimentMultiFeatureMobileBranch": { + "description": "The branch definition for mobile browsers. Supported on Firefox for Android 96+ and Firefox for iOS 39+.", + "properties": { + "slug": { + "description": "Identifier for the branch.", + "type": "string" + }, + "ratio": { + "description": "Relative ratio of population for the branch. e.g., if branch A=1 and branch B=3, then branch A would get 25% of the population.", + "type": "integer" + }, + "features": { + "description": "An array of feature configurations.", + "items": { + "$ref": "#/$defs/ExperimentFeatureConfig" + }, + "type": "array" + } + }, + "required": [ + "slug", + "ratio", + "features" + ], + "type": "object" + }, + "ExperimentOutcome": { + "properties": { + "slug": { + "description": "Identifier for the outcome.", + "type": "string" + }, + "priority": { + "description": "e.g., \"primary\" or \"secondary\".", + "type": "string" + } + }, + "required": [ + "slug", + "priority" + ], + "type": "object" + }, + "ExperimentSingleFeatureBranch": { + "description": "A single-feature branch definition. Supported by Firefox Desktop for versions before 95, Firefox for Android for versions before 96, and Firefox for iOS for versions before 39.", + "properties": { + "slug": { + "description": "Identifier for the branch.", + "type": "string" + }, + "ratio": { + "description": "Relative ratio of population for the branch. e.g., if branch A=1 and branch B=3, then branch A would get 25% of the population.", + "type": "integer" + }, + "feature": { + "$ref": "#/$defs/ExperimentFeatureConfig", + "description": "A single feature configuration." + } + }, + "required": [ + "slug", + "ratio", + "feature" + ], + "type": "object" + }, + "RandomizationUnit": { + "description": "A unique, stable indentifier for the user used as an input to bucket hashing.", + "enum": [ + "normandy_id", + "nimbus_id", + "user_id", + "group_id" + ], + "type": "string" + } + } +} diff --git a/schemas/schemas/SdkFeatureManifest.schema.json b/schemas/schemas/SdkFeatureManifest.schema.json new file mode 100644 index 0000000000..fc74fca933 --- /dev/null +++ b/schemas/schemas/SdkFeatureManifest.schema.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "SdkFeatureManifest", + "description": "The SDK-specific feature manifest.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SdkFeature" + }, + "$defs": { + "FeatureVariableType": { + "enum": [ + "int", + "string", + "boolean", + "json" + ], + "type": "string" + }, + "SdkFeature": { + "description": "A feature.", + "if": { + "properties": { + "hasExposure": { + "const": true + } + } + }, + "properties": { + "description": { + "description": "The description of the feature.", + "type": "string" + }, + "hasExposure": { + "description": "Whether or not this feature records exposure telemetry.", + "type": "boolean" + }, + "exposureDescription": { + "description": "A description of the exposure telemetry collected by this feature. Only required if hasExposure is true.", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/SdkFeatureVariable" + }, + "description": "The variables that this feature can set.", + "type": "object" + } + }, + "required": [ + "description", + "hasExposure", + "variables" + ], + "then": { + "required": [ + "exposureDescription" + ] + }, + "type": "object" + }, + "SdkFeatureVariable": { + "dependentSchemas": { + "enum": { + "properties": { + "type": { + "const": "string" + } + } + } + }, + "description": "A feature variable.", + "properties": { + "description": { + "description": "A description of the feature.", + "type": "string" + }, + "type": { + "$ref": "#/$defs/FeatureVariableType", + "description": "The field type." + }, + "enum": { + "description": "An optional list of possible string values. Only allowed when type is string.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "description", + "type" + ], + "type": "object" + } + } +}