generator.spoke

Generate directories and models for new namespaces.

  1"""Generate directories and models for new namespaces."""
  2
  3import logging
  4import os
  5import shutil
  6from collections import defaultdict
  7from pathlib import Path
  8from typing import Dict, List, TypedDict
  9
 10import click
 11import lkml
 12import looker_sdk
 13import yaml
 14
 15from .lookml import ViewDict
 16
 17MODEL_SETS_BY_INSTANCE: Dict[str, List[str]] = {
 18    "https://mozilladev.cloud.looker.com": ["mozilla_confidential"],
 19    "https://mozillastaging.cloud.looker.com": ["mozilla_confidential"],
 20    "https://mozilla.cloud.looker.com": ["mozilla_confidential"],
 21}
 22
 23DEFAULT_DB_CONNECTION = "telemetry"
 24
 25
 26class ExploreDict(TypedDict):
 27    """Represent an explore definition."""
 28
 29    type: str
 30    views: List[Dict[str, str]]
 31
 32
 33class NamespaceDict(TypedDict):
 34    """Represent a Namespace definition."""
 35
 36    views: ViewDict
 37    explores: ExploreDict
 38    pretty_name: str
 39    glean_app: bool
 40    connection: str
 41    spoke: str
 42
 43
 44def setup_env_with_looker_creds() -> bool:
 45    """
 46    Set up env with looker credentials.
 47
 48    Returns TRUE if the config is complete.
 49    """
 50    client_id = os.environ.get("LOOKER_API_CLIENT_ID")
 51    client_secret = os.environ.get("LOOKER_API_CLIENT_SECRET")
 52    instance = os.environ.get("LOOKER_INSTANCE_URI")
 53
 54    if client_id is None or client_secret is None or instance is None:
 55        return False
 56
 57    os.environ["LOOKERSDK_BASE_URL"] = instance
 58    os.environ["LOOKERSDK_API_VERSION"] = "4.0"
 59    os.environ["LOOKERSDK_VERIFY_SSL"] = "true"
 60    os.environ["LOOKERSDK_TIMEOUT"] = "120"
 61    os.environ["LOOKERSDK_CLIENT_ID"] = client_id
 62    os.environ["LOOKERSDK_CLIENT_SECRET"] = client_secret
 63
 64    return True
 65
 66
 67def generate_model(
 68    spoke_path: Path, name: str, namespace_defn: NamespaceDict, db_connection: str
 69) -> Path:
 70    """
 71    Generate a model file for a namespace.
 72
 73    We want these to have a nice label and a unique name.
 74    We only import explores and dashboards, as we want those
 75    to auto-import upon generation.
 76
 77    Views are not imported by default, since they should
 78    be added one-by-one if they are included in an explore.
 79    """
 80    logging.info(f"Generating model {name}...")
 81    model_defn = {
 82        "connection": db_connection,
 83        "label": namespace_defn["pretty_name"],
 84    }
 85
 86    # automatically import generated explores for new glean apps
 87    has_explores = len(namespace_defn.get("explores", {})) > 0
 88
 89    path = spoke_path / name / f"{name}.model.lkml"
 90    # lkml.dump may return None, in which case write an empty file
 91    footer_text = f"""
 92# Include files from looker-hub or spoke-default below. For example:
 93{'' if has_explores else '# '}include: "//looker-hub/{name}/explores/*"
 94# include: "//looker-hub/{name}/dashboards/*"
 95# include: "views/*"
 96# include: "explores/*"
 97# include: "dashboards/*"
 98"""
 99    model_text = lkml.dump(model_defn)
