This commit is contained in:
M. A. Reza 2026-02-18 15:35:05 +03:30
commit 7ff887027e
8 changed files with 739 additions and 0 deletions

256
.gitignore vendored Normal file
View File

@ -0,0 +1,256 @@
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,python,visualstudiocode,vim
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,python,visualstudiocode,vim
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,python,visualstudiocode,vim

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.11

8
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"cSpell.words": [
"jalali",
"jdatetime",
"reza",
"setuptools"
]
}

25
README.md Normal file
View File

@ -0,0 +1,25 @@
# Python Reza Logging
## Usage
```python
setup_logging(
calendar="jalali",
include_app_name=False,
include_name=True,
include_pid=False,
include_thread=False,
console_level=logging.INFO,
use_rich=True,
time_zone_name="Asia/Tehran",
)
logging.getLogger("elasticsearch").setLevel(logging.WARNING)
logging.getLogger("elastic_transport").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("tortoise.db_client").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("tortoise").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
log = logging.getLogger(__name__)
log.info("script_started")
```

21
pyproject.toml Normal file
View File

@ -0,0 +1,21 @@
[project]
name = "py-reza-logging"
version = "0.1.0"
description = "Reusable logging setup utilities"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[project.optional-dependencies]
rich = ["rich>=14.3.2"]
jalali = ["jdatetime>=5.2.0"]

View File

@ -0,0 +1,3 @@
from .setup import setup_logging
__all__ = ["setup_logging"]

View File

