Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,11 @@
# Environment variable name for configurable log level
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR: Final[str] = "LIGHTSPEED_STACK_LOG_LEVEL"
# Default log level when environment variable is not set
DEFAULT_LOGGER_NAME = "lightspeed_stack"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

DEFAULT_LOGGER_NAME is missing its Final[str] annotation

Every other constant in this file uses Final[type]. This one does not, leaving it open to accidental reassignment and breaking type-checker guarantees.

🛠 Proposed fix
-DEFAULT_LOGGER_NAME = "lightspeed_stack"
+DEFAULT_LOGGER_NAME: Final[str] = "lightspeed_stack"

As per coding guidelines, src/constants.py requires "type hints using Final[type]" for shared constants.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
DEFAULT_LOGGER_NAME = "lightspeed_stack"
DEFAULT_LOGGER_NAME: Final[str] = "lightspeed_stack"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/constants.py` at line 228, DEFAULT_LOGGER_NAME is missing the Final[str]
annotation; update the constant declaration for DEFAULT_LOGGER_NAME to be
annotated with Final[str] (e.g., DEFAULT_LOGGER_NAME: Final[str] =
"lightspeed_stack") and ensure Final is imported from typing at the top of
src/constants.py (add "from typing import Final" if it's not already present) so
the constant is type-locked like the others.

DEFAULT_LOG_LEVEL: Final[str] = "INFO"
# Default log format for plain-text logging in non-TTY environments
DEFAULT_LOG_FORMAT: Final[str] = (
"%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s"
"%(asctime)s.%(msecs)03d %(levelprefix)s %(message)s [%(name)s:%(lineno)d]"
)
# Environment variable to force StreamHandler instead of RichHandler
# Set to any non-empty value to disable RichHandler
Expand Down
25 changes: 2 additions & 23 deletions src/lightspeed_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,16 @@

import logging
import os
import sys
from argparse import ArgumentParser

from configuration import configuration
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR
from log import create_log_handler, get_logger, resolve_log_level
from log import get_logger, setup_logging
from runners.quota_scheduler import start_quota_scheduler
from runners.uvicorn import start_uvicorn
from utils import schema_dumper

# Resolve log level and handler from centralized logging utilities
log_level = resolve_log_level()

# Configure root logger. basicConfig(force=True) is intentionally root-logger-specific.
# RichHandler needs format="%(message)s" to prevent double-formatting by the root Formatter.
handler = create_log_handler()
if sys.stderr.isatty():
logging.basicConfig(
level=log_level,
format="%(message)s",
datefmt="[%X]",
handlers=[handler],
force=True,
)
else:
logging.basicConfig(
level=log_level,
handlers=[handler],
force=True,
)

setup_logging()
logger = get_logger(__name__)


Expand Down
124 changes: 67 additions & 57 deletions src/log.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"""Log utilities."""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Black formatting check fails — reformat src/log.py before merge

The CI pipeline reports Black would reformat this file. Run uv tool run black src/log.py (or uv tool run black src tests) to fix.

🧰 Tools
🪛 GitHub Actions: Black / 0_black.txt

[error] 1-1: Black formatting check failed (uv tool run black --check src tests). File would be reformatted. Run 'uv tool run black src tests' (or 'black --write') to fix.

🪛 GitHub Actions: Black / black

[error] 1-1: Black --check failed: would reformat /home/runner/work/lightspeed-stack/lightspeed-stack/src/log.py (1 file would be reformatted). Run 'uv tool run black src/log.py' (or 'uv tool run black --write src tests') to apply formatting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.py` at line 1, The file src/log.py fails Black formatting; run the
project formatter and commit the changes: execute "uv tool run black src/log.py"
(or "uv tool run black src tests") to reformat the module (src.log) and then
stage and commit the updated file so the CI Black check passes.


import logging
import logging.config
import os
import sys
import typing as t
from functools import lru_cache

from rich.logging import RichHandler
import uvicorn.config
from pydantic.v1.utils import deep_update
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

