Skip to content

Commit

Permalink
feat(schemas): generate .schema.json files for experimenter schemas
Browse files Browse the repository at this point in the history
Because
- we need the .schema.json files in Desktop;
- they are not currently packaged; and
- the FeatureManifest schema did not match the existing .schema.json
  file in Desktop

This commit:
- updates the FeatureManifest schema to match Firefox Desktop;
- generates .schema.json files for experimenter schemas;
- adds the .schema.json files to the npm package; and
- updates the schemas version to 2024.10.1.
  • Loading branch information
brennie committed Oct 15, 2024
1 parent 90f792c commit 70ddbfc
Show file tree
Hide file tree
Showing 12 changed files with 1,559 additions and 91 deletions.
2 changes: 1 addition & 1 deletion schemas/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2024.9.3
2024.10.1
124 changes: 122 additions & 2 deletions schemas/generate_json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
"""

import json
import re
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
from pydantic import BaseModel, create_model

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:
Expand Down Expand Up @@ -98,6 +101,114 @@ 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": "http://json-schema.org/draft-07/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",
"$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):
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()


@click.command()
@click.option(
"--output",
Expand All @@ -106,7 +217,14 @@ 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.",
)
def main(*, ts_output_path: Path, json_schemas_path: Path):
json_schema = iterate_models()

with TemporaryDirectory() as tmp_dir:
Expand All @@ -132,6 +250,8 @@ def main(*, ts_output_path: Path):

clean_output_file(ts_output_path)

write_json_schemas(json_schemas_path)


if __name__ == "__main__":
main()
169 changes: 130 additions & 39 deletions schemas/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -31,6 +31,110 @@ export type SizingMetricName = "active_hours" | "search_count" | "days_of_use" |
export type StatisticIngestEnum = "percentage" | "binomial" | "mean" | "count";
export type Statistics = Statistic[];

/**
* A Firefox Desktop-specific feature.
*
* Firefox Desktop requires different fields for its features compared to general purpose
* Nimbus features.
*/
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:
*
Expand Down Expand Up @@ -106,7 +210,10 @@ export interface NimbusExperiment {
| ExperimentSingleFeatureBranch[]
| ExperimentMultiFeatureDesktopBranch[]
| ExperimentMultiFeatureMobileBranch[];
targeting?: string | null;
/**
* A JEXL targeting expression used to filter out experiments.
*/
targeting?: string;
/**
* Actual publish date of the experiment.
*
Expand Down Expand Up @@ -282,48 +389,32 @@ export interface ExperimentMultiFeatureMobileBranch {
*/
features: ExperimentFeatureConfig[];
}
export interface FeatureManifest {
[k: string]: Feature;
}
/**
* A feature that has exposure.
* The SDK-specific feature manifest.
*/
export interface FeatureWithExposure {
description?: string | null;
isEarlyStartup?: boolean | null;
export interface SdkFeatureManifest {
[k: string]: SdkFeature;
}
export interface SdkFeature {
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;
Expand Down
10 changes: 8 additions & 2 deletions schemas/mozilla_nimbus_schemas/experiments/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
)
Loading

0 comments on commit 70ddbfc

Please sign in to comment.