@ -0,0 +1,332 @@
from __future__ import annotations
import logging
import logging.handlers
import os
from pathlib import Path
import sys
from zoneinfo import ZoneInfo
def setup_logging(
*,
app_name: str | None = "script",
log_dir: str | Path = "logs",
console_level: int | None = None,
file_level: int | None = None,
root_level: int | None = None,
calendar: str | None = None, # "gregorian" | "jalali" | None
json_logs: bool | None = None,
enable_file: bool | None = None, # None -> auto based on docker + env overrides
max_bytes: int = 10 * 1024 * 1024,
backup_count: int = 10,
use_rich: bool = False,
include_app_name: bool = True,
include_name: bool = True,
microseconds: int = 3, # 0, 3, or 6
include_pid: bool = True,
include_thread: bool = True,
time_zone_name: str = "UTC",
) -> None:
"""
Script-friendly logging:
- Console always
- File only if enabled (default: enabled locally, disabled in Docker)
- Optional JSON logs
Formatter options:
- microseconds: 0 (none), 3 (milliseconds), 6 (microseconds)
- app_name: if None, it's omitted
- include_pid/include_thread toggles
"""
if use_rich:
try:
from rich.logging import RichHandler
except Exception:
use_rich = False
if use_rich and not sys.stderr.isatty():
use_rich = False
# ---- Environment overrides ----
env_root = os.getenv("LOG_LEVEL")
env_console = os.getenv("LOG_LEVEL_CONSOLE")
env_file = os.getenv("LOG_LEVEL_FILE")
if root_level is None:
root_level = _level_from_env(env_root, default=logging.DEBUG)
if console_level is None:
console_level = _level_from_env(env_console, default=logging.INFO)
if file_level is None:
file_level = _level_from_env(env_file, default=logging.DEBUG)
if json_logs is None:
json_logs = _truthy_env(os.getenv("LOG_JSON"), default=False)
if enable_file is None:
env_log_to_file = os.getenv("LOG_TO_FILE")
if env_log_to_file is not None:
enable_file = _truthy_env(env_log_to_file, default=True)
else:
enable_file = not _looks_like_docker()
log_dir = Path(os.getenv("LOG_DIR", str(log_dir))).resolve()
# ---- Calendar selection ----
if calendar is None:
calendar = "gregorian"
calendar = calendar.strip().lower()
if calendar == "jalali":
try:
import jdatetime # noqa: F401
except Exception:
calendar = "gregorian"
if calendar not in ("gregorian", "jalali"):
raise ValueError("calendar must be 'gregorian' or 'jalali'")
# ---- Validate formatter knobs ----
if microseconds not in (0, 3, 6):
raise ValueError("microseconds must be 0, 3, or 6")
# ---- Configure root ----
root = logging.getLogger()
root.setLevel(root_level)
for h in list(root.handlers):
root.removeHandler(h)
# ---- Formatter ----
if json_logs:
formatter: logging.Formatter = _JsonFormatter(
app_name=app_name,
calendar=calendar,
microseconds=microseconds,
include_app_name=include_app_name,
include_name=include_name,
include_pid=include_pid,
include_thread=include_thread,
time_zone_name=time_zone_name,
)
else:
formatter = _TextFormatter(
app_name=app_name,
calendar=calendar,
microseconds=microseconds,
include_app_name=include_app_name,
include_name=include_name,
include_pid=include_pid,
include_thread=include_thread,
time_zone_name=time_zone_name,
)
# ---- Console handler ----
if use_rich and not json_logs:
console = RichHandler(
level=console_level,
rich_tracebacks=False,
tracebacks_show_locals=False,
show_time=False, # YOU already handle time
show_level=False, # YOU already handle level
show_path=False, # avoid noise
markup=False,
)
console.setFormatter(formatter)
else:
console = logging.StreamHandler()
console.setLevel(console_level)
console.setFormatter(formatter)
root.addHandler(console)
# ---- File handler (rotating) ----
if enable_file:
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{app_name or 'app'}.log"
file_handler = logging.handlers.RotatingFileHandler(
filename=log_path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
delay=True,
)
file_handler.setLevel(file_level)
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
def _looks_like_docker() -> bool:
if os.path.exists("/.dockerenv"):
return True
try:
cgroup = Path("/proc/1/cgroup")
if cgroup.exists():
txt = cgroup.read_text(errors="ignore")
if "docker" in txt or "kubepods" in txt or "containerd" in txt:
return True
except Exception:
pass
return False
def _truthy_env(val: str | None, *, default: bool) -> bool:
if val is None:
return default
return val.strip().lower() in ("1", "true", "yes", "y", "on")
def _level_from_env(val: str | None, *, default: int) -> int:
if not val:
return default
name = val.strip().upper()
return getattr(logging, name, default)
def _format_timestamp(*, calendar: str, microseconds: int, time_zone_name: str) -> str:
"""
microseconds:
- 0: YYYY-mm-dd HH:MM:SS
- 3: YYYY-mm-dd HH:MM:SS.mmm
- 6: YYYY-mm-dd HH:MM:SS.ffffff
"""
from datetime import datetime
if calendar == "gregorian":
now = datetime.now(tz=ZoneInfo(time_zone_name))
base = now.strftime("%Y-%m-%d %H:%M:%S")
if microseconds == 0:
return base
us = now.microsecond # 0..999999
if microseconds == 3:
return f"{base}.{us // 1000:03d}"
return f"{base}.{us:06d}"
# Jalali
try:
import jdatetime
except Exception:
# fallback
now = datetime.now(tz=ZoneInfo(time_zone_name))
base = now.strftime("%Y-%m-%d %H:%M:%S")
if microseconds == 0:
return base + " (gregorian-fallback)"
us = now.microsecond
if microseconds == 3:
return f"{base}.{us // 1000:03d} (gregorian-fallback)"
return f"{base}.{us:06d} (gregorian-fallback)"
jnow = jdatetime.datetime.now(tz=ZoneInfo(time_zone_name))
base = jnow.strftime("%Y-%m-%d %H:%M:%S")
if microseconds == 0:
return base
us = jnow.microsecond
if microseconds == 3:
return f"{base}.{us // 1000:03d}"
return f"{base}.{us:06d}"
def _build_context_suffix(record: logging.LogRecord, *, include_pid: bool, include_thread: bool) -> str:
parts: list[str] = []
if include_pid:
parts.append(str(record.process))
if include_thread:
parts.append(record.threadName)
if not parts:
return ""
return " [" + ":".join(parts) + "]"
class _TextFormatter(logging.Formatter):
def __init__(
self,
*,
app_name: str | None,
calendar: str,
microseconds: int,
include_app_name: bool,
include_name: bool,
include_pid: bool,
include_thread: bool,
time_zone_name: str,
):
super().__init__()
self.app_name = app_name
self.calendar = calendar
self.microseconds = microseconds
self.include_app_name = include_app_name
self.include_name = include_name
self.include_pid = include_pid
self.include_thread = include_thread
self.time_zone_name = time_zone_name
def format(self, record: logging.LogRecord) -> str:
ts = _format_timestamp(calendar=self.calendar, microseconds=self.microseconds, time_zone_name=self.time_zone_name)
# best practice: keep a stable, grep-friendly prefix
prefix_parts = [ts, record.levelname]
if self.include_app_name:
prefix_parts.append(self.app_name)
if self.include_name:
prefix_parts.append(record.name)
ctx = _build_context_suffix(record, include_pid=self.include_pid, include_thread=self.include_thread)
base = " ".join(prefix_parts) + ctx + " " + record.getMessage()
if record.exc_info:
base += "\n" + self.formatException(record.exc_info)
return base
class _JsonFormatter(logging.Formatter):
def __init__(
self,
*,
app_name: str | None,
calendar: str,
microseconds: int,
include_app_name: bool,
include_name: bool,
include_pid: bool,
include_thread: bool,
time_zone_name: str,
):
super().__init__()
self.app_name = app_name
self.calendar = calendar
self.microseconds = microseconds
self.include_app_name = include_app_name
self.include_name = include_name
self.include_pid = include_pid
self.include_thread = include_thread
self.time_zone_name = time_zone_name
def format(self, record: logging.LogRecord) -> str:
import json
payload: dict[str, object] = {
"ts": _format_timestamp(
calendar=self.calendar, microseconds=self.microseconds, time_zone_name=self.time_zone_name
),
"level": record.levelname,
"msg": record.getMessage(),
}
if self.include_app_name:
payload["app"] = self.app_name
if self.include_name:
payload["logger"] = record.name
if self.include_pid:
payload["process"] = record.process
if self.include_thread:
payload["thread"] = record.threadName
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)