100    if model_text is None:
101        path.write_text("")
102    else:
103        path.write_text(model_text + footer_text)
104
105    return path
106
107
108def configure_model(
109    sdk: looker_sdk.methods40.Looker40SDK,
110    model_name: str,
111    db_connection: str,
112    spoke_project: str,
113):
114    """Configure a Looker model by name."""
115    instance = os.environ["LOOKER_INSTANCE_URI"]
116    logging.info(f"Configuring model {model_name}...")
117
118    try:
119        sdk.lookml_model(model_name)
120        logging.info("Model is configured!")
121        return
122    except looker_sdk.error.SDKError:
123        pass
124
125    sdk.create_lookml_model(
126        looker_sdk.models40.WriteLookmlModel(
127            allowed_db_connection_names=[db_connection],
128            name=model_name,
129            project_name=spoke_project,
130        )
131    )
132
133    for model_set_name in MODEL_SETS_BY_INSTANCE[instance]:
134        model_sets = sdk.search_model_sets(name=model_set_name)
135        if len(model_sets) != 1:
136            raise click.ClickException("Error: Found more than one matching model set")
137
138        model_set = model_sets[0]
139        models, _id = model_set.models, model_set.id
140        if models is None or _id is None:
141            raise click.ClickException("Error: Missing models or name from model_set")
142
143        sdk.update_model_set(
144            _id, looker_sdk.models40.WriteModelSet(models=list(models) + [model_name])
145        )
146
147
148def generate_directories(
149    namespaces: Dict[str, NamespaceDict], base_dir: Path, sdk_setup=False
150):
151    """Generate directories and model for a namespace, if it doesn't exist."""
152    seen_spoke_namespaces = defaultdict(list)
153    for namespace, defn in namespaces.items():
154        spoke = defn["spoke"]
155        seen_spoke_namespaces[spoke].append(namespace)
156
157        spoke_dir = base_dir / spoke
158        spoke_dir.mkdir(parents=True, exist_ok=True)
159        print(f"Writing {namespace} to {spoke_dir}")
160        existing_dirs = {p.name for p in spoke_dir.iterdir()}
161
162        if namespace in existing_dirs:
163            continue
164
165        (spoke_dir / namespace).mkdir()
166        for dirname in ("views", "explores", "dashboards"):
167            (spoke_dir / namespace / dirname).mkdir()
168            (spoke_dir / namespace / dirname / ".gitkeep").touch()
169
170        db_connection: str = defn.get("connection", DEFAULT_DB_CONNECTION)
171        generate_model(spoke_dir, namespace, defn, db_connection)
172
173        if sdk_setup:
174            spoke_project = spoke.lstrip("looker-")
175            sdk = looker_sdk.init40()
176            logging.info("Looker SDK 4.0 initialized successfully.")
177            configure_model(sdk, namespace, db_connection, spoke_project)
178
179    # remove directories for namespaces that got removed
180    for spoke in seen_spoke_namespaces.keys():
181        spoke_dir = base_dir / spoke
182        existing_dirs = {p.name for p in spoke_dir.iterdir()}
183
184        for existing_dir in existing_dirs:
185            # make sure the directory belongs to a namespace by checking if a model file exists
186            if (spoke_dir / existing_dir / f"{existing_dir}.model.lkml").is_file():
187                if existing_dir not in seen_spoke_namespaces[spoke]:
188                    # namespace does not exists anymore, remove directory
189                    print(f"Removing {existing_dir} from {spoke_dir}")
190                    shutil.rmtree(spoke_dir / existing_dir)
191
192
193@click.command(help=__doc__)
194@click.option(
195    "--namespaces",
196    default="namespaces.yaml",
197    type=click.File(),
198    help="Path to the namespaces.yaml file.",
199)
200@click.option(
201    "--spoke-dir",
202    default=".",
203    type=click.Path(file_okay=False, dir_okay=True, writable=True),
204    help="Directory containing the Looker spoke.",
205)
206def update_spoke(namespaces, spoke_dir):
207    """Generate updates to spoke project."""
208    _namespaces = yaml.safe_load(namespaces)
209    sdk_setup = setup_env_with_looker_creds()
210    generate_directories(_namespaces, Path(spoke_dir), sdk_setup)
MODEL_SETS_BY_INSTANCE: Dict[str, List[str]] = {'https://mozilladev.cloud.looker.com': ['mozilla_confidential'], 'https://mozillastaging.cloud.looker.com': ['mozilla_confidential'], 'https://mozilla.cloud.looker.com': ['mozilla_confidential']}
DEFAULT_DB_CONNECTION = 'telemetry'
class ExploreDict(typing.TypedDict):
27class ExploreDict(TypedDict):
28    """Represent an explore definition."""
29
30    type: str
31    views: List[Dict[str, str]]

