Skip to content

Commit c9e78da

Browse files
authored
🐛 Route analyse/ logging by environment instead of a stderr handler at import (#73)
## Summary Fixes #72. The `analyse/` layer installed a `logging.StreamHandler` on its own loggers at **import time**, so routine INFO progress (`Source files loaded: N`, …) went to **stderr** unconditionally — reddened by tox/CI even though the build succeeds (exit 0) — and importing the package took the handler/level/stream choice away from consumers (against the stdlib guidance for libraries). This adds an environment-aware logging facade and routes the shared `analyse/` layer through whichever frontend is active, modelled on `sphinx_needs/logging.py`. ## What changed - **`logger.py`** — new `get_logger(name)` facade over a swappable backend: - **default (plain library):** stdlib logging, *no handler installed* → INFO dropped silently, WARNING+ to stderr via `logging.lastResort`. - **CLI:** the existing rich logger → INFO to **stdout**, WARNING to stderr, honouring `--verbose`/`--quiet`. - **Sphinx:** `sphinx.util.logging` → progress at `VERBOSE`, warnings carrying `type="codelinks"` + a `subtype` (suppressible via `suppress_warnings`, on the Sphinx warning stream). - **`analyse/{analyse,utils,projects,oneline_parser}.py`** — drop the import-time handler; use `get_logger(__name__)`. `utils.py`'s git warnings (previously `logging.warning()` on the **root** logger, which triggers `basicConfig`) now go through the facade with subtypes. `oneline_parser.py`'s dead logging block (no log calls) is removed. - **Entry points** — the CLI `analyse` command gains `-v/--verbose` & `-q/--quiet` and calls `configure_cli(...)`; the Sphinx `setup()` calls `configure_sphinx()`. ## Behaviour | Environment | INFO progress | WARNING | |---|---|---| | plain library (no configure) | dropped | stderr (`lastResort`) | | CLI (default) | stdout | stderr | | CLI `--quiet` | hidden | stderr | | Sphinx (normal build) | hidden | Sphinx warning stream (`codelinks.<subtype>`) | | Sphinx `-v` | Sphinx status stream | Sphinx warning stream | The accessible-pygments double-print noted in the issue disappears too, since codelinks no longer emits INFO that propagates to the root logger. `propagate` is left untouched (so consumers can still capture our logs). ## Testing - New `tests/test_logger.py`: no handler installed at import (regression guard for #72); default mode drops INFO / emits WARNING; CLI routes INFO→stdout & WARNING→stderr with `--quiet` suppressing INFO; Sphinx routes via `SphinxLoggerAdapter` with `type`/`subtype`. - New CLI test in `tests/test_cmd.py` for the `analyse` progress / `--quiet` gating. - Full suite green (212 passed), `mypy --strict` clean, `ruff` clean on changed files. - Manual repro — real `sphinx-build` of `tests/data/sphinx`: the INFO progress no longer appears on stderr (was flooded before, including the doubled accessible-pygments lines); only genuine warnings remain.
1 parent a65483e commit c9e78da

12 files changed

Lines changed: 428 additions & 62 deletions

File tree

src/sphinx_codelinks/analyse/analyse.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from collections.abc import Generator
22
from dataclasses import dataclass
33
import json
4-
import logging
54
from pathlib import Path
65
from typing import Any, TypedDict
76

@@ -26,14 +25,14 @@
2625
OneLineCommentStyle,
2726
SourceAnalyseConfig,
2827
)
28+
from sphinx_codelinks.logger import get_logger
2929

30-
# initialize logger
31-
logger = logging.getLogger(__name__)
32-
logger.setLevel(logging.INFO)
33-
# log to the console
34-
console = logging.StreamHandler()
35-
console.setLevel(logging.INFO)
36-
logger.addHandler(console)
30+
logger = get_logger(__name__)
31+
32+
33+
def _count(n: int, noun: str) -> str:
34+
"""Format ``n noun`` with a naive (append-s) plural for progress summaries."""
35+
return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
3736

3837

3938
class AnalyseWarningType(TypedDict):
@@ -57,7 +56,10 @@ class SourceAnalyse:
5756
def __init__(
5857
self,
5958
analyse_config: SourceAnalyseConfig,
59+
*,
60+
name: str = "",
6061
) -> None:
62+
self.name = name
6163
self.analyse_config = analyse_config
6264
self.src_files: list[SourceFile] = []
6365
self.src_comments: list[SourceComment] = []
@@ -110,9 +112,6 @@ def create_src_objects(self) -> None:
110112
self.src_files.append(src_file)
111113
self.src_comments.extend(src_comments)
112114

