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/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)
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