pydantic.v1.utils.deep_update is an internal v1-compat shim — replace with a self-contained helper

pydantic.v1.utils is part of the v1 compatibility layer bundled in pydantic v2, not a public API. Functionality marked as deprecated will not be removed until the next major V3 release, but deep_update from pydantic.v1.utils is an internal implementation detail, not a stable public export. Importing it creates an unnecessary coupling to pydantic's internals for a trivial utility. A 5-line replacement eliminates the dependency entirely.

♻️ Proposed replacement

Remove the pydantic import and add a local helper:

-from pydantic.v1.utils import deep_update
def _deep_update(base: dict, override: dict) -> dict:
    """Recursively merge override into base, returning a new dict."""
    result = base.copy()
    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = _deep_update(result[key], value)
        else:
            result[key] = value
    return result

Then replace all calls to deep_update(...) with _deep_update(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.py` at line 11, The import of pydantic.v1.utils.deep_update in
src/log.py uses an internal v1-compat API — remove that import, add a small
local helper function named _deep_update(base: dict, override: dict) that
recursively merges override into base (preserving dict semantics), and replace
all usages of deep_update(...) with _deep_update(...); update any function or
module references in this file that call deep_update to call _deep_update
instead so there is no dependency on pydantic internals.


from constants import (
DEFAULT_LOG_FORMAT,
DEFAULT_LOG_LEVEL,
DEFAULT_LOGGER_NAME,
LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR,
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR,
)
Expand Down Expand Up @@ -50,62 +55,67 @@ def resolve_log_level() -> int:
return validated_level


def create_log_handler() -> logging.Handler:
"""
Create and return a configured log handler based on TTY availability and environment settings.

If LIGHTSPEED_STACK_DISABLE_RICH_HANDLER is set to any non-empty value,
returns a StreamHandler with plain-text formatting. Otherwise, if stderr
is connected to a terminal (TTY), returns a RichHandler for rich-formatted
console output. If neither condition is met, returns a StreamHandler with
plain-text formatting suitable for non-TTY environments (e.g., containers).

Returns:
logging.Handler: A configured handler instance (RichHandler or StreamHandler).
"""
# Check if RichHandler is explicitly disabled via environment variable
if os.environ.get(LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR):
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
return handler

if sys.stderr.isatty():
# RichHandler's columnar layout assumes a real terminal.
# RichHandler handles its own formatting, so no formatter is set.
return RichHandler()

# In containers without a TTY, Rich falls back to 80 columns and
# the columns consume most of that width, leaving ~40 chars for the actual message.
# Tracebacks become nearly unreadable. Use a plain StreamHandler instead.
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
return handler


def get_logger(name: str) -> logging.Logger:
"""
Get a logger configured for Rich console output.

The returned logger has its level set based on the LIGHTSPEED_STACK_LOG_LEVEL
environment variable (defaults to INFO), its handlers replaced with a single
handler (RichHandler for TTY or StreamHandler for non-TTY), and propagation
to ancestor loggers disabled.

Parameters:
----------
name (str): Name of the logger to retrieve or create.

Returns:
-------
logging.Logger: The configured logger instance.
"""
logger = logging.getLogger(name)
"""Create a common logger for all modules in this package."""
# FIXME: Remove the need for this function.
#
# Normally this is derived from the package name (__name__).
#
# Since this program is sometimes called from from the entrypoint and
# sometimes called from src/lightspeed_stack.py, the value for __name__
# does not contain a consistent root value.
#
# How the application is installed and run needs to be streamlined so that
# __name__ provides the expected value in all cases.
return logging.getLogger(f"{DEFAULT_LOGGER_NAME}.{name}")
Comment on lines +59 to +70
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

FIXME: inconsistent __name__ for the root logger — tracked for follow-up

The FIXME at line 60 acknowledges that get_logger(__name__) produces different root prefixes depending on how the application is launched.

Would you like me to open a new issue to track streamlining the application's package layout so __name__ always resolves to the expected root, removing the need for the DEFAULT_LOGGER_NAME prefix workaround entirely?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.py` around lines 59 - 70, The logger helper uses a
DEFAULT_LOGGER_NAME prefix to work around inconsistent __name__ values across
different entrypoints (see get_logger and DEFAULT_LOGGER_NAME); open a tracking
issue to remove that workaround by standardizing the package/entrypoint layout
so __name__ always yields the expected root, document the change, and include a
migration plan: update get_logger to use __name__ (remove the
DEFAULT_LOGGER_NAME concatenation), refactor any callers that relied on the
prefixed logger names (adjust tests and configuration), and add a small
integration test that verifies the root logger name is consistent when run via
all supported entrypoints.



@lru_cache
def setup_logging() -> dict[t.Any, t.Any]:
Comment on lines +73 to +74
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

setup_logging() is missing a docstring — CI reports D103

The pydocstyle pipeline fails with D103: Missing docstring in public function setup_logging. Add a docstring describing the function's behavior and side effects.

🛠 Proposed fix
 `@lru_cache`
 def setup_logging() -> dict[t.Any, t.Any]:
+    """Configure and return the merged logging configuration.
+
+    Selects a Rich or uvicorn default handler based on TTY detection and the
+    LIGHTSPEED_STACK_DISABLE_RICH_HANDLER environment variable, merges the
+    computed configuration into uvicorn's default logging config, applies it
+    via logging.config.dictConfig, and returns the merged configuration dict.
+    Result is cached; call setup_logging.cache_clear() to force re-initialization.
+
+    Returns:
+        dict: The merged logging configuration passed to dictConfig.
+    """
🧰 Tools
🪛 GitHub Actions: Pydocstyle / 0_pydocstyle.txt

[error] 74-74: pydocstyle failed (D103): Missing docstring in public function setup_logging.

🪛 GitHub Actions: Pydocstyle / pydocstyle

[error] 74-74: pydocstyle (uv tool run pydocstyle -v src tests) reported: D103: Missing docstring in public function setup_logging.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.py` around lines 73 - 74, Add a clear docstring to the public
function setup_logging() that explains what the function does (e.g., configures
and returns the logging configuration dict, applies any global logging settings
or handlers) and any side effects (such as calling logging.config.dictConfig or
mutating global logger state); ensure the docstring appears immediately below
the def setup_logging() line and follows project docstring style so pydocstyle
D103 is satisfied.

handler = "default"
log_level = resolve_log_level()
if sys.stderr.isatty() and not os.environ.get(
LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR
):
handler = "rich"

logging_conf = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"rich": {
"()": "rich.logging.RichHandler",
"show_time": True,
"log_time_format": "%Y-%m-%d %H:%M:%S.%f",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

log_time_format uses %f (microseconds, 6 digits) — PR goal was milliseconds

The %f strftime directive expands to 6-digit microseconds (e.g., 123456). The non-Rich path correctly uses %(msecs)03d for 3-digit milliseconds in DEFAULT_LOG_FORMAT. The Rich handler will show 6-digit precision instead of the intended 3-digit millisecond granularity.

🛠 Proposed fix
-                "log_time_format": "%Y-%m-%d %H:%M:%S.%f",
+                "log_time_format": "%Y-%m-%d %H:%M:%S",

Rich's RichHandler renders its own millisecond column separately when show_time=True, so the sub-second portion in log_time_format is typically redundant.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"log_time_format": "%Y-%m-%d %H:%M:%S.%f",
"log_time_format": "%Y-%m-%d %H:%M:%S",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.py` at line 89, The Rich handler configuration uses "log_time_format"
set to "%Y-%m-%d %H:%M:%S.%f" which emits 6-digit microseconds instead of the
intended 3-digit milliseconds; update the Rich-based logging path to remove the
"%f" subsecond directive (or omit log_time_format entirely) and rely on
RichHandler's own millisecond column via show_time=True, ensuring consistency
with DEFAULT_LOG_FORMAT which uses "%(msecs)03d"; modify the code that sets
log_time_format (symbol: log_time_format) and the Rich handler instantiation
(symbol: RichHandler, show_time) so the Rich path does not output 6-digit
microseconds.

"level": log_level,
},
},
"loggers": {
DEFAULT_LOGGER_NAME: {
"handlers": [handler],
"level": log_level,
"propagate": False,
},
"llama_stack_client": {
"handlers": [handler],
"level": log_level,
"propagate": False,
},
},
}

