Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fde93613f | |||
| e4d5a32de2 |
@@ -12,6 +12,7 @@
|
|||||||
- [x] Sample library fixtures
|
- [x] Sample library fixtures
|
||||||
- [ ] Database V2 read-only parser
|
- [ ] Database V2 read-only parser
|
||||||
- [x] Configuration
|
- [x] Configuration
|
||||||
|
- [x] Logging
|
||||||
|
|
||||||
## v0.2 — Diagnostics
|
## v0.2 — Diagnostics
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Application Logging
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The CLI reports final counts but provides no diagnostic trail when a scan behaves
|
||||||
|
unexpectedly. Troubleshooting should not require adding print statements or expose
|
||||||
|
library contents by default.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project uses an isolated standard-library logger. It has no visible output by
|
||||||
|
default. `--verbose` writes progress to standard error, while `--log-file PATH`
|
||||||
|
writes an informational audit trail. Normal result lines remain on standard output.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
- Reconfiguring logging in the same process must not duplicate handlers.
|
||||||
|
- Console and file logging may be enabled together.
|
||||||
|
- Log messages contain aggregate counts, not track names or crate contents.
|
||||||
|
- A default scan must remain quiet except for its established result output.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Tests verify quiet defaults, file output, handler replacement, and unchanged CLI
|
||||||
|
result lines. The full sample-library scan remains read-only.
|
||||||
@@ -3,6 +3,7 @@ import argparse
|
|||||||
|
|
||||||
from serato_doctor.config import ScanConfig
|
from serato_doctor.config import ScanConfig
|
||||||
from serato_doctor.crate_parser import parse_crates
|
from serato_doctor.crate_parser import parse_crates
|
||||||
|
from serato_doctor.logging import configure_logging
|
||||||
from serato_doctor.models.library import Library
|
from serato_doctor.models.library import Library
|
||||||
from serato_doctor.scanner import scan_audio
|
from serato_doctor.scanner import scan_audio
|
||||||
from serato_doctor.report import write_csv, write_missing_report
|
from serato_doctor.report import write_csv, write_missing_report
|
||||||
@@ -20,6 +21,10 @@ def main():
|
|||||||
default=[],
|
default=[],
|
||||||
help="Old library root stored in crates; may be supplied more than once",
|
help="Old library root stored in crates; may be supplied more than once",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose", action="store_true", help="Write diagnostic progress to stderr"
|
||||||
|
)
|
||||||
|
parser.add_argument("--log-file", help="Write scan progress to a log file")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
config = ScanConfig.build(
|
config = ScanConfig.build(
|
||||||
@@ -28,7 +33,13 @@ def main():
|
|||||||
out=Path(args.out),
|
out=Path(args.out),
|
||||||
report=Path(args.report),
|
report=Path(args.report),
|
||||||
reference_roots=(Path(root) for root in args.reference_root),
|
reference_roots=(Path(root) for root in args.reference_root),
|
||||||
|
verbose=args.verbose,
|
||||||
|
log_file=Path(args.log_file) if args.log_file else None,
|
||||||
)
|
)
|
||||||
|
logger = configure_logging(config.verbose, config.log_file)
|
||||||
|
logger.info("Starting read-only library scan")
|
||||||
|
logger.debug("Serato directory: %s", config.serato)
|
||||||
|
logger.debug("Music directory: %s", config.music)
|
||||||
|
|
||||||
library = Library.build(
|
library = Library.build(
|
||||||
references=parse_crates(
|
references=parse_crates(
|
||||||
@@ -38,9 +49,13 @@ def main():
|
|||||||
)
|
)
|
||||||
results = library.reconcile_by_filename()
|
results = library.reconcile_by_filename()
|
||||||
missing_count = sum(1 for result in results if not result.exists_by_filename)
|
missing_count = sum(1 for result in results if not result.exists_by_filename)
|
||||||
|
logger.info("Parsed %d crate references", len(library.references))
|
||||||
|
logger.info("Scanned %d disk tracks", len(library.tracks))
|
||||||
|
logger.info("Found %d references missing by filename", missing_count)
|
||||||
|
|
||||||
write_csv(results, config.out)
|
write_csv(results, config.out)
|
||||||
write_missing_report(results, config.report)
|
write_missing_report(results, config.report)
|
||||||
|
logger.info("Wrote CSV and missing-reference reports")
|
||||||
|
|
||||||
print(f"Crate references: {len(library.references)}")
|
print(f"Crate references: {len(library.references)}")
|
||||||
print(f"Disk tracks: {len(library.tracks)}")
|
print(f"Disk tracks: {len(library.tracks)}")
|
||||||
|
|||||||
+14
-2
@@ -1,6 +1,6 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Tuple
|
from typing import Iterable, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -12,6 +12,8 @@ class ScanConfig:
|
|||||||
out: Path
|
out: Path
|
||||||
report: Path
|
report: Path
|
||||||
reference_roots: Tuple[Path, ...] = ()
|
reference_roots: Tuple[Path, ...] = ()
|
||||||
|
verbose: bool = False
|
||||||
|
log_file: Optional[Path] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def build(
|
def build(
|
||||||
@@ -21,5 +23,15 @@ class ScanConfig:
|
|||||||
out: Path,
|
out: Path,
|
||||||
report: Path,
|
report: Path,
|
||||||
reference_roots: Iterable[Path] = (),
|
reference_roots: Iterable[Path] = (),
|
||||||
|
verbose: bool = False,
|
||||||
|
log_file: Optional[Path] = None,
|
||||||
) -> "ScanConfig":
|
) -> "ScanConfig":
|
||||||
return cls(serato, music, out, report, tuple(reference_roots))
|
return cls(
|
||||||
|
serato,
|
||||||
|
music,
|
||||||
|
out,
|
||||||
|
report,
|
||||||
|
tuple(reference_roots),
|
||||||
|
verbose,
|
||||||
|
log_file,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER_NAME = "serato_doctor"
|
||||||
|
LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(
|
||||||
|
verbose: bool = False, log_file: Optional[Path] = None
|
||||||
|
) -> logging.Logger:
|
||||||
|
"""Configure isolated application logging and return the project logger."""
|
||||||
|
|
||||||
|
logger = logging.getLogger(LOGGER_NAME)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
for handler in logger.handlers[:]:
|
||||||
|
handler.close()
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
formatter = logging.Formatter(LOG_FORMAT)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
console = logging.StreamHandler()
|
||||||
|
console.setLevel(logging.DEBUG)
|
||||||
|
console.setFormatter(formatter)
|
||||||
|
logger.addHandler(console)
|
||||||
|
|
||||||
|
if log_file is not None:
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(logging.NullHandler())
|
||||||
|
|
||||||
|
return logger
|
||||||
+3
-1
@@ -37,7 +37,9 @@ def test_cli_writes_reports_and_prints_counts(tmp_path, monkeypatch, capsys):
|
|||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
||||||
output = capsys.readouterr().out
|
captured = capsys.readouterr()
|
||||||
|
output = captured.out
|
||||||
|
assert captured.err == ""
|
||||||
assert "Crate references: 2" in output
|
assert "Crate references: 2" in output
|
||||||
assert "Disk tracks: 1" in output
|
assert "Disk tracks: 1" in output
|
||||||
assert "Missing by filename: 1" in output
|
assert "Missing by filename: 1" in output
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from serato_doctor.logging import LOGGER_NAME, configure_logging
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_is_quiet_by_default(capsys):
|
||||||
|
logger = configure_logging()
|
||||||
|
|
||||||
|
logger.info("not visible")
|
||||||
|
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_writes_aggregate_progress_to_file(tmp_path):
|
||||||
|
log_path = tmp_path / "scan.log"
|
||||||
|
logger = configure_logging(log_file=log_path)
|
||||||
|
|
||||||
|
logger.info("Scanned %d disk tracks", 10)
|
||||||
|
|
||||||
|
contents = log_path.read_text(encoding="utf-8")
|
||||||
|
assert "INFO Scanned 10 disk tracks" in contents
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconfiguring_logging_replaces_handlers():
|
||||||
|
configure_logging(verbose=True)
|
||||||
|
logger = configure_logging(verbose=True)
|
||||||
|
|
||||||
|
active_handlers = [
|
||||||
|
handler
|
||||||
|
for handler in logger.handlers
|
||||||
|
if not isinstance(handler, logging.NullHandler)
|
||||||
|
]
|
||||||
|
assert logger.name == LOGGER_NAME
|
||||||
|
assert len(active_handlers) == 1
|
||||||
Reference in New Issue
Block a user