93
uv.lock generated Normal file
View File

@ -0,0 +1,93 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "jalali-core"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/3c/21e32e3444c572174a5d774643eb2aa8ab60ef68b99a4c3585a0a11428b4/jalali_core-1.0.0.tar.gz", hash = "sha256:f4287c70c630323dcf0a3ab26df905ba4d451e230ac1f65b3bb2f77797894a2b", size = 2752, upload-time = "2024-03-25T09:36:14.934Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/20/a4e942f9685df720a106da292e29a53212b27903749cc563b86b612b113e/jalali_core-1.0.0-py3-none-any.whl", hash = "sha256:84e6f5090eadfb35234f24fad084be831d00da3c0b238ee001e8a1fd49bf7924", size = 3616, upload-time = "2024-03-25T09:36:13.133Z" },
]
[[package]]
name = "jdatetime"
version = "5.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jalali-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/9d/5ed59c36f3cbc68c01fab6442e6efb6d35a484ba4eec4f790264fce39f6c/jdatetime-5.2.0.tar.gz", hash = "sha256:c81d5898717b82b609a3ce2a73f8b8d3230b0c757e5c0de9d6b1acfdc224f551", size = 21663, upload-time = "2025-01-26T09:29:16.802Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/39/0dd2676d08468692606645db5ea40091290dc20747ff59636c21c0567d3c/jdatetime-5.2.0-py3-none-any.whl", hash = "sha256:d4aa73543e4e6c0e6122b58743773168edee5efe5c5acf05d1dc8c90524ca71c", size = 12199, upload-time = "2025-01-26T09:29:15.038Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "py-reza-logging"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "jdatetime" },
{ name = "rich" },
]
[package.optional-dependencies]
jalali = [
{ name = "jdatetime" },
]
rich = [
{ name = "rich" },
]
[package.metadata]
requires-dist = [
{ name = "jdatetime", specifier = ">=5.2.0" },
{ name = "jdatetime", marker = "extra == 'jalali'", specifier = ">=5.2.0" },
{ name = "rich", specifier = ">=14.3.2" },
{ name = "rich", marker = "extra == 'rich'", specifier = ">=14.3.2" },
]
provides-extras = ["rich", "jalali"]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "rich"
version = "14.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
]