diff --git a/src/sphinx_codelinks/analyse/analyse.py b/src/sphinx_codelinks/analyse/analyse.py index 842c51b..cfd1017 100644 --- a/src/sphinx_codelinks/analyse/analyse.py +++ b/src/sphinx_codelinks/analyse/analyse.py @@ -1,7 +1,6 @@ from collections.abc import Generator from dataclasses import dataclass import json -import logging from pathlib import Path from typing import Any, TypedDict @@ -26,14 +25,14 @@ OneLineCommentStyle, SourceAnalyseConfig, ) +from sphinx_codelinks.logger import get_logger -# initialize logger -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) -# log to the console -console = logging.StreamHandler() -console.setLevel(logging.INFO) -logger.addHandler(console) +logger = get_logger(__name__) + + +def _count(n: int, noun: str) -> str: + """Format ``n noun`` with a naive (append-s) plural for progress summaries.""" + return f"{n} {noun}" if n == 1 else f"{n} {noun}s" class AnalyseWarningType(TypedDict): @@ -57,7 +56,10 @@ class SourceAnalyse: def __init__( self, analyse_config: SourceAnalyseConfig, + *, + name: str = "", ) -> None: + self.name = name self.analyse_config = analyse_config self.src_files: list[SourceFile] = [] self.src_comments: list[SourceComment] = [] @@ -110,9 +112,6 @@ def create_src_objects(self) -> None: self.src_files.append(src_file) self.src_comments.extend(src_comments) - logger.info(f"Source files loaded: {len(self.src_files)}") - logger.info(f"Source comments extracted: {len(self.src_comments)}") - def extract_marker( self, text: str, @@ -353,13 +352,6 @@ def extract_marked_content(self) -> None: if marked_rst: self.marked_rst.append(marked_rst) - if self.analyse_config.get_need_id_refs: - logger.info(f"Need-id-refs extracted: {len(self.need_id_refs)}") - if self.analyse_config.get_oneline_needs: - logger.info(f"Oneline needs extracted: {len(self.oneline_needs)}") - if self.analyse_config.get_rst: - logger.info(f"Marked rst extracted: {len(self.marked_rst)}") - def merge_marked_content(self) -> None: self.all_marked_content.extend(self.need_id_refs) self.oneline_needs.sort(key=lambda x: x.source_map["start"]["row"]) @@ -378,9 +370,23 @@ def dump_marked_content(self, outdir: Path) -> None: ] with output_path.open("w") as f: json.dump(to_dump, f) - logger.info(f"Marked content dumped to {output_path}") def run(self) -> None: self.create_src_objects() self.extract_marked_content() self.merge_marked_content() + self._log_summary() + + def _log_summary(self) -> None: + """Emit a per-project marker (default-visible) plus a -v breakdown.""" + label = f"codelinks [{self.name}]" if self.name else "codelinks" + logger.info( + f"{label}: {_count(len(self.src_files), 'file')}, " + f"{_count(len(self.all_marked_content), 'marker')}" + ) + logger.debug( + f"{label}: {_count(len(self.src_comments), 'comment')}, " + f"{_count(len(self.oneline_needs), 'oneline need')}, " + f"{_count(len(self.need_id_refs), 'id-ref')}, " + f"{_count(len(self.marked_rst), 'marked-rst block')}" + ) diff --git a/src/sphinx_codelinks/analyse/oneline_parser.py b/src/sphinx_codelinks/analyse/oneline_parser.py index 3395a1e..91aabb5 100644 --- a/src/sphinx_codelinks/analyse/oneline_parser.py +++ b/src/sphinx_codelinks/analyse/oneline_parser.py @@ -1,17 +1,8 @@ from dataclasses import dataclass from enum import Enum -import logging from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle -# initialize logger -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) -# log to the console -console = logging.StreamHandler() -console.setLevel(logging.INFO) -logger.addHandler(console) - class WarningSubTypeEnum(str, Enum): """Enum for warning sub types.""" diff --git a/src/sphinx_codelinks/analyse/projects.py b/src/sphinx_codelinks/analyse/projects.py index 4a1d873..b3fdb0f 100644 --- a/src/sphinx_codelinks/analyse/projects.py +++ b/src/sphinx_codelinks/analyse/projects.py @@ -1,5 +1,4 @@ import json -import logging from pathlib import Path from typing import cast @@ -9,14 +8,9 @@ SourceAnalyse, ) from sphinx_codelinks.config import CodeLinksConfig, CodeLinksProjectConfigType +from sphinx_codelinks.logger import get_logger -# initialize logger -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) -# log to the console -console = logging.StreamHandler() -console.setLevel(logging.INFO) -logger.addHandler(console) +logger = get_logger(__name__) class AnalyseProjects: @@ -32,7 +26,7 @@ def __init__(self, codelink_config: CodeLinksConfig) -> None: def run(self) -> None: for project, config in self.projects_configs.items(): - src_analyse = SourceAnalyse(config["analyse_config"]) + src_analyse = SourceAnalyse(config["analyse_config"], name=project) src_analyse.run() self.projects_analyse[project] = src_analyse @@ -46,7 +40,7 @@ def dump_markers(self) -> None: } with output_path.open("w") as f: json.dump(to_dump, f) - logger.info(f"Marked content dumped to {output_path}") + logger.debug(f"codelinks: marked content dumped to {output_path}") @classmethod def load_warnings(cls, warnings_dir: Path) -> list[AnalyseWarning] | None: diff --git a/src/sphinx_codelinks/analyse/utils.py b/src/sphinx_codelinks/analyse/utils.py index 5a11fdd..7bfd5bb 100644 --- a/src/sphinx_codelinks/analyse/utils.py +++ b/src/sphinx_codelinks/analyse/utils.py @@ -1,6 +1,5 @@ from collections.abc import ByteString, Callable import configparser -import logging from pathlib import Path from typing import TypedDict from urllib.request import pathname2url @@ -10,6 +9,7 @@ from tree_sitter import Node as TreeSitterNode from sphinx_codelinks.config import UNIX_NEWLINE, CommentCategory +from sphinx_codelinks.logger import get_logger from sphinx_codelinks.source_discover.config import CommentType # Language-specific node types for scope detection @@ -31,13 +31,7 @@ }, } -# initialize logger -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) -# log to the console -console = logging.StreamHandler() -console.setLevel(logging.INFO) -logger.addHandler(console) +logger = get_logger(__name__) GIT_HOST_URL_TEMPLATE = { "github": "https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{lineno}", @@ -270,7 +264,11 @@ def locate_git_root(src_dir: Path) -> Path | None: for parent in parents: if (parent / ".git").exists() and (parent / ".git").is_dir(): return parent - logger.warning(f"git root is not found in the parent of {src_dir}") + logger.warning( + f"git root is not found in the parent of {src_dir}", + subtype="git_root", + location=str(src_dir), + ) return None @@ -278,7 +276,11 @@ def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None: """Get remote url from .git/config.""" config_path = git_root / ".git" / "config" if not config_path.exists(): - logging.warning(f"{config_path} does not exist") + logger.warning( + f"{config_path} does not exist", + subtype="git_config", + location=str(config_path), + ) return None config = configparser.ConfigParser(allow_no_value=True, strict=False) @@ -287,7 +289,11 @@ def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None: if section in config and "url" in config[section]: url: str = config[section]["url"] return url - logger.warning(f"remote-url is not found in {config_path}") + logger.warning( + f"remote-url is not found in {config_path}", + subtype="git_remote", + location=str(config_path), + ) return None @@ -295,16 +301,25 @@ def get_current_rev(git_root: Path) -> str | None: """Get current commit rev from .git/HEAD.""" head_path = git_root / ".git" / "HEAD" if not head_path.exists(): - logging.warning(f"{head_path} does not exist") + logger.warning( + f"{head_path} does not exist", + subtype="git_head", + location=str(head_path), + ) return None head_content = head_path.read_text().strip() if not head_content.startswith("ref: "): - logging.warning(f"Expect starting with 'ref: ' in {head_path}") - return None + # Detached HEAD (e.g. CI checkouts): .git/HEAD holds the commit SHA + # directly, which is exactly the rev we want. + return head_content ref_path = git_root / ".git" / head_content.split(":", 1)[1].strip() if not ref_path.exists(): - logging.warning(f"{ref_path} does not exist") + logger.warning( + f"{ref_path} does not exist", + subtype="git_ref", + location=str(ref_path), + ) return None return ref_path.read_text().strip() @@ -315,7 +330,10 @@ def form_https_url( parsed_url = parse(git_url) template = GIT_HOST_URL_TEMPLATE.get(parsed_url.platform) if not template: - logging.warning(f"Unsupported Git host: {parsed_url.platform}") + logger.warning( + f"Unsupported Git host: {parsed_url.platform}", + subtype="git_host", + ) return git_url https_url = template.format( owner=parsed_url.owner, diff --git a/src/sphinx_codelinks/cmd.py b/src/sphinx_codelinks/cmd.py index 2961116..f32a30d 100644 --- a/src/sphinx_codelinks/cmd.py +++ b/src/sphinx_codelinks/cmd.py @@ -14,7 +14,7 @@ CodeLinksProjectConfigType, generate_project_configs, ) -from sphinx_codelinks.logger import logger +from sphinx_codelinks.logger import configure_cli, logger from sphinx_codelinks.needextend_write import MarkedObjType, convert_marked_content from sphinx_codelinks.source_discover.config import ( CommentType, @@ -88,9 +88,12 @@ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches exists=True, ), ] = None, + verbose: OptVerbose = False, + quiet: OptQuiet = False, ) -> None: """Analyse marked content in source code.""" # @CLI command to analyse source code and extract traceability markers, IMPL_CLI_ANALYZE, impl, [FE_CLI_ANALYZE] + configure_cli(verbose, quiet) data: CodeLinksConfigType = load_config_from_toml(config) @@ -291,7 +294,7 @@ def write_rst( # noqa: PLR0913 # for CLI, so it takes as many as it requires quiet: OptQuiet = False, ) -> None: """Generate needextend.rst from the extracted obj in JSON.""" - logger.configure(verbose, quiet) + configure_cli(verbose, quiet) try: with jsonpath.open("r") as f: marked_content = json.load(f) diff --git a/src/sphinx_codelinks/logger.py b/src/sphinx_codelinks/logger.py index 05b3f35..f100455 100644 --- a/src/sphinx_codelinks/logger.py +++ b/src/sphinx_codelinks/logger.py @@ -1,5 +1,10 @@ +import logging +from typing import Protocol + from rich.console import Console from rich.text import Text +from sphinx import version_info as _sphinx_version_info +from sphinx.util import logging as sphinx_logging import typer @@ -92,3 +97,165 @@ def error( logger = Logger() + + +# --------------------------------------------------------------------------- # +# Environment-aware logging facade +# +# The ``analyse`` layer is shared between the standalone CLI and the Sphinx +# extension. Instead of installing handlers on its own loggers (which leaks +# routine INFO progress onto stderr; see issue #72), the layer logs through +# :func:`get_logger`, and the active *frontend* selects where records go: +# +# * default (plain library) -> stdlib logging with no handler installed, so +# INFO is dropped silently and WARNING+ falls back to stderr via +# ``logging.lastResort``. +# * CLI -> the rich :data:`logger` above (INFO to stdout, WARNING to stderr, +# honouring ``--verbose``/``--quiet``). +# * Sphinx -> ``sphinx.util.logging`` (respects verbosity, colour, +# ``suppress_warnings`` and the Sphinx warning stream). +# --------------------------------------------------------------------------- # + + +class _Backend(Protocol): + """Where the ``analyse`` layer's log records are routed. + + Arguments are positional-only so each backend may ignore (and underscore) + the ones it does not need. + """ + + def debug(self, name: str, msg: str, location: str | None, /) -> None: ... + + def info(self, name: str, msg: str, location: str | None, /) -> None: ... + + def warning( + self, name: str, msg: str, subtype: str, location: str | None, / + ) -> None: ... + + +class _StdlibBackend: + """Default backend: emit through stdlib logging, install nothing. + + A library must not configure handlers on its own loggers. With no handler, + INFO records are dropped and WARNING+ reaches stderr via ``lastResort``. + """ + + def debug(self, name: str, msg: str, _location: str | None, /) -> None: + logging.getLogger(name).debug(msg) + + def info(self, name: str, msg: str, _location: str | None, /) -> None: + logging.getLogger(name).info(msg) + + def warning( + self, name: str, msg: str, _subtype: str, _location: str | None, / + ) -> None: + logging.getLogger(name).warning(msg) + + +class _CliBackend: + """CLI backend: route through the rich :data:`logger`. + + The summary is INFO (stdout, hidden by ``--quiet``); the breakdown is DEBUG + (stdout, shown only with ``--verbose``); warnings go to stderr. + """ + + def debug(self, _name: str, msg: str, _location: str | None, /) -> None: + logger.debug(msg) + + def info(self, _name: str, msg: str, _location: str | None, /) -> None: + logger.info(msg) + + def warning( + self, _name: str, msg: str, _subtype: str, _location: str | None, / + ) -> None: + # reserve stderr for warnings/errors (the rich logger prints to stdout + # by default; route this to the error console explicitly) + logger.warning(msg, console=logger.err_console) + + +class _SphinxBackend: + """Sphinx backend: route through ``sphinx.util.logging``. + + The summary is INFO (shown in a normal build's status stream); the breakdown + is VERBOSE (shown only with ``sphinx-build -v``); warnings carry + ``type="codelinks"`` plus a subtype so they are suppressible via + ``suppress_warnings`` and rendered on the Sphinx warning stream. + """ + + # Sphinx >= 8 renders the warning type itself; older versions need it + # appended to the message (mirrors sphinx-needs' logging helper). + _show_warning_types = _sphinx_version_info >= (8,) + + def debug(self, name: str, msg: str, _location: str | None, /) -> None: + sphinx_logging.getLogger(name).verbose(msg) + + def info(self, name: str, msg: str, _location: str | None, /) -> None: + sphinx_logging.getLogger(name).info(msg) + + def warning( + self, name: str, msg: str, subtype: str, location: str | None, / + ) -> None: + message = msg + if not self._show_warning_types: + message += f" [codelinks.{subtype}]" if subtype else " [codelinks]" + sphinx_logging.getLogger(name).warning( + message, + type="codelinks", + subtype=subtype, + location=location, + ) + + +class _Dispatch: + """Holds the active backend, swapped by the ``configure_*`` entry points.""" + + def __init__(self) -> None: + self.backend: _Backend = _StdlibBackend() + + +_dispatch = _Dispatch() + + +class CodelinksLogger: + """Thin proxy used by the ``analyse`` layer. + + The active backend is resolved at *call* time so that an entry point may + select the frontend after the ``analyse`` modules have been imported. + """ + + __slots__ = ("_name",) + + def __init__(self, name: str) -> None: + self._name = name + + def debug(self, msg: str, *, location: str | None = None) -> None: + _dispatch.backend.debug(self._name, msg, location) + + def info(self, msg: str, *, location: str | None = None) -> None: + _dispatch.backend.info(self._name, msg, location) + + def warning( + self, msg: str, *, subtype: str = "", location: str | None = None + ) -> None: + _dispatch.backend.warning(self._name, msg, subtype, location) + + +def get_logger(name: str) -> CodelinksLogger: + """Return a logger that routes to whichever frontend is configured.""" + return CodelinksLogger(name) + + +def configure_cli(verbose: bool = False, quiet: bool = False) -> None: + """Select the CLI frontend and configure the rich logger's verbosity.""" + logger.configure(verbose=verbose, quiet=quiet) + _dispatch.backend = _CliBackend() + + +def configure_sphinx() -> None: + """Select the Sphinx frontend (``sphinx.util.logging``).""" + _dispatch.backend = _SphinxBackend() + + +def reset() -> None: + """Restore the default (plain library) backend. Mainly for tests.""" + _dispatch.backend = _StdlibBackend() diff --git a/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py b/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py index c04f133..0b522f7 100644 --- a/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +++ b/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py @@ -123,7 +123,7 @@ def run(self) -> list[nodes.Node]: src_trace_toml_path = Path(src_trace_sphinx_config.config_from_toml) conf_dir = conf_dir / src_trace_toml_path.parent analyse_config.git_root = (conf_dir / analyse_config.git_root).resolve() - src_analyse = SourceAnalyse(analyse_config) + src_analyse = SourceAnalyse(analyse_config, name=project) src_analyse.run() dirs = { diff --git a/src/sphinx_codelinks/sphinx_extension/source_tracing.py b/src/sphinx_codelinks/sphinx_extension/source_tracing.py index 37504da..80106d4 100644 --- a/src/sphinx_codelinks/sphinx_extension/source_tracing.py +++ b/src/sphinx_codelinks/sphinx_extension/source_tracing.py @@ -25,6 +25,7 @@ file_lineno_href, generate_project_configs, ) +from sphinx_codelinks.logger import configure_sphinx from sphinx_codelinks.sphinx_extension import debug from sphinx_codelinks.sphinx_extension.directives.src_trace import ( SourceTracing, @@ -52,6 +53,9 @@ def _check_sphinx_needs_dependency(app: Sphinx) -> bool: def setup(app: Sphinx) -> dict[str, Any]: # type: ignore[explicit-any] + # Route the shared analyse layer's logging through Sphinx (verbosity, + # colour, suppress_warnings, warning stream) instead of stderr. + configure_sphinx() # Check if sphinx-needs is available and properly configured if not _check_sphinx_needs_dependency(app): logger.error( diff --git a/tests/test_analyse.py b/tests/test_analyse.py index 6e6c2a7..9617b7b 100644 --- a/tests/test_analyse.py +++ b/tests/test_analyse.py @@ -4,7 +4,7 @@ import pytest -from sphinx_codelinks.analyse.analyse import SourceAnalyse +from sphinx_codelinks.analyse.analyse import SourceAnalyse, _count from sphinx_codelinks.config import SourceAnalyseConfig from tests.conftest import ( ONELINE_COMMENT_STYLE, @@ -232,3 +232,10 @@ def test_oneline_parser_warnings_are_collected(tmp_path): warning = src_analyse.oneline_warnings[0] assert "too_many_fields" in warning.sub_type assert warning.lineno == 17 + + +def test_count_pluralizes_nouns() -> None: + assert _count(0, "file") == "0 files" + assert _count(1, "file") == "1 file" + assert _count(2, "marker") == "2 markers" + assert _count(1, "marked-rst block") == "1 marked-rst block" diff --git a/tests/test_analyse_utils.py b/tests/test_analyse_utils.py index 207b1f9..23ad162 100644 --- a/tests/test_analyse_utils.py +++ b/tests/test_analyse_utils.py @@ -929,6 +929,17 @@ def test_get_current_rev(git_repo: tuple[Path, str]) -> None: assert current_rev == utils.get_current_rev(repo_path) +def test_get_current_rev_detached_head(tmp_path: Path) -> None: + """In a detached HEAD (e.g. CI checkouts) .git/HEAD holds the commit SHA + directly; get_current_rev returns it rather than warning and giving up.""" + git_root = tmp_path / "repo" + (git_root / ".git").mkdir(parents=True) + sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" + (git_root / ".git" / "HEAD").write_text(f"{sha}\n") + + assert utils.get_current_rev(git_root) == sha + + @pytest.mark.parametrize( ("text", "leading_sequences", "result"), [ diff --git a/tests/test_cmd.py b/tests/test_cmd.py index edc62a3..d6533e4 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -116,6 +116,31 @@ def test_analyse_outputs_warnings(tmp_path: Path) -> None: assert "too_many_fields" in result.output +def test_analyse_logs_per_project_summary_and_gates_detail(tmp_path: Path) -> None: + """Each project gets a default-visible ``codelinks []`` summary with + counts; the per-type breakdown is gated behind --verbose; --quiet silences it.""" + config_path = DATA_DIR / "configs" / "minimum_config.toml" + base = ["analyse", str(config_path), "--outdir", str(tmp_path)] + + # default: per-project summary shown, breakdown hidden + result = runner.invoke(app, base) + assert result.exit_code == 0 + assert "codelinks [" in result.output + assert "markers" in result.output + assert "oneline need" not in result.output + + # --verbose: per-type breakdown also shown + verbose_result = runner.invoke(app, [*base, "--verbose"]) + assert verbose_result.exit_code == 0 + assert "codelinks [" in verbose_result.output + assert "oneline need" in verbose_result.output + + # --quiet: summary silenced + quiet_result = runner.invoke(app, [*base, "--quiet"]) + assert quiet_result.exit_code == 0 + assert "codelinks [" not in quiet_result.output + + @pytest.mark.parametrize( ("options", "stdout"), [ @@ -227,7 +252,7 @@ def test_analyse_config_negative( ] result = runner.invoke(app, options) assert result.exit_code != 0 - normalized = _normalize_output(result.stdout) + normalized = _normalize_output(result.output) for line in output_lines: assert line in normalized @@ -261,7 +286,7 @@ def test_analyse_project_negative(projects, output_lines, tmp_path: Path) -> Non options.extend(projects_config) result = runner.invoke(app, options) assert result.exit_code != 0 - normalized = _normalize_output(result.stdout) + normalized = _normalize_output(result.output) for line in output_lines: assert line in normalized @@ -287,7 +312,7 @@ def test_write_rst_invalid_json(tmp_path: Path) -> None: result = runner.invoke(app, options) assert result.exit_code != 0 - assert "Expecting" in result.stdout + assert "Expecting" in result.output @pytest.mark.parametrize( @@ -332,7 +357,7 @@ def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> No result = runner.invoke(app, options) assert result.exit_code != 0 - normalized = _normalize_output(result.stdout) + normalized = _normalize_output(result.output) for line in output_lines: assert line in normalized diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..dd43cfb --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,140 @@ +# Test suite for the environment-aware logging facade (issue #72). +import importlib +import logging + +import pytest +from sphinx.util.logging import VERBOSE + +from sphinx_codelinks import logger as logmod + + +@pytest.fixture(autouse=True) +def _reset_backend(): + """Each test starts and ends with the default (library) backend.""" + logmod.reset() + logmod.logger.configure(verbose=False, quiet=False) + yield + logmod.reset() + logmod.logger.configure(verbose=False, quiet=False) + + +def test_default_backend_drops_info_and_emits_warning(caplog): + """As a plain library (no frontend configured), routine INFO is silently + dropped while genuine warnings still propagate.""" + log = logmod.get_logger("sphinx_codelinks.analyse.sample") + + log.info("routine progress") + log.warning("real problem", subtype="git_root") + + messages = [record.getMessage() for record in caplog.records] + assert "routine progress" not in messages + assert "real problem" in messages + + +def test_cli_backend_routes_info_to_stdout_and_warning_to_stderr(capsys): + """CLI frontend: routine progress goes to stdout, warnings to stderr.""" + logmod.configure_cli(verbose=False, quiet=False) + log = logmod.get_logger("sphinx_codelinks.analyse.sample") + + log.info("files loaded: 3") + log.warning("git root not found", subtype="git_root") + + captured = capsys.readouterr() + assert "files loaded: 3" in captured.out + assert "files loaded: 3" not in captured.err + assert "git root not found" in captured.err + assert "git root not found" not in captured.out + + +def test_cli_backend_quiet_suppresses_info_but_keeps_warning(capsys): + """--quiet hides routine progress but never hides warnings.""" + logmod.configure_cli(verbose=False, quiet=True) + log = logmod.get_logger("sphinx_codelinks.analyse.sample") + + log.info("files loaded: 3") + log.warning("git root not found", subtype="git_root") + + captured = capsys.readouterr() + assert "files loaded: 3" not in captured.out + assert "git root not found" in captured.err + + +def test_cli_backend_debug_is_gated_by_verbose(capsys): + """CLI frontend: detailed debug output is hidden by default, shown with -v.""" + logmod.configure_cli(verbose=False, quiet=False) + logmod.get_logger("sphinx_codelinks.analyse.sample").debug("breakdown detail") + assert "breakdown detail" not in capsys.readouterr().out + + logmod.configure_cli(verbose=True, quiet=False) + logmod.get_logger("sphinx_codelinks.analyse.sample").debug("breakdown detail") + assert "breakdown detail" in capsys.readouterr().out + + +class _ListHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +def test_sphinx_backend_routes_through_sphinx_logging(): + """Sphinx frontend: info is default-visible (INFO), debug is -v only + (VERBOSE), warnings carry the codelinks type/subtype; all under sphinx.*""" + logmod.configure_sphinx() + + handler = _ListHandler() + sphinx_logger = logging.getLogger("sphinx") + sphinx_logger.addHandler(handler) + old_level = sphinx_logger.level + sphinx_logger.setLevel(VERBOSE) + try: + log = logmod.get_logger("sphinx_codelinks.analyse.sample") + log.info("project summary") + log.debug("breakdown detail") + log.warning("git root not found", subtype="git_root", location="x.cpp") + finally: + sphinx_logger.removeHandler(handler) + sphinx_logger.setLevel(old_level) + + # routed under the sphinx-prefixed namespace Sphinx actually captures + assert all( + rec.name == "sphinx.sphinx_codelinks.analyse.sample" for rec in handler.records + ) + + info_records = [r for r in handler.records if "project summary" in r.getMessage()] + assert info_records and info_records[0].levelno == logging.INFO + + debug_records = [r for r in handler.records if "breakdown detail" in r.getMessage()] + assert debug_records and debug_records[0].levelno == VERBOSE + + warn_records = [ + r for r in handler.records if "git root not found" in r.getMessage() + ] + assert warn_records + assert warn_records[0].levelno == logging.WARNING + assert getattr(warn_records[0], "type", None) == "codelinks" + assert getattr(warn_records[0], "subtype", None) == "git_root" + + +ANALYSE_MODULE_LOGGERS = ( + "sphinx_codelinks.analyse.analyse", + "sphinx_codelinks.analyse.oneline_parser", + "sphinx_codelinks.analyse.projects", + "sphinx_codelinks.analyse.utils", +) + + +def test_analyse_modules_install_no_handlers_at_import(): + """Regression guard for #72: importing the analyse layer must not install + handlers or pin levels on its own loggers.""" + for name in ANALYSE_MODULE_LOGGERS: + importlib.import_module(name) + module_logger = logging.getLogger(name) + assert module_logger.handlers == [], ( + f"{name} installed handlers at import: {module_logger.handlers}" + ) + assert module_logger.level == logging.NOTSET, ( + f"{name} pinned its level at import to {module_logger.level}" + )