Represent an explore definition.

type: str
views: List[Dict[str, str]]
class NamespaceDict(typing.TypedDict):
34class NamespaceDict(TypedDict):
35    """Represent a Namespace definition."""
36
37    views: ViewDict
38    explores: ExploreDict
39    pretty_name: str
40    glean_app: bool
41    connection: str
42    spoke: str

Represent a Namespace definition.

explores: ExploreDict
pretty_name: str
glean_app: bool
connection: str
spoke: str
def setup_env_with_looker_creds() -> bool:
45def setup_env_with_looker_creds() -> bool:
46    """
47    Set up env with looker credentials.
48
49    Returns TRUE if the config is complete.
50    """
51    client_id = os.environ.get("LOOKER_API_CLIENT_ID")
52    client_secret = os.environ.get("LOOKER_API_CLIENT_SECRET")
53    instance = os.environ.get("LOOKER_INSTANCE_URI")
54
55    if client_id is None or client_secret is None or instance is None:
56        return False
57
58    os.environ["LOOKERSDK_BASE_URL"] = instance
59    os.environ["LOOKERSDK_API_VERSION"] = "4.0"
60    os.environ["LOOKERSDK_VERIFY_SSL"] = "true"
61    os.environ["LOOKERSDK_TIMEOUT"] = "120"
62    os.environ["LOOKERSDK_CLIENT_ID"] = client_id
63    os.environ["LOOKERSDK_CLIENT_SECRET"] = client_secret
64
65    return True

Set up env with looker credentials.

Returns TRUE if the config is complete.

def generate_model( spoke_path: pathlib.Path, name: str, namespace_defn: NamespaceDict, db_connection: str) -> pathlib.Path:
 68def generate_model(
 69    spoke_path: Path, name: str, namespace_defn: NamespaceDict, db_connection: str
 70) -> Path:
 71    """
 72    Generate a model file for a namespace.
 73
 74    We want these to have a nice label and a unique name.
 75    We only import explores and dashboards, as we want those
 76    to auto-import upon generation.
 77
 78    Views are not imported by default, since they should
 79    be added one-by-one if they are included in an explore.
 80    """
 81    logging.info(f"Generating model {name}...")
 82    model_defn = {
 83        "connection": db_connection,
 84        "label": namespace_defn["pretty_name"],
 85    }
 86
 87    # automatically import generated explores for new glean apps
 88    has_explores = len(namespace_defn.get("explores", {})) > 0
 89
 90    path = spoke_path / name / f"{name}.model.lkml"
 91    # lkml.dump may return None, in which case write an empty file
 92    footer_text = f"""
 93# Include files from looker-hub or spoke-default below. For example:
 94{'' if has_explores else '# '}include: "//looker-hub/{name}/explores/*"
 95# include: "//looker-hub/{name}/dashboards/*"
 96# include: "views/*"
 97# include: "explores/*"
 98# include: "dashboards/*"
 99"""
100    model_text = lkml.dump(model_defn)
101    if model_text is None:
102        path.write_text("")
103    else:
104        path.write_text(model_text + footer_text)
105
106    return path

Generate a model file for a namespace.

We want these to have a nice label and a unique name. We only import explores and dashboards, as we want those to auto-import upon generation.

