Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,12 +331,9 @@ The `lkr` CLI supports optional dependencies that enable additional functionalit

### Available Extras

- **`mcp`**: Enables the MCP (Model Context Protocol) server functionality
- Includes: `mcp[cli]>=1.9.2`, `duckdb>=1.2.2`
- **`embed-observability`**: Enables the observability embed monitoring features
- Includes: `fastapi>=0.115.12`, `selenium>=4.32.0`
- **`user-attribute-updater`**: Enables the user attribute updater functionality
- Includes: `fastapi>=0.115.12`
- **`mcp`**: Enables the MCP (Model Context Protocol) server functionality and `lkr mcp` commands
- **`observability`**: Enables the observability embed monitoring features and `lkr observability` commands
- **`tools`**: Enables the user attribute updater functionality and `lkr tools` commands

### Installing Optional Dependencies

Expand Down Expand Up @@ -368,11 +365,3 @@ pip install lkr-dev-cli[all]
# Install specific extras
pip install lkr-dev-cli[mcp,embed-observability,user-attribute-updater]
```

### What Each Extra Enables

- **`mcp`**: Use the MCP server with tools like Cursor for enhanced IDE integration
- **`embed-observability`**: Run the observability embed server for monitoring Looker dashboard performance
- **`user-attribute-updater`**: Deploy the user attribute updater service for OIDC token management

All extras are designed to work together seamlessly, and installing `all` is equivalent to installing all individual extras.
22 changes: 17 additions & 5 deletions lkr/auth/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import urllib.parse
from typing import Annotated, List, Union

import questionary
import typer
from looker_sdk.rtl.auth_token import AccessToken, AuthToken
from rich.console import Console
from rich.table import Table

from lkr.auth.oauth import OAuth2PKCE
from lkr.auth_service import get_auth
from lkr.logger import logger

QUESTIONARY_AVAILABLE = True
RICH_AVAILABLE = True
try:
import questionary
except ModuleNotFoundError:
QUESTIONARY_AVAILABLE = False
try:
from rich.console import Console
from rich.table import Table
except ModuleNotFoundError:
RICH_AVAILABLE = False

__all__ = ["group"]

group = typer.Typer(name="auth", help="Authentication commands for LookML Repository")
Expand Down Expand Up @@ -199,7 +208,7 @@ def list(ctx: typer.Context):
"""
List all authenticated Looker instances
"""
console = Console()
console = Console() if RICH_AVAILABLE else None
auth = get_auth(ctx)
all_instances = auth.list_auth()
if not all_instances:
Expand All @@ -213,7 +222,10 @@ def list(ctx: typer.Context):
instance[1],
"Yes" if instance[3] else "No",
)
console.print(table)
if console:
console.print(table)
else:
print(table)


if __name__ == "__main__":
Expand Down
7 changes: 4 additions & 3 deletions lkr/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import sqlite3
import types
from datetime import datetime, timedelta, timezone
from typing import List, Self, Tuple, Union
from typing import List, Self, Tuple, Union, TYPE_CHECKING

import requests
import typer
if TYPE_CHECKING:
import typer
from looker_sdk.rtl import serialize
from looker_sdk.rtl.api_settings import ApiSettings, SettingsConfig
from looker_sdk.rtl.auth_session import AuthSession, CryptoHash, OAuthSession
Expand All @@ -25,7 +26,7 @@
__all__ = ["get_auth", "ApiKeyAuthSession", "DbOAuthSession"]


def get_auth(ctx: typer.Context | LkrCtxObj) -> Union["SqlLiteAuth", "ApiKeyAuth"]:
def get_auth(ctx: Union["typer.Context", LkrCtxObj]) -> Union["SqlLiteAuth", "ApiKeyAuth"]:
if isinstance(ctx, LkrCtxObj):
lkr_ctx = ctx
else:
Expand Down
55 changes: 33 additions & 22 deletions lkr/logger.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import logging
import os
from lkr.custom_types import LogLevel

import structlog
from rich.console import Console
from rich.logging import RichHandler
from rich.theme import Theme
STRUCT_LOG_AVAILABLE = True
RICH_AVAILABLE = True
try:
import structlog
except ModuleNotFoundError:
STRUCT_LOG_AVAILABLE = False
try:
from rich.console import Console
from rich.logging import RichHandler
from rich.theme import Theme
except ModuleNotFoundError:
RICH_AVAILABLE = False

from lkr.custom_types import LogLevel

structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
]
)
if STRUCT_LOG_AVAILABLE:
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
]
)

# Define a custom theme for our logging
theme = Theme(
Expand All @@ -24,10 +33,10 @@
"logging.level.error": "bold red",
"logging.level.critical": "bold white on red",
}
)
) if RICH_AVAILABLE else None

# Create a console for logging
console = Console(theme=theme)
console = Console(theme=theme) if RICH_AVAILABLE else None

# Configure the logging handler
handler = RichHandler(
Expand All @@ -37,7 +46,7 @@
markup=True,
rich_tracebacks=True,
tracebacks_show_locals=True,
)
) if RICH_AVAILABLE else None

# Get log level from environment variable, defaulting to INFO
DEFAULT_LOG_LEVEL = "INFO"
Expand All @@ -50,25 +59,27 @@
), # Fallback to INFO if invalid level
format="%(message)s",
datefmt="[%X]",
handlers=[handler],
handlers=[handler] if handler else [],
)

# Create a logger for the application
logger = logging.getLogger("lkr")
structured_logger = structlog.get_logger("lkr.structured")
structured_logger = structlog.get_logger("lkr.structured") if STRUCT_LOG_AVAILABLE else None


# Configure the requests_transport logger to only show debug messages when LOG_LEVEL is DEBUG
requests_logger = logging.getLogger("looker_sdk.rtl.requests_transport")
if log_level != "DEBUG":
requests_logger = logging.getLogger("looker_sdk.rtl.requests_transport") if RICH_AVAILABLE else None
if log_level != "DEBUG" and requests_logger:
requests_logger.setLevel(logging.WARNING)


def set_log_level(level: LogLevel):
"""Set the logging level for the application."""
logger.setLevel(getattr(logging, level.value))
logging.getLogger("lkr.structured").setLevel(getattr(logging, level.value))
if structured_logger:
structured_logger.setLevel(getattr(logging, level.value))
# Update requests_transport logger level based on the new level
requests_logger.setLevel(
logging.DEBUG if level == LogLevel.DEBUG else logging.WARNING
)
if requests_logger:
requests_logger.setLevel(
logging.DEBUG if level == LogLevel.DEBUG else logging.WARNING
)
31 changes: 25 additions & 6 deletions lkr/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
from lkr.classes import LkrCtxObj
from lkr.custom_types import LogLevel
from lkr.logger import logger
from lkr.mcp.main import group as mcp_group
from lkr.observability.main import group as observability_group
from lkr.tools.main import group as tools_group

app = typer.Typer(
name="lkr",
Expand All @@ -19,10 +16,32 @@
)

app.add_typer(auth_group, name="auth")
app.add_typer(mcp_group, name="mcp")
app.add_typer(observability_group, name="observability")
app.add_typer(tools_group, name="tools")

IMPORT_ERROR = None

def add_optional_typer_group(app, import_path, group_name, extra_message=None):
try:
module_path, attr = import_path.rsplit(".", 1)
mod = __import__(module_path, fromlist=[attr])
group = getattr(mod, attr)
app.add_typer(group, name=group_name)
except ModuleNotFoundError as import_error:
@app.command(
name=group_name,
add_help_option=False,
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
)
def fallback(import_error=import_error):
msg = f"{group_name} tools (dependencies not available, try installing optional dependencies: lkr-dev-cli\\[{group_name}])"
if extra_message:
msg += f" {extra_message}"
logger.error(msg)
logger.error(import_error)
raise typer.Exit(1)

add_optional_typer_group(app, "lkr.mcp.main.group", "mcp")
add_optional_typer_group(app, "lkr.observability.main.group", "observability")
add_optional_typer_group(app, "lkr.tools.main.group", "tools")

@app.callback()
def callback(
Expand Down
2 changes: 2 additions & 0 deletions lkr/observability/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@

observability_ctx = ObservabilityCtxObj()

if not structured_logger:
raise Exception("Structured logger is not available")
Comment on lines +42 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The structured_logger is part of the cli optional dependency, not the observability extra. This check introduces a hard dependency on structlog for the observability module, which contradicts the goal of handling missing optional dependencies gracefully. If structured_logger is truly required for this module, it should be explicitly listed as a dependency for the observability extra in pyproject.toml. Otherwise, consider falling back to the base logger or handling its absence more gracefully to prevent a hard crash.

if not structured_logger:
    # Fallback to base logger or handle gracefully if structlog is truly optional
    # For now, raising an exception as it's a critical component for this module.
    raise Exception("Structured logger is not available")


def get_embed_sdk_obj(
dashboard_id: str = Query(...),
Expand Down
3 changes: 3 additions & 0 deletions lkr/tools/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

group = typer.Typer()

if not logger:
raise Exception("Logger is not available")


@group.command()
def user_attribute_updater(
Expand Down
29 changes: 19 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,38 @@ requires-python = ">=3.12"
dependencies = [
"looker-sdk>=25.4.0",
"pydantic>=2.11.4",
"pydash>=8.0.5"
]

[project.optional-dependencies]

cli = [
"typer>=0.15.2",
"requests>=2.31.0",
"cryptography>=42.0.0",
"pydash>=8.0.5",
"structlog>=25.3.0",
"questionary>=2.1.0",
"questionary>=2.1.0"
]

[project.optional-dependencies]
mcp = [
"mcp[cli]>=1.9.2",
"duckdb>=1.2.2"
"duckdb>=1.2.2",
"fastapi[standard]>=0.115.12"
]
embed-observability = [
"fastapi>=0.115.12",
observability = [
"fastapi[standard]>=0.115.12",
"selenium>=4.32.0"
]
user-attribute-updater = [
"fastapi>=0.115.12"
tools = [
"fastapi[standard]>=0.115.12"
]
all = [
"typer>=0.15.2",
"requests>=2.31.0",
"cryptography>=42.0.0",
"structlog>=25.3.0",
"questionary>=2.1.0",
"mcp[cli]>=1.9.2",
"fastapi>=0.115.12",
"fastapi[standard]>=0.115.12",
"selenium>=4.32.0",
"duckdb>=1.2.2"
]
Expand Down
16 changes: 16 additions & 0 deletions test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from lkr.tools.classes import UserAttributeUpdater


updater = UserAttributeUpdater(
user_attribute="test",
value="test",
update_type="default",
)
print(updater.model_dump_json())







Loading