113-
logger.info(f"Source files loaded: {len(self.src_files)}")
114-
logger.info(f"Source comments extracted: {len(self.src_comments)}")
115-
116115
def extract_marker(
117116
self,
118117
text: str,
@@ -353,13 +352,6 @@ def extract_marked_content(self) -> None:
353352
if marked_rst:
354353
self.marked_rst.append(marked_rst)
355354

356-
if self.analyse_config.get_need_id_refs:
357-
logger.info(f"Need-id-refs extracted: {len(self.need_id_refs)}")
358-
if self.analyse_config.get_oneline_needs:
359-
logger.info(f"Oneline needs extracted: {len(self.oneline_needs)}")
360-
if self.analyse_config.get_rst:
361-
logger.info(f"Marked rst extracted: {len(self.marked_rst)}")
362-
363355
def merge_marked_content(self) -> None:
364356
self.all_marked_content.extend(self.need_id_refs)
365357
self.oneline_needs.sort(key=lambda x: x.source_map["start"]["row"])
@@ -378,9 +370,23 @@ def dump_marked_content(self, outdir: Path) -> None:
378370
]
379371
with output_path.open("w") as f:
380372
json.dump(to_dump, f)
381-
logger.info(f"Marked content dumped to {output_path}")
382373

383374
def run(self) -> None:
384375
self.create_src_objects()
385376
self.extract_marked_content()
386377
self.merge_marked_content()
378+
self._log_summary()
379+
380+
def _log_summary(self) -> None:
381+
"""Emit a per-project marker (default-visible) plus a -v breakdown."""
382+
label = f"codelinks [{self.name}]" if self.name else "codelinks"
383+
logger.info(
384+
f"{label}: {_count(len(self.src_files), 'file')}, "
385+
f"{_count(len(self.all_marked_content), 'marker')}"
386+
)
387+
logger.debug(
388+
f"{label}: {_count(len(self.src_comments), 'comment')}, "
389+
f"{_count(len(self.oneline_needs), 'oneline need')}, "
390+
f"{_count(len(self.need_id_refs), 'id-ref')}, "
391+
f"{_count(len(self.marked_rst), 'marked-rst block')}"
392+
)

src/sphinx_codelinks/analyse/oneline_parser.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
from dataclasses import dataclass
22
from enum import Enum
3-
import logging
43

54
from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle
65

7-
# initialize logger
8-
logger = logging.getLogger(__name__)
9-
logger.setLevel(logging.INFO)
10-
# log to the console
11-
console = logging.StreamHandler()
12-
console.setLevel(logging.INFO)
13-
logger.addHandler(console)
14-
156

167
class WarningSubTypeEnum(str, Enum):
178
"""Enum for warning sub types."""

src/sphinx_codelinks/analyse/projects.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
import logging
32
from pathlib import Path
43
from typing import cast
54

@@ -9,14 +8,9 @@
98
SourceAnalyse,
109
)
1110
from sphinx_codelinks.config import CodeLinksConfig, CodeLinksProjectConfigType
11+
from sphinx_codelinks.logger import get_logger
1212

13-
# initialize logger
14-
logger = logging.getLogger(__name__)
15-
logger.setLevel(logging.INFO)
16-
# log to the console
17-
console = logging.StreamHandler()
18-
console.setLevel(logging.INFO)
19-
logger.addHandler(console)
13+
logger = get_logger(__name__)
2014

2115

2216
class AnalyseProjects:
@@ -32,7 +26,7 @@ def __init__(self, codelink_config: CodeLinksConfig) -> None:
3226

3327
def run(self) -> None:
3428
for project, config in self.projects_configs.items():
35-
src_analyse = SourceAnalyse(config["analyse_config"])
29+
src_analyse = SourceAnalyse(config["analyse_config"], name=project)
3630
src_analyse.run()
3731
self.projects_analyse[project] = src_analyse
3832

@@ -46,7 +40,7 @@ def dump_markers(self) -> None:
4640
}
4741
with output_path.open("w") as f:
4842
json.dump(to_dump, f)
49-
logger.info(f"Marked content dumped to {output_path}")
43+
logger.debug(f"codelinks: marked content dumped to {output_path}")
5044

5145
@classmethod
5246
def load_warnings(cls, warnings_dir: Path) -> list[AnalyseWarning] | None:

src/sphinx_codelinks/analyse/utils.py

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from collections.abc import ByteString, Callable
22
import configparser
3-
import logging
43
from pathlib import Path
54
from typing import TypedDict
65
from urllib.request import pathname2url
@@ -10,6 +9,7 @@
109
from tree_sitter import Node as TreeSitterNode
1110

1211
from sphinx_codelinks.config import UNIX_NEWLINE, CommentCategory
12+
from sphinx_codelinks.logger import get_logger
1313
from sphinx_codelinks.source_discover.config import CommentType
1414

1515
# Language-specific node types for scope detection
@@ -31,13 +31,7 @@
3131
},
3232
}
3333

