From 7ff887027e787f65259c6c1eaf8a339e480b2ff0 Mon Sep 17 00:00:00 2001 From: "M. A. Reza" Date: Wed, 18 Feb 2026 15:35:05 +0330 Subject: [PATCH] update --- .gitignore | 256 ++++++++++++++++++++++++ .python-version | 1 + .vscode/settings.json | 8 + README.md | 25 +++ pyproject.toml | 21 ++ src/py_reza_logging/__init__.py | 3 + src/py_reza_logging/setup.py | 332 ++++++++++++++++++++++++++++++++ uv.lock | 93 +++++++++ 8 files changed, 739 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/py_reza_logging/__init__.py create mode 100644 src/py_reza_logging/setup.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b558ba0 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..29a7ad9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "cSpell.words": [ + "jalali", + "jdatetime", + "reza", + "setuptools" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..40f5c9b --- /dev/null +++ b/README.md @@ -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") +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ce17cd --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/py_reza_logging/__init__.py b/src/py_reza_logging/__init__.py new file mode 100644 index 0000000..bf29c7c --- /dev/null +++ b/src/py_reza_logging/__init__.py @@ -0,0 +1,3 @@ +from .setup import setup_logging + +__all__ = ["setup_logging"] \ No newline at end of file diff --git a/src/py_reza_logging/setup.py b/src/py_reza_logging/setup.py new file mode 100644 index 0000000..dbcae37 --- /dev/null +++ b/src/py_reza_logging/setup.py @@ -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) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..babba91 --- /dev/null +++ b/uv.lock @@ -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" }, +]