Skip to content

Commit ab153e6

Browse files
authored
Merge pull request #124 from openSUSE/toms/several_tests
Improve tests, coverage, readability and refactor tests
2 parents 57686b6 + 89685dd commit ab153e6

12 files changed

Lines changed: 540 additions & 223 deletions

File tree

changelog.d/124.refactor.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve several tests to improve coverage, readability, reduce dependency to private functions or attributes, and refactor for better modularity.

src/docbuild/config/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ def _get_container_name(self) -> str:
114114
else f'list item at index {self._current_key}'
115115
)
116116

117+
def get_container_name(self) -> str:
118+
"""Public accessor for the current container/key name.
119+
120+
This provides a stable, public way to retrieve the human-readable
121+
container name for diagnostics and tests without reaching into
122+
private attributes.
123+
"""
124+
return self._get_container_name()
125+
117126
def replace(self) -> dict[str, Any]:
118127
"""Replace all placeholders in the configuration.
119128

src/docbuild/logging.py

Lines changed: 73 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
"""Set up logging for the documentation build process."""
22

33
import atexit
4+
from contextlib import suppress
5+
import contextvars
46
import copy
57
import importlib
68
import logging
79
import logging.handlers
810
import os
11+
from pathlib import Path
912
import queue
1013
import threading
1114
import time
12-
import contextvars
13-
from pathlib import Path
14-
from typing import Any, List
15+
from typing import Any, Self
1516

1617
from .constants import APP_NAME, BASE_LOG_DIR, GITLOGGER_NAME
1718

@@ -20,12 +21,10 @@
2021
_original_emit = logging.StreamHandler.emit
2122

2223

23-
def _safe_emit(self, record):
24-
try:
24+
def _safe_emit(self: Self, record: logging.LogRecord) -> None: # pyright: ignore[reportGeneralTypeIssues]
25+
# Happens if a background thread logs after sys.stdout/stderr closed.
26+
with suppress(ValueError):
2527
_original_emit(self, record)
26-
except ValueError:
27-
# Happens if a background thread logs after sys.stdout/stderr closed.
28-
pass
2928

3029

3130
logging.StreamHandler.emit = _safe_emit
@@ -80,46 +79,50 @@ def _safe_emit(self, record):
8079
# --- Context-aware global state ---
8180
_LOGGING_STATE: dict[str, contextvars.ContextVar] = {
8281
"listener": contextvars.ContextVar("listener", default=None),
83-
"handlers": contextvars.ContextVar("handlers", default=[]),
84-
"background_threads": contextvars.ContextVar("background_threads", default=[]),
82+
# Use None as the default to avoid sharing mutable defaults across
83+
# contexts. Callers should coerce a None to a fresh list before use.
84+
"handlers": contextvars.ContextVar("handlers", default=None),
85+
"background_threads": contextvars.ContextVar("background_threads", default=None),
8586
}
8687

8788

88-
def _shutdown_logging():
89+
def _shutdown_logging() -> None:
8990
"""Ensure all logging threads and handlers shut down cleanly."""
9091
listener = _LOGGING_STATE["listener"].get()
91-
handlers: List[logging.Handler] = _LOGGING_STATE["handlers"].get()
92-
bg_threads: List[threading.Thread] = _LOGGING_STATE["background_threads"].get()
92+
handlers: list[logging.Handler] = _LOGGING_STATE["handlers"].get()
93+
bg_threads: list[threading.Thread] | None = _LOGGING_STATE["background_threads"].get()
94+
95+
# Defensive: some tests or earlier code may have mutated the stored
96+
# value accidentally (e.g. to the `list` type). Coerce to an iterable
97+
# list to avoid TypeErrors during shutdown.
98+
if not isinstance(bg_threads, list):
99+
bg_threads = []
93100

94101
if listener:
95-
try:
102+
with suppress(Exception):
96103
listener.stop()
97-
except Exception:
98-
pass
99104

100105
for handler in handlers:
101-
try:
106+
with suppress(Exception):
102107
handler.close()
103-
except Exception:
104-
pass
105108

106109
# Join all registered background threads
107110
for t in bg_threads:
108111
if t.is_alive():
109-
try:
112+
with suppress(Exception):
110113
t.join(timeout=5)
111-
except Exception:
112-
pass
113114

114115
# Reset contextvars
115116
_LOGGING_STATE["listener"].set(None)
116117
_LOGGING_STATE["handlers"].set([])
117118
_LOGGING_STATE["background_threads"].set([])
118119

119120

120-
def register_background_thread(thread: threading.Thread):
121+
def register_background_thread(thread: threading.Thread) -> None:
121122
"""Register a thread to be joined on logging shutdown."""
122123
threads = _LOGGING_STATE["background_threads"].get()
124+
if not isinstance(threads, list):
125+
threads = []
123126
threads.append(thread)
124127
_LOGGING_STATE["background_threads"].set(threads)
125128

@@ -131,15 +134,56 @@ def create_base_log_dir(base_log_dir: str | Path = BASE_LOG_DIR) -> Path:
131134
return log_dir
132135

133136

134-
def _resolve_class(path: str):
137+
def _resolve_class(path: str) -> type:
135138
"""Dynamically imports and returns a class from a string path."""
136139
module_name, class_name = path.rsplit(".", 1)
137140
module = importlib.import_module(module_name)
138141
return getattr(module, class_name)
139142

140143

144+
def build_handlers_from_config(config: dict[str, Any]) -> list[logging.Handler]:
145+
"""Build handler instances from a logging config dict without starting listeners.
146+
147+
This is a small helper useful for unit tests: it constructs handler
148+
objects and attaches formatters according to the provided `config`
149+
but does not start any background listener or register global state.
150+
"""
151+
built_handlers: list[logging.Handler] = []
152+
153+
handler_keys = ("class", "formatter", "level", "class_name")
154+
formatter_keys = ("class", "formatter", "level", "class_name", "validate")
155+
156+
for _, hconf in config.get("handlers", {}).items():
157+
cls = _resolve_class(hconf["class"])
158+
handler_args = {k: v for k, v in hconf.items() if k not in handler_keys}
159+
handler = cls(**handler_args)
160+
handler.setLevel(hconf.get("level", "NOTSET"))
161+
162+
formatter_name = hconf.get("formatter")
163+
if formatter_name and formatter_name in config.get("formatters", {}):
164+
fmt_conf = config["formatters"][formatter_name]
165+
formatter_kwargs = {
166+
k: v
167+
for k, v in fmt_conf.items()
168+
if k not in formatter_keys and k not in ["format"]
169+
}
170+
formatter_kwargs["fmt"] = fmt_conf.get("format")
171+
formatter_kwargs["datefmt"] = fmt_conf.get("datefmt")
172+
formatter_kwargs["style"] = fmt_conf.get("style")
173+
formatter_kwargs = {
174+
k: v for k, v in formatter_kwargs.items()
175+
if v is not None
176+
}
177+
fmt_cls = _resolve_class(fmt_conf.get("class", "logging.Formatter"))
178+
handler.setFormatter(fmt_cls(**formatter_kwargs))
179+
180+
built_handlers.append(handler)
181+
182+
return built_handlers
183+
184+
141185
def setup_logging(cliverbosity: int, user_config: dict[str, Any] | None = None) -> None:
142-
"""Sets up a non-blocking, configurable logging system."""
186+
"""Set up a non-blocking, configurable logging system."""
143187
config = copy.deepcopy(DEFAULT_LOGGING_CONFIG)
144188

145189
if user_config and "logging" in user_config:
@@ -162,38 +206,14 @@ def deep_merge(target: dict, source: dict) -> None:
162206
log_path = log_dir / log_filename
163207
config["handlers"]["file"]["filename"] = str(log_path)
164208

165-
built_handlers = []
166-
167-
# --- Handler Initialization ---
168-
HANDLER_INTERNAL_KEYS = ["class", "formatter", "level", "class_name"]
169-
FORMATTER_INTERNAL_KEYS = ["class", "formatter", "level", "class_name", "validate"]
170-
171-
for hname, hconf in config["handlers"].items():
172-
cls = _resolve_class(hconf["class"])
173-
handler_args = {k: v for k, v in hconf.items() if k not in HANDLER_INTERNAL_KEYS}
174-
handler = cls(**handler_args)
175-
handler.setLevel(hconf.get("level", "NOTSET"))
176-
177-
formatter_name = hconf.get("formatter")
178-
if formatter_name and formatter_name in config["formatters"]:
179-
fmt_conf = config["formatters"][formatter_name]
180-
formatter_kwargs = {
181-
k: v for k, v in fmt_conf.items() if k not in FORMATTER_INTERNAL_KEYS and k not in ["format"]
182-
}
183-
formatter_kwargs["fmt"] = fmt_conf.get("format")
184-
formatter_kwargs["datefmt"] = fmt_conf.get("datefmt")
185-
formatter_kwargs["style"] = fmt_conf.get("style")
186-
formatter_kwargs = {k: v for k, v in formatter_kwargs.items() if v is not None}
187-
fmt_cls = _resolve_class(fmt_conf.get("class", "logging.Formatter"))
188-
handler.setFormatter(fmt_cls(**formatter_kwargs))
189-
190-
built_handlers.append(handler)
209+
# Build handlers (use helper to keep logic in one place)
210+
handlers = build_handlers_from_config(config)
191211

192212
# --- Asynchronous Queue Setup ---
193213
log_queue = queue.Queue(-1)
194214
queue_handler = logging.handlers.QueueHandler(log_queue)
195215
listener = logging.handlers.QueueListener(
196-
log_queue, *built_handlers, respect_handler_level=True
216+
log_queue, *handlers, respect_handler_level=True
197217
)
198218
listener.start()
199219

@@ -210,5 +230,5 @@ def deep_merge(target: dict, source: dict) -> None:
210230

211231
# --- Register graceful shutdown ---
212232
_LOGGING_STATE["listener"].set(listener)
213-
_LOGGING_STATE["handlers"].set(built_handlers)
233+
_LOGGING_STATE["handlers"].set(handlers)
214234
atexit.register(_shutdown_logging)

src/docbuild/models/path.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ def validate_and_create(cls, path: Path) -> Self:
6363
Expands user, checks if path exists. If not, creates it. Then checks permissions.
6464
"""
6565

66+
# Ensure user expansion happens before any filesystem operations
67+
path = path.expanduser()
68+
6669
# 1. Auto-Creation Logic
6770
if not path.exists():
6871
try:

src/docbuild/utils/git.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ class ManagedGitRepo:
1717
#: Class variable to indicate the update state of a repo
1818
_is_updated: ClassVar[dict[Repo, bool]] = {}
1919

20+
@classmethod
21+
def clear_cache(cls) -> None:
22+
"""Clear the internal update-state cache.
23+
24+
This is a small, explicit API intended primarily for tests
25+
to reset class-level state between test cases. It avoids
26+
tests touching the private `_is_updated` attribute directly.
27+
"""
28+
cls._is_updated.clear()
29+
2030
def __init__(self: Self,
2131
remote_url: str,
2232
permanent_root: Path,

0 commit comments

Comments
 (0)