Skip to content

Commit e43fef7

Browse files
committed
Add logging configuration module and related tests, and more docstrings
1 parent 5f3298d commit e43fef7

3 files changed

Lines changed: 286 additions & 10 deletions

File tree

streamdeck/utils/dirs.py

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
"""This module provides utility functions to get various directory paths related to Elgato Stream Deck plugins and components.
2+
3+
The functions defined here help locate user-specific log directories, local data directories for plugins, and application data directories for Elgato Stream Deck components and plugins.
4+
These paths are useful for accessing log files, unpacked plugin code, and other application data for Stream Deck and its plugins.
5+
6+
Note:
7+
These functions have been tested on macOS only so far, with plans to test on Windows in the future.
8+
"""
19
from __future__ import annotations
210

311
from typing import TYPE_CHECKING
@@ -11,28 +19,76 @@
1119

1220

1321
def streamdeck_log_dir() -> Path:
22+
"""Get the path to the user-specific log directory for Elgato Stream Deck.
23+
24+
The result of this function is the user log directory path that all plugins as well as internal StreamDeck components write to.
25+
26+
Returns:
27+
Path: Path to the Stream Deck log directory.
28+
"""
1429
return platformdirs.user_log_path(appname="ElgatoStreamDeck")
1530

1631

1732
def streamdeck_local_data_dir() -> Path:
33+
"""Get the path to the local user-specific data directory for Elgato Stream Deck.
34+
35+
The result of this function is the directory that contains the unpacked (i.e. unzipped) directories of all of the code of installed plugins.
36+
37+
Returns:
38+
Path: Path to the local data directory for Stream Deck.
39+
"""
1840
return platformdirs.user_data_path("com.elgato.StreamDeck")
1941

2042

21-
def plugin_local_data_dir(plugin_name: str) -> Path:
22-
return streamdeck_local_data_dir() / "Plugins" / plugin_name
43+
def plugin_local_data_dir(plugin_uuid: str) -> Path:
44+
"""Get the path to the local user-specific data directory for a specific Stream Deck plugin.
45+
46+
The result of this function is the directory path for the given plugin's unpacked (i.e. unzipped) code.
47+
48+
Args:
49+
plugin_uuid (str): The UUID of the plugin.
50+
51+
Returns:
52+
Path: Path to the local data directory for the specified plugin.
53+
"""
54+
return streamdeck_local_data_dir() / "Plugins" / f"{plugin_uuid}.sdPlugin"
2355

2456

2557
def streamdeck_application_data_dir() -> Path:
26-
# NOTE: The only directory under this seems to be "QtWebEngine",
27-
# which is a component for embeding web browser functionality into their desktop applications.
28-
return platformdirs.user_data_path('elgato') / "Streamdeck"
58+
"""Get the path to the application-specific data directory for Elgato Stream Deck.
59+
60+
Note:
61+
The only directory under this path seems to be "QtWebEngine", which is a component for embedding web browser
62+
functionality into their desktop applications.
63+
64+
Returns:
65+
Path: Path to the application data directory for Stream Deck.
66+
"""
67+
return platformdirs.user_data_path("elgato") / "Streamdeck"
68+
2969

70+
def plugin_application_data_dir(plugin_uuid: str) -> Path:
71+
"""Get the path to the application-specific data directory for a specific Stream Deck plugin.
3072
31-
def plugin_application_data_dir(plugin_name: str) -> Path:
32-
# NOTE: Not all plugins have a directory here.. A few individual plugins have probably pushed data to this location.
33-
return streamdeck_application_data_dir() / "QtWebEngine" / plugin_name
73+
Note:
74+
Not all plugins have a directory here. Only a few individual plugins have likely pushed data to this location.
75+
76+
Args:
77+
plugin_uuid (str): The UUID of the plugin.
78+
79+
Returns:
80+
Path: Path to the application data directory for the specified plugin.
81+
"""
82+
return streamdeck_application_data_dir() / "QtWebEngine" / plugin_uuid
3483

3584

3685
def elgato_site_data_dir() -> Path:
37-
# NOTE: There doesn't seem to be a "Stream Deck" directory in here, just "Wave Link".
38-
return platformdirs.site_data_path("Elgato")
86+
"""Get the path to the system-wide shared data directory for Elgato products.
87+
88+
Note:
89+
There doesn't seem to be a "Stream Deck" directory in here, only "Wave Link".
90+
91+
Returns:
92+
Path: Path to the shared data directory for Elgato products.
93+
"""
94+
return platformdirs.site_data_path("Elgato")