34-
# initialize logger
35-
logger = logging.getLogger(__name__)
36-
logger.setLevel(logging.INFO)
37-
# log to the console
38-
console = logging.StreamHandler()
39-
console.setLevel(logging.INFO)
40-
logger.addHandler(console)
34+
logger = get_logger(__name__)
4135

4236
GIT_HOST_URL_TEMPLATE = {
4337
"github": "https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{lineno}",
@@ -270,15 +264,23 @@ def locate_git_root(src_dir: Path) -> Path | None:
270264
for parent in parents:
271265
if (parent / ".git").exists() and (parent / ".git").is_dir():
272266
return parent
273-
logger.warning(f"git root is not found in the parent of {src_dir}")
267+
logger.warning(
268+
f"git root is not found in the parent of {src_dir}",
269+
subtype="git_root",
270+
location=str(src_dir),
271+
)
274272
return None
275273

276274

277275
def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
278276
"""Get remote url from .git/config."""
279277
config_path = git_root / ".git" / "config"
280278
if not config_path.exists():
281-
logging.warning(f"{config_path} does not exist")
279+
logger.warning(
280+
f"{config_path} does not exist",
281+
subtype="git_config",
282+
location=str(config_path),
283+
)
282284
return None
283285

284286
config = configparser.ConfigParser(allow_no_value=True, strict=False)
@@ -287,24 +289,37 @@ def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
287289
if section in config and "url" in config[section]:
288290
url: str = config[section]["url"]
289291
return url
290-
logger.warning(f"remote-url is not found in {config_path}")
292+
logger.warning(
293+
f"remote-url is not found in {config_path}",
294+
subtype="git_remote",
295+
location=str(config_path),
296+
)
291297
return None
292298

293299

294300
def get_current_rev(git_root: Path) -> str | None:
295301
"""Get current commit rev from .git/HEAD."""
296302
head_path = git_root / ".git" / "HEAD"
297303
if not head_path.exists():
298-
logging.warning(f"{head_path} does not exist")
304+
logger.warning(
305+
f"{head_path} does not exist",
306+
subtype="git_head",
307+
location=str(head_path),
308+
)
299309
return None
300310
head_content = head_path.read_text().strip()
301311
if not head_content.startswith("ref: "):
302-
logging.warning(f"Expect starting with 'ref: ' in {head_path}")
303-
return None
312+
# Detached HEAD (e.g. CI checkouts): .git/HEAD holds the commit SHA
313+
# directly, which is exactly the rev we want.
314+
return head_content
304315

305316
ref_path = git_root / ".git" / head_content.split(":", 1)[1].strip()
306317
if not ref_path.exists():
307-
logging.warning(f"{ref_path} does not exist")
318+
logger.warning(
319+
f"{ref_path} does not exist",
320+
subtype="git_ref",
321+
location=str(ref_path),
322+
)
308323
return None
309324
return ref_path.read_text().strip()
310325

@@ -315,7 +330,10 @@ def form_https_url(
315330
parsed_url = parse(git_url)
316331
template = GIT_HOST_URL_TEMPLATE.get(parsed_url.platform)
317332
if not template:
318-
logging.warning(f"Unsupported Git host: {parsed_url.platform}")
333+
logger.warning(
334+
f"Unsupported Git host: {parsed_url.platform}",
335+
subtype="git_host",
336+
)
319337
return git_url
320338
https_url = template.format(
321339
owner=parsed_url.owner,

src/sphinx_codelinks/cmd.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
CodeLinksProjectConfigType,
1515
generate_project_configs,
1616
)
17-
from sphinx_codelinks.logger import logger
17+
from sphinx_codelinks.logger import configure_cli, logger
1818
from sphinx_codelinks.needextend_write import MarkedObjType, convert_marked_content
1919
from sphinx_codelinks.source_discover.config import (
2020
CommentType,
@@ -88,9 +88,12 @@ def analyse( # noqa: PLR0912 # for CLI, so it needs the branches
8888
exists=True,
8989
),
9090
] = None,
91+
verbose: OptVerbose = False,
92+
quiet: OptQuiet = False,
9193
) -> None:
9294
"""Analyse marked content in source code."""
9395
# @CLI command to analyse source code and extract traceability markers, IMPL_CLI_ANALYZE, impl, [FE_CLI_ANALYZE]
96+
configure_cli(verbose, quiet)
9497

9598
data: CodeLinksConfigType = load_config_from_toml(config)
9699

@@ -291,7 +294,7 @@ def write_rst( # noqa: PLR0913 # for CLI, so it takes as many as it requires
291294
quiet: OptQuiet = False,
292295
) -> None:
293296
"""Generate needextend.rst from the extracted obj in JSON."""
294-
logger.configure(verbose, quiet)
297+
configure_cli(verbose, quiet)
295298
try:
296299
with jsonpath.open("r") as f:
297300
marked_content = json.load(f)

0 commit comments

Comments
 (0)