Views are not imported by default, since they should be added one-by-one if they are included in an explore.

def configure_model( sdk: looker_sdk.sdk.api40.methods.Looker40SDK, model_name: str, db_connection: str, spoke_project: str):
109def configure_model(
110    sdk: looker_sdk.methods40.Looker40SDK,
111    model_name: str,
112    db_connection: str,
113    spoke_project: str,
114):
115    """Configure a Looker model by name."""
116    instance = os.environ["LOOKER_INSTANCE_URI"]
117    logging.info(f"Configuring model {model_name}...")
118
119    try:
120        sdk.lookml_model(model_name)
121        logging.info("Model is configured!")
122        return
123    except looker_sdk.error.SDKError:
124        pass
125
126    sdk.create_lookml_model(
127        looker_sdk.models40.WriteLookmlModel(
128            allowed_db_connection_names=[db_connection],
129            name=model_name,
130            project_name=spoke_project,
131        )
132    )
133
134    for model_set_name in MODEL_SETS_BY_INSTANCE[instance]:
135        model_sets = sdk.search_model_sets(name=model_set_name)
136        if len(model_sets) != 1:
137            raise click.ClickException("Error: Found more than one matching model set")
138
139        model_set = model_sets[0]
140        models, _id = model_set.models, model_set.id
141        if models is None or _id is None:
142            raise click.ClickException("Error: Missing models or name from model_set")
143
144        sdk.update_model_set(
145            _id, looker_sdk.models40.WriteModelSet(models=list(models) + [model_name])
146        )

Configure a Looker model by name.

def generate_directories( namespaces: Dict[str, NamespaceDict], base_dir: pathlib.Path, sdk_setup=False):
149def generate_directories(
150    namespaces: Dict[str, NamespaceDict], base_dir: Path, sdk_setup=False
151):
152    """Generate directories and model for a namespace, if it doesn't exist."""
153    seen_spoke_namespaces = defaultdict(list)
154    for namespace, defn in namespaces.items():
155        spoke = defn["spoke"]
156        seen_spoke_namespaces[spoke].append(namespace)
157
158        spoke_dir = base_dir / spoke
159        spoke_dir.mkdir(parents=True, exist_ok=True)
160        print(f"Writing {namespace} to {spoke_dir}")
161        existing_dirs = {p.name for p in spoke_dir.iterdir()}
162
163        if namespace in existing_dirs:
164            continue
165
166        (spoke_dir / namespace).mkdir()
167        for dirname in ("views", "explores", "dashboards"):
168            (spoke_dir / namespace / dirname).mkdir()
169            (spoke_dir / namespace / dirname / ".gitkeep").touch()
170
171        db_connection: str = defn.get("connection", DEFAULT_DB_CONNECTION)
172        generate_model(spoke_dir, namespace, defn, db_connection)
173
174        if sdk_setup:
175            spoke_project = spoke.lstrip("looker-")
176            sdk = looker_sdk.init40()
177            logging.info("Looker SDK 4.0 initialized successfully.")
178            configure_model(sdk, namespace, db_connection, spoke_project)
179
180    # remove directories for namespaces that got removed
181    for spoke in seen_spoke_namespaces.keys():
182        spoke_dir = base_dir / spoke
183        existing_dirs = {p.name for p in spoke_dir.iterdir()}
184
185        for existing_dir in existing_dirs:
186            # make sure the directory belongs to a namespace by checking if a model file exists
187            if (spoke_dir / existing_dir / f"{existing_dir}.model.lkml").is_file():
188                if existing_dir not in seen_spoke_namespaces[spoke]:
189                    # namespace does not exists anymore, remove directory
190                    print(f"Removing {existing_dir} from {spoke_dir}")
191                    shutil.rmtree(spoke_dir / existing_dir)

Generate directories and model for a namespace, if it doesn't exist.

update_spoke = <Command update-spoke>

Generate updates to spoke project.