streamdeck/utils/logging.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""This module provides utility functions for configuring loggers for Elgato Stream Deck plugins.
2+
3+
The module includes functions to set up loggers that write logs to either the centralized Stream Deck user log directory
4+
or to the installed plugin's local code directory. Each logger is configured with both a stream handler for console output and a
5+
rotating file handler to manage log file sizes.
6+
"""
7+
import logging
8+
from logging.handlers import RotatingFileHandler
9+
from pathlib import Path
10+
11+
from streamdeck.utils.dirs import plugin_local_data_dir, streamdeck_log_dir
12+
13+
14+
def configure_streamdeck_logger(
15+
name: str,
16+
plugin_uuid: str,
17+
log_level: int = logging.DEBUG,
18+
) -> None:
19+
"""Configure a logger for the Elgato Stream Deck plugin with a rotating file handler.
20+
21+
This logger writes to a log file located in the Stream Deck user log directory, where both internal Stream Deck components
22+
and plugins have their logs centralized.
23+
24+
Args:
25+
name (str): The name of the logger.
26+
plugin_uuid (str): The UUID of the plugin.
27+
log_level (int, optional): The logging level. Defaults to logging.DEBUG.
28+
"""
29+
plugin_log_filepath = streamdeck_log_dir() / f"{plugin_uuid}.log"
30+
31+
_configure_logger(
32+
name=name,
33+
filename=plugin_log_filepath,
34+
level=log_level,
35+
)
36+
37+
38+
def configure_local_logger(
39+
name: str,
40+
plugin_uuid: str,
41+
log_level: int = logging.DEBUG,
42+
) -> None:
43+
"""Configure a logger for a Stream Deck plugin that writes to a local data directory.
44+
45+
This logger writes to a log file located in the local plugin directory, allowing plugin-specific logs to be kept
46+
separately from the main Stream Deck logs.
47+
48+
Args:
49+
name (str): The name of the logger.
50+
plugin_uuid (str): The UUID of the plugin.
51+
log_level (int, optional): The logging level. Defaults to logging.DEBUG.
52+
"""
53+
# The log file name is the last component of the plugin_uuid.
54+
plugin_component_name = plugin_uuid.split(".")[-1]
55+
56+
local_log_filepath = (
57+
plugin_local_data_dir(plugin_uuid=plugin_uuid) / f"logs/{plugin_component_name}.log"
58+
)
59+
60+
_configure_logger(
61+
name=name,
62+
filename=local_log_filepath,
63+
level=log_level,
64+
)
65+
66+
67+
def _configure_logger(
68+
name: str,
69+
filename: Path,
70+
level: int = logging.DEBUG,
71+
) -> logging.Logger:
72+
"""Helper function to configure a logger with a stream handler and a rotating file handler.
73+
74+
This function ensures that the logger is only configured once by checking its handlers. It sets up both a stream
75+
handler for console output and a rotating file handler to save logs to a file.
76+
77+
Args:
78+
name (str): The name of the logger.
79+
filename (Path): The path to the log file.
80+
level (int, optional): The logging level. Defaults to logging.DEBUG.
81+
82+
Returns:
83+
logging.Logger: The configured logger instance.
84+
"""
85+
logger = logging.getLogger(name)
86+
87+
# For some reason, logger.getHandlers() evaluates as True here, even if the handlers list property is empty...
88+
if not logger.handlers:
89+
logger.setLevel(level)
90+
91+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
92+
stream_handler = logging.StreamHandler()
93+
stream_handler.setFormatter(formatter)
94+
logger.addHandler(stream_handler)
95+
96+
# Ensure the directory path this logger will write files to exists.
97+
filename.parent.mkdir(parents=True, exist_ok=True)
98+
99+
file_handler = RotatingFileHandler(
100+
filename.expanduser(),
101+
maxBytes=5 * 1024 * 1024,
102+
backupCount=20,
103+
)
104+
file_handler.setFormatter(formatter)
105+
logger.addHandler(file_handler)
106+
107+
return logger

tests/test_logging.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""This module contains tests for configuring loggers for the Elgato Stream Deck plugins.
2+
3+
The tests validate that the logger configurations correctly create log files for both local plugin-specific logging and
4+
centralized Stream Deck logging.
5+
"""
6+
import logging
7+
from pathlib import Path
8+
9+
import platformdirs
10+
import pytest
11+
from streamdeck.utils.logging import configure_local_logger, configure_streamdeck_logger
12+
13+
14+
@pytest.fixture
15+
def fake_plugin_local_log_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
16+
"""Fixture to set up a fake local log directory for plugin-specific logging.
17+
18+
This fixture uses `monkeypatch` to replace the `platformdirs.user_data_path` function, redirecting it to use a
19+
temporary path for testing purposes.
20+
21+
Args:
22+
monkeypatch (pytest.MonkeyPatch): The pytest monkeypatch object for modifying module attributes.
23+
tmp_path (Path): A temporary path provided by pytest for file operations.
24+
25+
Returns:
26+
Path: The temporary path used for local plugin logging.
27+
"""
28+
monkeypatch.setattr(
29+
platformdirs, "user_data_path", value=(lambda plugin_uuid: tmp_path / plugin_uuid)
30+
)
31+
return tmp_path
32+
33+
34+
@pytest.fixture
35+
def fake_streamdeck_log_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
36+
"""Fixture to set up a fake Stream Deck log directory for centralized logging.
37+
38+
This fixture uses `monkeypatch` to replace the `platformdirs.user_log_path` function, redirecting it to use a
39+
temporary path for testing purposes.
40+
41+
Args:
42+
monkeypatch (pytest.MonkeyPatch): The pytest monkeypatch object for modifying module attributes.
43+
tmp_path (Path): A temporary path provided by pytest for file operations.
44+
45+
Returns:
46+
Path: The temporary path used for Stream Deck logging.
47+
"""
48+
monkeypatch.setattr(platformdirs, "user_log_path", value=(lambda appname: tmp_path / appname))
49+
return tmp_path
50+
51+
52+
def test_local_logger(fake_plugin_local_log_dir: Path):
53+
"""Test the configuration of a local plugin-specific logger.
54+
55+
This test verifies that a logger configured for a specific plugin writes logs to the correct local directory.
56+
57+
Args:
58+
fake_plugin_local_log_dir (Path): The fake local log directory path provided by the fixture.
59+
"""
60+
FAKE_LOGGER_NAME = "TEST-PLUGIN-FILE"
61+
FAKE_PLUGIN_UUID = "com.test.spotifier"
62+
LOG_STATEMENT = "Test log."
63+
64+
configure_local_logger(name=FAKE_LOGGER_NAME, plugin_uuid=FAKE_PLUGIN_UUID)
65+
logger = logging.getLogger(FAKE_LOGGER_NAME)
66+
67+
logger.info(LOG_STATEMENT)
68+
69+
log_file = (
70+
fake_plugin_local_log_dir
71+
/ "com.elgato.StreamDeck/Plugins"
72+
/ FAKE_PLUGIN_UUID
73+
/ f"logs/{FAKE_LOGGER_NAME}.log"
74+
)
75+
76+
assert log_file.exists()
77+
78+
with log_file.open("r") as f:
79+
actual_log_file_output = f.read()
80+
# We don't want to make too many assumptions here about the format of the log output.
81+
assert "INFO" in actual_log_file_output
82+
assert LOG_STATEMENT in actual_log_file_output
83+
# Probably don't need to assert that the logger's name is in the log.
84+
# assert fake_name in actual_log_file_output
85+
86+
87+
def test_streamdeck_logger(fake_streamdeck_log_dir: Path):
88+
"""Test the configuration of a centralized Stream Deck logger.
89+
90+
This test verifies that a logger configured for the Stream Deck writes logs to the correct centralized directory.
91+
92+
Args:
93+
fake_streamdeck_log_dir (Path): The fake Stream Deck log directory path provided by the fixture.
94+
"""
95+
FAKE_LOGGER_NAME = "my_test_name"
96+
FAKE_PLUGIN_UUID = "com.test.plugin"
97+
LOG_STATEMENT = "Testing 123..."
98+
99+
configure_streamdeck_logger(name=FAKE_LOGGER_NAME, plugin_uuid=FAKE_PLUGIN_UUID)
100+
logger = logging.getLogger(FAKE_LOGGER_NAME)
101+
102+
logger.info(LOG_STATEMENT)
103+
104+
log_file = fake_streamdeck_log_dir / "ElgatoStreamDeck" / f"{FAKE_PLUGIN_UUID}.log"
105+
106+
assert log_file.exists()
107+
with log_file.open("r") as f:
108+
actual_log_file_output = f.read()
109+
# We don't want to make too many assumptions here about the format of the log output.
110+
assert "INFO" in actual_log_file_output
111+
assert LOG_STATEMENT in actual_log_file_output
112+
# Probably don't need to assert that the logger's name is in the log.
113+
assert FAKE_LOGGER_NAME in actual_log_file_output

0 commit comments

Comments
 (0)