From 7c49a712acaed5db690582bd23261d0908f40114 Mon Sep 17 00:00:00 2001 From: Brian Ruf Date: Thu, 14 May 2026 23:05:55 +0000 Subject: [PATCH 1/2] updated PUBLISHING.md --- docs/PUBLISHING.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index ce6b7e6..599f97e 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -27,15 +27,16 @@ git commit -m "Bump version to vX.Y.Z" git push ``` -### 3. Tag the commit +### 3. Merge with Main +https://github.com/brian-ruf/ruf-common-python/pulls + +### 4. Tag the commit ```bash git tag vX.Y.Z git push origin vX.Y.Z ``` -### 4. Merge with Main - ### 5. Create the GitHub Release 1. Go to [Releases → New release](https://github.com/brian-ruf/ruf-common-python/releases/new) From 714c267912b8b8bc57cfebd5c12a6dbc41023a12 Mon Sep 17 00:00:00 2001 From: Brian Ruf Date: Sun, 12 Jul 2026 02:40:32 +0000 Subject: [PATCH 2/2] migrated from loguru to standard logging --- docs/LOGGING.md | 127 +++++++++++++++++++++++++++ pyproject.toml | 3 +- ruf_common/__init__.py | 62 ++++++------- ruf_common/country_code_converter.py | 4 +- ruf_common/data.py | 4 +- ruf_common/database.py | 6 +- ruf_common/database_sqlite3.py | 4 +- ruf_common/helper.py | 4 +- ruf_common/lfs.py | 4 +- ruf_common/logging.py | 99 --------------------- ruf_common/network.py | 4 +- tests/unit/test_logging_module.py | 120 ------------------------- 12 files changed, 181 insertions(+), 260 deletions(-) create mode 100644 docs/LOGGING.md delete mode 100644 ruf_common/logging.py delete mode 100644 tests/unit/test_logging_module.py diff --git a/docs/LOGGING.md b/docs/LOGGING.md new file mode 100644 index 0000000..7b9482c --- /dev/null +++ b/docs/LOGGING.md @@ -0,0 +1,127 @@ +# Logging in `ruf_common` + +`ruf_common` uses the Python standard-library `logging` module and follows the +standard "library" contract: + +- Every module logs to a named logger obtained with `logging.getLogger(__name__)` + (e.g. `ruf_common.network`, `ruf_common.data`, `ruf_common.lfs`, ...). These are + all descendants of the top-level `ruf_common` logger. +- The package installs a single `logging.NullHandler` on the `ruf_common` logger. + The library **never** adds a real handler, never writes to a file, never calls + `logging.basicConfig()`, and never touches the root logger. + +The practical consequences: + +- The library is **silent by default**. `INFO` and `DEBUG` messages are suppressed + unless you opt in. +- `WARNING`/`ERROR`/`CRITICAL` will surface if — and only where — *your* + application has configured logging. If your app has configured nothing at all, + Python's built-in "last resort" prints `WARNING` and above to `stderr`. (This is + standard-library behaviour, not something `ruf_common` does.) +- **You** decide entirely where logs go and how log files are managed. The library + contributes log *records*; your handlers decide the destination, formatting, + rotation, retention, etc. + +## Turning logging on + +Enabling is a one-liner: point a handler at the `ruf_common` logger and set the +level you want. + +```python +import logging +import ruf_common + +# Send ruf_common's logs to the console at DEBUG. +logging.basicConfig(level=logging.WARNING) # your app's root config +logging.getLogger("ruf_common").setLevel(logging.DEBUG) # this library, verbose +``` + +`basicConfig` installs a handler on the **root** logger; because `ruf_common` +loggers propagate, their records reach it. The per-library `setLevel` call is what +turns `INFO`/`DEBUG` on for this library specifically without making the rest of +your application verbose. + +### Per-library and finer-grained control + +The logger name gives you as much granularity as you want: + +```python +import logging + +logging.getLogger("ruf_common").setLevel(logging.INFO) # whole library +logging.getLogger("ruf_common.network").setLevel(logging.DEBUG) # just one module +``` + +### Sending logs somewhere other than the console + +Attach whatever handler you like directly to the library logger — a file, a +rotating file, syslog, etc. The library never does this for you: + +```python +import logging +from logging.handlers import RotatingFileHandler + +handler = RotatingFileHandler("myapp.log", maxBytes=10_000_000, backupCount=3) +handler.setFormatter(logging.Formatter( + "%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s - %(message)s" +)) + +log = logging.getLogger("ruf_common") +log.setLevel(logging.DEBUG) +log.addHandler(handler) +``` + +### Turning it back off + +```python +import logging +logging.getLogger("ruf_common").setLevel(logging.WARNING) # silence INFO/DEBUG again +``` + +## Advanced: incorporating `ruf_common` logs into loguru + +If your application uses [loguru](https://loguru.readthedocs.io/) instead of the +standard library, `ruf_common`'s records won't reach loguru automatically — the two +systems are separate. `ruf_common` emits standard-library records; loguru manages +its own sinks. You bridge them **once, in your application**, by adding a small +stdlib handler that forwards records into loguru (loguru's documented +`InterceptHandler` pattern). `ruf_common` needs no changes — it just keeps emitting +records. + +```python +import logging +import sys +from loguru import logger + +# --- your application's own loguru configuration (sinks, files, formats) --- +logger.remove() # drop loguru's default handler +logger.add(sys.stderr, level="INFO") # console +logger.add("app.log", level="DEBUG", # rotating file, managed by YOU + rotation="10 MB", retention="1 week", compression="zip") + +# --- bridge: forward stdlib records (from ruf_common, etc.) into loguru --- +class InterceptHandler(logging.Handler): + def emit(self, record): + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + logger.opt(depth=6, exception=record.exc_info).log(level, record.getMessage()) + +# Route just this library into loguru, and choose how verbose it should be: +lib = logging.getLogger("ruf_common") +lib.setLevel(logging.DEBUG) # let DEBUG/INFO through for ruf_common +lib.addHandler(InterceptHandler()) # send its records to your loguru sinks +lib.propagate = False # optional: don't also hit the stdlib root logger +``` + +From here on, everything `ruf_common` logs flows into whatever loguru sinks your +application configured (console, `app.log`, etc.), formatted and rotated entirely +by your application's rules. + +> Incorporating `ruf_common` into **other** logging frameworks is typically the +> same shape: because `ruf_common` emits ordinary standard-library log records, any +> framework that can consume them works. For example, `structlog` reads stdlib +> records via its `ProcessorFormatter`; you attach that formatter's handler to the +> `ruf_common` logger exactly as shown above. Only the "install a handler that +> adapts stdlib records into framework X" step differs. diff --git a/pyproject.toml b/pyproject.toml index bf29c1b..97b6527 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ruf-common" -version = "2.1.0" +version = "2.2.0" description = "Functions common to several of Brian's Python projects." requires-python = ">=3.9" license = "MIT" @@ -18,7 +18,6 @@ classifiers = [ ] keywords = ["utilities", "common", "helpers"] dependencies = [ - "loguru>=0.7.3", "elementpath>=4.7.0", "pytz>=2025.2", "tzlocal>=5.3.1", diff --git a/ruf_common/__init__.py b/ruf_common/__init__.py index cae2dc8..7834f03 100644 --- a/ruf_common/__init__.py +++ b/ruf_common/__init__.py @@ -1,31 +1,31 @@ -from loguru import logger - -from . import country_code_converter -from . import data -from . import database -from . import helper -from . import html_to_markdown -from . import lfs -from . import network -from . import logging -from . import stats -from . import timezone_lookup -from . import xml_formatter - -# Disable logging by default; consumers can enable with: -# logger.enable("ruf_common") -logger.disable("ruf_common") - -__all__ = [ - "data", - "database", - "lfs", - "helper", - "network", - "logging", - "stats", - "country_code_converter", - "html_to_markdown", - "timezone_lookup", - "xml_formatter" -] +import logging + +# The library emits log records but installs no handler of its own, so it stays +# silent unless the calling application configures logging. Consumers enable it +# with e.g. ``logging.getLogger("ruf_common").setLevel(logging.INFO)`` plus a +# handler of their choosing. See docs/LOGGING.md. +logging.getLogger("ruf_common").addHandler(logging.NullHandler()) + +from . import country_code_converter +from . import data +from . import database +from . import helper +from . import html_to_markdown +from . import lfs +from . import network +from . import stats +from . import timezone_lookup +from . import xml_formatter + +__all__ = [ + "data", + "database", + "lfs", + "helper", + "network", + "stats", + "country_code_converter", + "html_to_markdown", + "timezone_lookup", + "xml_formatter" +] diff --git a/ruf_common/country_code_converter.py b/ruf_common/country_code_converter.py index e066a2d..7e235a0 100644 --- a/ruf_common/country_code_converter.py +++ b/ruf_common/country_code_converter.py @@ -2,7 +2,9 @@ Python functions to convert country names to ISO 3166-1 alpha-2 country codes. Provides multiple approaches from simple dictionary lookup to library-based solutions. """ -from loguru import logger +import logging + +logger = logging.getLogger(__name__) # Method 1: Simple dictionary approach (most common countries) COUNTRY_TO_CODE = { diff --git a/ruf_common/data.py b/ruf_common/data.py index 9ee2723..57e9c13 100644 --- a/ruf_common/data.py +++ b/ruf_common/data.py @@ -6,10 +6,12 @@ import elementpath import xml.etree.ElementTree as ET from xml.etree.ElementTree import tostring -from loguru import logger +import logging from typing import Any, cast +logger = logging.getLogger(__name__) + # ------------------------------------------------------------------------- def detect_data_format(content: str) -> str: """Detect whether the content is XML, JSON, or YAML based on its starting characters.""" diff --git a/ruf_common/database.py b/ruf_common/database.py index b41b818..5e96d5a 100644 --- a/ruf_common/database.py +++ b/ruf_common/database.py @@ -14,10 +14,12 @@ # ============================================================================= import sqlite3 import uuid as uuid_module +import logging from typing import Any, Optional, Union -from loguru import logger -from . import helper +from . import helper from . import database_sqlite3 + +logger = logging.getLogger(__name__) # import asyncio # List of supported databses: diff --git a/ruf_common/database_sqlite3.py b/ruf_common/database_sqlite3.py index a64912b..9ba045a 100644 --- a/ruf_common/database_sqlite3.py +++ b/ruf_common/database_sqlite3.py @@ -3,9 +3,11 @@ These functions assume that the SQLite3 database is already created and """ import os -from loguru import logger +import logging import pickle from typing import Any, Optional, Dict + +logger = logging.getLogger(__name__) import zlib import sqlite3 from .helper import convert_datetime_format diff --git a/ruf_common/helper.py b/ruf_common/helper.py index 16fbff5..61d817f 100644 --- a/ruf_common/helper.py +++ b/ruf_common/helper.py @@ -18,9 +18,11 @@ from tzlocal import get_localzone import json import getpass as gt -from loguru import logger +import logging from typing import Dict, Any, Union +logger = logging.getLogger(__name__) + # ----------------------------------------------------------------------------- # ============================================================================= # DATE/TIME FUNCTIONS diff --git a/ruf_common/lfs.py b/ruf_common/lfs.py index 2c6f853..c52a528 100644 --- a/ruf_common/lfs.py +++ b/ruf_common/lfs.py @@ -4,9 +4,11 @@ import errno import sys import json +import logging from ruf_common.helper import normalize_content, datetime_string -from loguru import logger from pathlib import Path + +logger = logging.getLogger(__name__) # from datetime import datetime # ============================================================================= diff --git a/ruf_common/logging.py b/ruf_common/logging.py deleted file mode 100644 index 7ead9b4..0000000 --- a/ruf_common/logging.py +++ /dev/null @@ -1,99 +0,0 @@ -from loguru import logger -from typing import Any, List, Dict, Optional -import sys - -class DictSink: - """Custom sink that captures log records as dictionaries.""" - - def __init__(self) -> None: - self.records: List[Dict] = [] - - def write(self, message: Any) -> None: - record = message.record - log_entry = { - 'timestamp': record['time'].isoformat(), - 'level': record['level'].name, - 'message': record['message'], - 'module': record['module'], - 'function': record['function'], - 'line': record['line'] - } - if record['exception']: - log_entry['exception'] = { - 'type': record['exception'].type.__name__, - 'value': str(record['exception'].value), - } - self.records.append(log_entry) - - def get_records(self) -> List[Dict]: - return self.records.copy() - - def clear(self) -> None: - self.records.clear() - - -class LoggableMixin: - """Mixin to add flexible logging capabilities to any class.""" - - def setup_logging(self, log_mode: str = 'console', - log_file: Optional[str] = None, - log_level: str = 'INFO'): - """ - Setup logging for this instance. - - Args: - log_mode: 'console', 'file', 'dict', or 'both' - log_file: Path to log file (required if log_mode includes file) - log_level: Minimum log level - """ - # Remove any existing handlers for this instance - if hasattr(self, '_handler_ids'): - for handler_id in self._handler_ids: - logger.remove(handler_id) - - self._handler_ids = [] - self._dict_sink = None - - if log_mode in ('console', 'both'): - handler_id = logger.add( - sys.stderr, - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function} - {message}", - level=log_level, - colorize=True - ) - self._handler_ids.append(handler_id) - - if log_mode in ('file', 'both'): - if not log_file: - raise ValueError("log_file required when log_mode includes 'file'") - handler_id = logger.add( - log_file, - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function} - {message}", - level='DEBUG', - rotation="10 MB", - retention="1 week", - compression="zip" - ) - self._handler_ids.append(handler_id) - - if log_mode == 'dict': - self._dict_sink = DictSink() - handler_id = logger.add(self._dict_sink, format="{message}", level='DEBUG') - self._handler_ids.append(handler_id) - - def get_logs(self) -> List[Dict]: - """Get captured log records (only works in 'dict' mode).""" - if hasattr(self, '_dict_sink') and self._dict_sink: - return self._dict_sink.get_records() - return [] - - def clear_logs(self) -> None: - """Clear captured log records.""" - if hasattr(self, '_dict_sink') and self._dict_sink: - self._dict_sink.clear() - - def cleanup_logging(self) -> None: - """Remove all handlers added by this instance.""" - if hasattr(self, '_handler_ids'): - for handler_id in self._handler_ids: - logger.remove(handler_id) diff --git a/ruf_common/network.py b/ruf_common/network.py index 17fd183..41005eb 100644 --- a/ruf_common/network.py +++ b/ruf_common/network.py @@ -1,10 +1,12 @@ +import logging import requests -from loguru import logger from . import helper import socket import aiohttp from typing import Any, Optional +logger = logging.getLogger(__name__) + def check_internet_connection() -> bool: try: diff --git a/tests/unit/test_logging_module.py b/tests/unit/test_logging_module.py deleted file mode 100644 index a1d587f..0000000 --- a/tests/unit/test_logging_module.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tests for the logging module (DictSink and LoggableMixin).""" - -import pytest -from loguru import logger - -from ruf_common.logging import DictSink, LoggableMixin - - -class TestDictSink: - @pytest.fixture - def sink(self): - s = DictSink() - handler_id = logger.add(s, format="{message}", level="DEBUG") - yield s - logger.remove(handler_id) - - def test_captures_log_record(self, sink): - logger.info("test message") - records = sink.get_records() - assert any(r["message"] == "test message" for r in records) - - def test_record_has_required_fields(self, sink): - logger.warning("check fields") - records = sink.get_records() - assert len(records) > 0 - record = records[-1] - assert "timestamp" in record - assert "level" in record - assert "message" in record - assert "module" in record - assert "function" in record - assert "line" in record - - def test_level_captured_correctly(self, sink): - logger.error("an error") - records = sink.get_records() - error_records = [r for r in records if r["message"] == "an error"] - assert len(error_records) == 1 - assert error_records[0]["level"] == "ERROR" - - def test_get_records_returns_copy(self, sink): - logger.info("msg") - r1 = sink.get_records() - r2 = sink.get_records() - assert r1 == r2 - r1.clear() - assert len(sink.get_records()) > 0 # original unaffected - - def test_clear_removes_records(self, sink): - logger.info("something") - sink.clear() - assert sink.get_records() == [] - - def test_multiple_records(self, sink): - logger.info("one") - logger.info("two") - logger.info("three") - records = sink.get_records() - messages = [r["message"] for r in records] - assert "one" in messages - assert "two" in messages - assert "three" in messages - - -class TestLoggableMixin: - class _MyClass(LoggableMixin): - pass - - @pytest.fixture - def obj(self): - instance = self._MyClass() - yield instance - try: - instance.cleanup_logging() - except ValueError: - pass # handlers already removed in the test - - def test_setup_dict_mode(self, obj): - obj.setup_logging(log_mode="dict") - assert obj._dict_sink is not None - - def test_get_logs_returns_list(self, obj): - obj.setup_logging(log_mode="dict") - logs = obj.get_logs() - assert isinstance(logs, list) - - def test_clear_logs(self, obj): - obj.setup_logging(log_mode="dict") - logger.debug("captured") - obj.clear_logs() - assert obj.get_logs() == [] - - def test_get_logs_without_dict_mode_returns_empty(self, obj): - assert obj.get_logs() == [] - - def test_cleanup_removes_handlers(self, obj): - obj.setup_logging(log_mode="dict") - handler_ids_before = list(obj._handler_ids) - obj.cleanup_logging() - assert obj._handler_ids == handler_ids_before # ids still stored, but removed from loguru - - def test_file_mode_requires_log_file(self, obj): - with pytest.raises(ValueError): - obj.setup_logging(log_mode="file", log_file=None) - - def test_file_mode_creates_file(self, obj, tmp_path): - log_file = str(tmp_path / "test.log") - obj.setup_logging(log_mode="file", log_file=log_file) - logger.info("file test") - obj.cleanup_logging() - assert os.path.exists(log_file) - - def test_setup_reinitializes(self, obj): - obj.setup_logging(log_mode="dict") - first_id = list(obj._handler_ids) - obj.setup_logging(log_mode="dict") - assert obj._handler_ids != first_id or len(obj._handler_ids) > 0 - - -import os # noqa: E402 — needed for test_file_mode_creates_file