merged_config = deep_update(uvicorn.config.LOGGING_CONFIG, logging_conf)

if handler == "rich":
merged_config["loggers"]["uvicorn"]["handlers"] = [handler]
merged_config["loggers"]["uvicorn.access"]["handlers"] = [handler]
else:
merged_config["formatters"]["access"]["fmt"] = (
'ACCESS %(asctime)s.%(msecs)03d %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s'
)
merged_config["formatters"]["default"]["fmt"] = DEFAULT_LOG_FORMAT
merged_config["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M"
Comment on lines +107 to +117
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

setup_logging() silently mutates uvicorn.config.LOGGING_CONFIG — corrupts global state on cache replay

deep_update(uvicorn.config.LOGGING_CONFIG, logging_conf) performs only a shallow copy at the top level. Because logging_conf contains no "formatters" key, merged_config["formatters"] is the same dict object as uvicorn.config.LOGGING_CONFIG["formatters"]. The subsequent assignments on lines 113–117 therefore permanently mutate uvicorn's module-level global:

merged_config["formatters"]["access"]["fmt"] = ...   # mutates uvicorn.config.LOGGING_CONFIG
merged_config["formatters"]["default"]["fmt"] = ...  # mutates uvicorn.config.LOGGING_CONFIG
merged_config["formatters"]["default"]["datefmt"] = ...  # mutates uvicorn.config.LOGGING_CONFIG

Similarly in the rich path (lines 110–111), merged_config["loggers"]["uvicorn"] is an alias for the original uvicorn logger dict, so merged_config["loggers"]["uvicorn"]["handlers"] = [handler] also mutates the global.

In tests this causes silent pollution: cache_clear() re-runs the function body against an already-mutated uvicorn.config.LOGGING_CONFIG, making the test environment progressively diverge from a clean state.

Root fix: build all conditional configuration inside logging_conf before the single deep_update call, so deep_update handles all merges correctly and no post-merge mutations are needed.

🛠 Proposed fix
-    logging_conf = {
+    # Build formatters/loggers conditionally so deep_update handles all merges
+    # and uvicorn.config.LOGGING_CONFIG is never mutated.
+    logging_conf: dict[t.Any, t.Any] = {
         "version": 1,
         "disable_existing_loggers": False,
         "handlers": {
             "rich": {
                 "()": "rich.logging.RichHandler",
                 "show_time": True,
-                "log_time_format": "%Y-%m-%d %H:%M:%S.%f",
+                "log_time_format": "%Y-%m-%d %H:%M:%S",
                 "level": log_level,
             },
         },
         "loggers": {
             DEFAULT_LOGGER_NAME: {
                 "handlers": [handler],
                 "level": log_level,
                 "propagate": False,
             },
             "llama_stack_client": {
                 "handlers": [handler],
                 "level": log_level,
                 "propagate": False,
             },
         },
     }

     if handler == "rich":
-        merged_config = deep_update(uvicorn.config.LOGGING_CONFIG, logging_conf)
-        merged_config["loggers"]["uvicorn"]["handlers"] = [handler]
-        merged_config["loggers"]["uvicorn.access"]["handlers"] = [handler]
-    else:
-        merged_config = deep_update(uvicorn.config.LOGGING_CONFIG, logging_conf)
-        merged_config["formatters"]["access"]["fmt"] = (
-            'ACCESS %(asctime)s.%(msecs)03d %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s'
-        )
-        merged_config["formatters"]["default"]["fmt"] = DEFAULT_LOG_FORMAT
-        merged_config["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M"
+        logging_conf["loggers"]["uvicorn"] = {"handlers": ["rich"]}
+        logging_conf["loggers"]["uvicorn.access"] = {"handlers": ["rich"]}
+    else:
+        logging_conf["formatters"] = {
+            "access": {
+                "fmt": 'ACCESS %(asctime)s.%(msecs)03d %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
+            },
+            "default": {
+                "fmt": DEFAULT_LOG_FORMAT,
+                "datefmt": "%Y-%m-%d %H:%M",
+            },
+        }

+    merged_config = deep_update(uvicorn.config.LOGGING_CONFIG, logging_conf)

     logging.config.dictConfig(merged_config)
     return merged_config
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/log.py` around lines 107 - 117, The setup_logging() function currently
calls deep_update(uvicorn.config.LOGGING_CONFIG, logging_conf) then mutates
merged_config which aliases uvicorn.config.LOGGING_CONFIG; instead construct all
conditional changes (the "rich" handler branch and the access/default formatter
entries: the handlers for "uvicorn" and "uvicorn.access" and the
formatters["access"] and formatters["default"]["fmt"/"datefmt"]) inside
logging_conf before calling deep_update so the single deep_update call merges
everything and merged_config is never mutated afterwards; update the code paths
that reference handler, merged_config and logging_conf accordingly so no
post-merge assignments (e.g., merged_config["formatters"][...] = ...) remain.


# Skip reconfiguration if logger already has handlers from a prior call
if logger.handlers:
return logger
logging.config.dictConfig(merged_config)

logger.handlers = [create_log_handler()]
logger.propagate = False
logger.setLevel(resolve_log_level())
return logger
return merged_config
10 changes: 8 additions & 2 deletions src/runners/uvicorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

import uvicorn

from log import get_logger, resolve_log_level
from log import get_logger, resolve_log_level, setup_logging
from models.config import ServiceConfiguration

logger = get_logger(__name__)


def start_uvicorn(configuration: ServiceConfiguration) -> None:
def start_uvicorn(
configuration: ServiceConfiguration,
log_config: dict | None = None,
) -> None:
"""Start the Uvicorn server using the provided service configuration.

Parameters:
Expand All @@ -22,6 +25,8 @@ def start_uvicorn(configuration: ServiceConfiguration) -> None:
"""
log_level = resolve_log_level()
logger.info("Starting Uvicorn with log level %s", logging.getLevelName(log_level))
if log_config is None:
log_config = setup_logging()

# please note:
# TLS fields can be None, which means we will pass those values as None to uvicorn.run
Expand All @@ -30,6 +35,7 @@ def start_uvicorn(configuration: ServiceConfiguration) -> None:
host=configuration.host,
port=configuration.port,
workers=configuration.workers,
log_config=log_config,
log_level=log_level,
ssl_keyfile=configuration.tls_config.tls_key_path,
ssl_certfile=configuration.tls_config.tls_certificate_path,
Expand Down
106 changes: 33 additions & 73 deletions tests/unit/test_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,38 @@
import logging

import pytest
from pytest_mock import MockerFixture
from rich.logging import RichHandler

from constants import (
DEFAULT_LOG_FORMAT,
LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR,
DEFAULT_LOGGER_NAME,
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR,
)
from log import create_log_handler, get_logger, resolve_log_level
from log import get_logger, resolve_log_level, setup_logging


@pytest.fixture(autouse=True)
def clear_logging_cache():
setup_logging.cache_clear()


def test_get_logger() -> None:
"""Check the function to retrieve logger."""
logger_name = "foo"
logger = get_logger(logger_name)
assert logger is not None
assert logger.name == logger_name
setup_logging()

# at least one handler need to be set
assert len(logger.handlers) >= 1
logger = get_logger(__name__)

assert logger is not None
assert logger.name == f"{DEFAULT_LOGGER_NAME}.tests.unit.test_log"
assert logger.hasHandlers()


def test_get_logger_invalid_env_var_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that invalid env var value falls back to INFO level."""
monkeypatch.setenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, "FOOBAR")

logger = get_logger("test_invalid")
assert logger.level == logging.INFO
setup_logging()

logger = get_logger(__name__)
assert logger.getEffectiveLevel() == logging.INFO


@pytest.mark.parametrize(
Expand Down Expand Up @@ -59,16 +63,20 @@ def test_get_logger_log_level(
"""
monkeypatch.setenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, level_name)

logger = get_logger(f"test_{level_name}")
assert logger.level == expected_level
setup_logging()

logger = get_logger(__name__)
assert logger.getEffectiveLevel() == expected_level


def test_get_logger_default_log_level(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that get_logger() uses INFO level by default when env var is not set."""
monkeypatch.delenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, raising=False)

logger = get_logger("test_default")
assert logger.level == logging.INFO
setup_logging()

logger = get_logger(__name__)
assert logger.getEffectiveLevel() == logging.INFO


@pytest.mark.parametrize(
Expand All @@ -88,73 +96,25 @@ def test_resolve_log_level(
) -> None:
"""Test that resolve_log_level correctly resolves valid level names."""
monkeypatch.setenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, level_name)

setup_logging()

assert resolve_log_level() == expected_level


def test_resolve_log_level_invalid_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that resolve_log_level falls back to INFO for invalid values."""
monkeypatch.setenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, "BOGUS")

setup_logging()

assert resolve_log_level() == logging.INFO


def test_resolve_log_level_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that resolve_log_level defaults to INFO when env var is unset."""
monkeypatch.delenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, raising=False)
assert resolve_log_level() == logging.INFO


def test_create_log_handler_tty(mocker: MockerFixture) -> None:
"""Test that create_log_handler returns RichHandler when TTY is available."""
mocker.patch("sys.stderr.isatty", return_value=True)
handler = create_log_handler()
assert isinstance(handler, RichHandler)
setup_logging()


def test_create_log_handler_non_tty(mocker: MockerFixture) -> None:
"""Test that create_log_handler returns StreamHandler when no TTY."""
mocker.patch("sys.stderr.isatty", return_value=False)
handler = create_log_handler()
assert isinstance(handler, logging.StreamHandler)
assert not isinstance(handler, RichHandler)


def test_create_log_handler_non_tty_format(mocker: MockerFixture) -> None:
"""Test that non-TTY handler uses DEFAULT_LOG_FORMAT."""
mocker.patch("sys.stderr.isatty", return_value=False)
handler = create_log_handler()
assert handler.formatter is not None
# pylint: disable=protected-access
assert handler.formatter._fmt == DEFAULT_LOG_FORMAT


def test_create_log_handler_disable_rich_with_tty(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that RichHandler is disabled when env var is set, even with TTY."""
mocker.patch("sys.stderr.isatty", return_value=True)
monkeypatch.setenv(LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR, "1")
handler = create_log_handler()
assert isinstance(handler, logging.StreamHandler)
assert not isinstance(handler, RichHandler)


def test_create_log_handler_disable_rich_format(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that disabled RichHandler uses DEFAULT_LOG_FORMAT."""
mocker.patch("sys.stderr.isatty", return_value=True)
monkeypatch.setenv(LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR, "true")
handler = create_log_handler()
assert handler.formatter is not None
# pylint: disable=protected-access
assert handler.formatter._fmt == DEFAULT_LOG_FORMAT


def test_create_log_handler_enable_rich_when_env_var_empty(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that RichHandler is used when env var is empty string."""
mocker.patch("sys.stderr.isatty", return_value=True)
monkeypatch.setenv(LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR, "")
handler = create_log_handler()
assert isinstance(handler, RichHandler)
assert resolve_log_level() == logging.INFO
Loading