From 2cec014d4c0e55eae448526507cc6840c9330d60 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 13 Aug 2026 00:57:52 +0200 Subject: [PATCH] feat: migrate Model Resolver logging system --- Doc/BUILDING.md | 1 + Doc/TESTING.md | 46 ++ canvas_node.py | 27 +- js/config.js | 10 +- js/log_system/config.js | 15 + js/log_system/log_funcs.js | 61 +++ js/log_system/logger.js | 740 +++++++++++++++++++++++++++++ js/logger.js | 324 +------------ js/utils/LoggerUtils.js | 76 +-- python/log_system/__init__.py | 15 + python/log_system/config.py | 18 + python/log_system/log_funcs.py | 201 ++++++++ python/log_system/logger.py | 658 ++++++++++++++++++++++++++ python/logger.py | 372 +++------------ src/config.ts | 17 +- src/log_system/config.ts | 18 + src/log_system/log_funcs.ts | 81 ++++ src/log_system/logger.ts | 822 +++++++++++++++++++++++++++++++++ src/logger.ts | 376 +-------------- src/utils/LoggerUtils.ts | 100 +--- tests/test_log_rotation.py | 81 ++++ tests/test_log_system.mjs | 93 ++++ tests/test_python_logger.py | 41 +- 23 files changed, 2978 insertions(+), 1215 deletions(-) create mode 100644 Doc/TESTING.md create mode 100644 js/log_system/config.js create mode 100644 js/log_system/log_funcs.js create mode 100644 js/log_system/logger.js create mode 100644 python/log_system/__init__.py create mode 100644 python/log_system/config.py create mode 100644 python/log_system/log_funcs.py create mode 100644 python/log_system/logger.py create mode 100644 src/log_system/config.ts create mode 100644 src/log_system/log_funcs.ts create mode 100644 src/log_system/logger.ts create mode 100644 tests/test_log_rotation.py create mode 100644 tests/test_log_system.mjs diff --git a/Doc/BUILDING.md b/Doc/BUILDING.md index a78278f..81ea91c 100644 --- a/Doc/BUILDING.md +++ b/Doc/BUILDING.md @@ -5,6 +5,7 @@ LayerForge uses TypeScript for its frontend source and compiles it to JavaScript ## Requirements - [Node.js](https://nodejs.org/), which includes npm +- Python checks are documented in [TESTING.md](TESTING.md). ## Install dependencies diff --git a/Doc/TESTING.md b/Doc/TESTING.md new file mode 100644 index 0000000..c1cdea4 --- /dev/null +++ b/Doc/TESTING.md @@ -0,0 +1,46 @@ +# Testing LayerForge + +LayerForge does not require a project-local `venv`. Local Python checks use the +Python runtime bundled with the ComfyUI portable installation. CI creates its +own temporary Python environment with `actions/setup-python`; neither setup +depends on a committed or pre-existing virtual environment. + +## Local Windows checks + +Run these commands from the LayerForge project directory: + +```powershell +$ComfyPython = "..\..\..\python_embeded\python.exe" + +& $ComfyPython -m pytest -q +& $ComfyPython -m pytest -q --cov=python --cov-report=term-missing +& $ComfyPython -m ruff check __init__.py canvas_node.py python tests +& $ComfyPython -m compileall -q __init__.py canvas_node.py python tests +``` + +The explicit interpreter path is intentional: it keeps tests aligned with the +Python dependencies used by the running ComfyUI installation. If a developer +uses a non-portable ComfyUI installation, the same commands can be run with +that installation's Python executable instead. + +## Frontend checks + +```powershell +npm test +npm run lint +npx tsc --noEmit +``` + +After changing TypeScript, CSS, or HTML source files, rebuild the generated +frontend before running the frontend checks: + +```powershell +.\build.bat +``` + +## Continuous integration + +The backend CI job follows the same dependency model as +`comfyui-model-resolver`: GitHub Actions provisions Python 3.10, installs the +project with its development dependencies, and runs pytest, coverage, Ruff, +and the syntax check. A local `venv` is not part of the repository contract. diff --git a/canvas_node.py b/canvas_node.py index d2efe80..8fb423b 100644 --- a/canvas_node.py +++ b/canvas_node.py @@ -26,25 +26,18 @@ import io import sys import os -try: - from python.logger import logger, LogLevel, debug, info, warn, error, exception - from python.config import LOG_LEVEL - - logger.set_module_level('canvas_node', LogLevel[LOG_LEVEL]) - - logger.configure({ - 'log_to_file': True, - 'log_dir': os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs') - }) - - log_debug = lambda *args, **kwargs: debug('canvas_node', *args, **kwargs) - log_info = lambda *args, **kwargs: info('canvas_node', *args, **kwargs) - log_warn = lambda *args, **kwargs: warn('canvas_node', *args, **kwargs) - log_error = lambda *args, **kwargs: error('canvas_node', *args, **kwargs) - log_exception = lambda *args: exception('canvas_node', *args) +try: + from python.log_system import create_module_logger + + _module_logger = create_module_logger(__name__) + log_debug = _module_logger.debug + log_info = _module_logger.info + log_warn = _module_logger.warning + log_error = _module_logger.error + log_exception = _module_logger.exception log_info("Logger initialized for canvas_node") -except ImportError as e: +except ImportError as e: print(f"Warning: Logger module not available: {e}") diff --git a/js/config.js b/js/config.js index 472d3c8..a377b39 100644 --- a/js/config.js +++ b/js/config.js @@ -1,3 +1,7 @@ -// Log level for development. -// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE' -export const LOG_LEVEL = 'NONE'; +/** + * Backward-compatible exports for the LayerForge logging configuration. + * + * The canonical configuration lives in `src/log_system/config.ts`, matching + * the Model Resolver project layout. + */ +export { DEFAULT_LOGGER_NAME, LOG_LEVEL, LOG_MODULE_NAME, USE_COLORS, } from './log_system/config.js'; diff --git a/js/log_system/config.js b/js/log_system/config.js new file mode 100644 index 0000000..f6ba4a5 --- /dev/null +++ b/js/log_system/config.js @@ -0,0 +1,15 @@ +/** +author: Azornes +title: AzLogs +version: 1.5.6 +description: Logging Configuration +*/ +// Log level for development. +// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL', 'NONE' +export const LOG_LEVEL = 'ERROR'; +// Logger module name. Set to null to use the default project name. +export const LOG_MODULE_NAME = "LayerForge"; +// Default project/logger name used when LOG_MODULE_NAME is not set. +export const DEFAULT_LOGGER_NAME = 'LayerForge'; +// Enable styled console logs. +export const USE_COLORS = true; diff --git a/js/log_system/log_funcs.js b/js/log_system/log_funcs.js new file mode 100644 index 0000000..1a5ebd4 --- /dev/null +++ b/js/log_system/log_funcs.js @@ -0,0 +1,61 @@ +/** + * AzLogs helper functions. + * + * This is the typed LayerForge port of Model Resolver's log system helper + * module. Keeping these helpers in their own module lets every frontend + * feature create a consistent module-scoped logger. + */ +import { logger } from './logger.js'; +/** Create a logger object with a fixed module name. */ +export function createModuleLogger(moduleName) { + return { + debug: (...args) => logger.debug(moduleName, ...args), + info: (...args) => logger.info(moduleName, ...args), + warn: (...args) => logger.warn(moduleName, ...args), + error: (...args) => logger.error(moduleName, ...args), + exception: (...args) => logger.exception(moduleName, ...args), + fatal: (...args) => logger.fatal(moduleName, ...args), + }; +} +/** Create a logger using the first JavaScript filename found in the stack. */ +export function createAutoLogger() { + const stack = new Error().stack; + const match = stack?.match(/\/([^/]+)\.js/); + const moduleName = match ? match[1] : 'Unknown'; + return createModuleLogger(moduleName); +} +/** Wrap an async operation with start, completion, and failure logging. */ +export function withErrorLogging(operation, log, operationName) { + return async function (...args) { + try { + log.debug(`Starting ${operationName}`); + const result = await operation.apply(this, args); + log.debug(`Completed ${operationName}`); + return result; + } + catch (error) { + log.error(`Error in ${operationName}:`, error); + throw error; + } + }; +} +/** Decorate a class method with start, completion, and failure logging. */ +export function logMethod(log, methodName) { + return function (_target, propertyKey, descriptor) { + const originalMethod = descriptor.value; + descriptor.value = async function (...args) { + const name = methodName || String(propertyKey); + try { + log.debug(`${name} started`); + const result = await originalMethod.apply(this, args); + log.debug(`${name} completed`); + return result; + } + catch (error) { + log.error(`${name} failed:`, error); + throw error; + } + }; + return descriptor; + }; +} diff --git a/js/log_system/logger.js b/js/log_system/logger.js new file mode 100644 index 0000000..32f3096 --- /dev/null +++ b/js/log_system/logger.js @@ -0,0 +1,740 @@ +/** +author: Azornes +title: AzLogs +version: 1.5.6 +description: Logging Setup - Central logging system + +Features: +logger - Central logging system +- Multiple log levels (DEBUG, INFO, WARN, ERROR) +- Ability to enable/disable logging globally or per module +- Colorful logs in the console +- Ability to save logs to localStorage +- Ability to export logs +*/ +import { DEFAULT_LOGGER_NAME, LOG_LEVEL, LOG_MODULE_NAME, USE_COLORS } from './config.js'; +function padStart(str, targetLength, padString) { + targetLength = targetLength >> 0; + padString = String(padString || ' '); + if (str.length > targetLength) { + return String(str); + } + else { + targetLength = targetLength - str.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); + } + return padString.slice(0, targetLength) + String(str); + } +} +function sanitizeKeyPart(value) { + return String(value).replace(/[^a-zA-Z0-9_-]+/g, '_'); +} +function toPascalCase(value) { + return String(value) + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} +const LOGGER_NAME = LOG_MODULE_NAME || DEFAULT_LOGGER_NAME; +const STORAGE_PREFIX = sanitizeKeyPart(LOGGER_NAME); +const LOGGER_CONFIG_KEY = `${STORAGE_PREFIX}_logger_config`; +const LOGGER_STORAGE_KEY = `${STORAGE_PREFIX}_logs`; +const LOGGER_EXPORT_PREFIX = STORAGE_PREFIX; +const WINDOW_LOGGER_KEY = `${toPascalCase(LOGGER_NAME)}Logger`; +export const LogLevel = { + DEBUG: 10, + INFO: 20, + WARN: 30, + ERROR: 40, + FATAL: 50, + NONE: 100 +}; +const DEFAULT_CONFIG = { + enabled: true, + globalLevel: LogLevel[LOG_LEVEL] ?? LogLevel.INFO, + moduleSettings: {}, + useColors: USE_COLORS, + saveToStorage: false, + maxStoredLogs: 1000, + timestampFormat: 'HH:mm:ss', + includeMilliseconds: true, + compactCallsite: true, + storageKey: LOGGER_STORAGE_KEY +}; +const COLORS = { + [LogLevel.DEBUG]: '#9B59B6', + [LogLevel.INFO]: '#2ECC71', + [LogLevel.WARN]: '#F39C12', + [LogLevel.ERROR]: '#C0392B', + [LogLevel.FATAL]: '#E74C3C', +}; +const LEVEL_NAMES = { + [LogLevel.DEBUG]: 'DEBUG', + [LogLevel.INFO]: 'INFO', + [LogLevel.WARN]: 'WARN', + [LogLevel.ERROR]: 'ERROR', + [LogLevel.FATAL]: 'FATAL', +}; +const CONSOLE_METHODS = { + [LogLevel.DEBUG]: 'debug', + [LogLevel.INFO]: 'info', + [LogLevel.WARN]: 'warn', + [LogLevel.ERROR]: 'error', + [LogLevel.FATAL]: 'error', +}; +const TIME_BG = '#263f4c'; +const LEVEL_WIDTH = 5; +export class Logger { + constructor() { + this.config = { ...DEFAULT_CONFIG }; + this.logs = []; + this.enabled = true; + this.loadConfig(); + this.enabled = this.config.enabled !== false; + this.loadLogs(); + } + /** + * Configure the logger + * @param {Partial} config - Configuration object + */ + configure(config) { + this.config = { ...this.config, ...config }; + if (Object.prototype.hasOwnProperty.call(config, 'enabled')) { + this.enabled = config.enabled !== false; + } + this.config.globalLevel = this.normalizeLevel(this.config.globalLevel, DEFAULT_CONFIG.globalLevel); + if (!this.config.moduleSettings || typeof this.config.moduleSettings !== 'object') { + this.config.moduleSettings = {}; + } + else { + Object.entries(this.config.moduleSettings).forEach(([module, level]) => { + this.config.moduleSettings[module] = this.normalizeLevel(level, this.config.globalLevel); + }); + } + this.saveConfig(); + return this; + } + /** + * Enable/disable logger globally + * @param {boolean} enabled - Whether the logger should be enabled + */ + setEnabled(enabled) { + this.enabled = Boolean(enabled); + this.config.enabled = this.enabled; + this.saveConfig(); + return this; + } + /** + * Set global logging level + * @param {LogLevels} level - Logging level + */ + setGlobalLevel(level) { + this.config.globalLevel = this.normalizeLevel(level, DEFAULT_CONFIG.globalLevel); + this.saveConfig(); + return this; + } + normalizeLevel(level, fallback = this.config.globalLevel) { + if (typeof level === 'number' && Object.values(LogLevel).includes(level)) { + return level; + } + if (typeof level === 'string') { + let normalized = level.trim().toUpperCase(); + if (normalized === 'WARNING') + normalized = 'WARN'; + if (normalized === 'CRITICAL') + normalized = 'FATAL'; + if (Object.prototype.hasOwnProperty.call(LogLevel, normalized)) { + return LogLevel[normalized]; + } + } + return fallback; + } + getLevelName(level = this.config.globalLevel) { + const normalizedLevel = this.normalizeLevel(level, this.config.globalLevel); + return Object.entries(LogLevel).find(([, value]) => value === normalizedLevel)?.[0] || 'INFO'; + } + setGlobalAndModuleLevel(level) { + const normalizedLevel = this.normalizeLevel(level, this.config.globalLevel); + this.config.globalLevel = normalizedLevel; + Object.keys(this.config.moduleSettings || {}).forEach((module) => { + this.config.moduleSettings[module] = normalizedLevel; + }); + this.saveConfig(); + return this; + } + /** + * Set logging level for a specific module + * @param {string} module - Module name + * @param {LogLevels} level - Logging level + */ + setModuleLevel(module, level) { + this.config.moduleSettings[module] = this.normalizeLevel(level, this.config.globalLevel); + this.saveConfig(); + return this; + } + /** + * Check if a given logging level is active for a module + * @param {string} module - Module name + * @param {LogLevels} level - Logging level to check + * @returns {boolean} - Whether the level is active + */ + isLevelEnabled(module, level) { + if (!this.enabled) + return false; + if (this.config.moduleSettings[module] !== undefined) { + return level >= this.config.moduleSettings[module]; + } + return level >= this.config.globalLevel; + } + /** + * Format timestamp + * @returns {string} - Formatted timestamp + */ + formatTimestamp() { + const now = new Date(); + const format = this.config.timestampFormat || DEFAULT_CONFIG.timestampFormat; + const milliseconds = padStart(String(now.getMilliseconds()), 3, '0'); + const timestamp = format + .replace('HH', padStart(String(now.getHours()), 2, '0')) + .replace('mm', padStart(String(now.getMinutes()), 2, '0')) + .replace('ss', padStart(String(now.getSeconds()), 2, '0')) + .replace('SSS', milliseconds); + if (this.config.includeMilliseconds !== false && !format.includes('SSS')) { + return `${timestamp}.${milliseconds}`; + } + return timestamp; + } + /** + * Save log + * @param {string} module - Module name + * @param {LogLevels} level - Logging level + * @param {any[]} args - Arguments to log + */ + log(module, level, ...args) { + if (!this.isLevelEnabled(module, level)) + return; + const timestamp = this.formatTimestamp(); + const levelName = LEVEL_NAMES[level]; + const callsite = this.getCallsite(); + const logData = { + timestamp, + module, + level, + levelName, + args, + callsite, + time: new Date() + }; + this.logs.push(logData); + if (this.logs.length > this.config.maxStoredLogs) { + this.logs.shift(); + } + if (this.config.saveToStorage) { + this.saveLogs(); + } + this.printToConsole(logData); + } + /** + * Display log to console + * @param {LogData} logData - Log data + */ + printToConsole(logData) { + const { timestamp, module, level, levelName, args, callsite } = logData; + const consoleMethod = CONSOLE_METHODS[level] || 'log'; + const consoleRecord = console; + const consoleFn = typeof consoleRecord[consoleMethod] === 'function' + ? consoleRecord[consoleMethod].bind(console) + : console.log.bind(console); + const detailFn = typeof console.log === 'function' + ? console.log.bind(console) + : consoleFn; + const normalizedCallsite = this.normalizeCallsite(callsite); + const shouldGroupDetails = this.shouldGroupDetails(normalizedCallsite); + const outputFn = shouldGroupDetails + ? console.groupCollapsed.bind(console) + : consoleFn; + const { root, detail } = this.splitModuleName(module); + const consoleArgs = this.formatConsoleArgs(args); + const suffix = this.formatSuffix(detail, normalizedCallsite); + const suffixArgs = suffix ? [suffix.trimStart()] : []; + if (this.config.useColors && typeof consoleFn === 'function') { + const color = COLORS[level] || '#000000'; + outputFn(...this.formatStyledConsoleArgs({ + levelName, + timestamp, + root, + args: consoleArgs, + suffix: suffixArgs[0] || '', + color + })); + this.printExpandedDetails(detailFn, normalizedCallsite); + return; + } + outputFn(`${levelName.padEnd(LEVEL_WIDTH, ' ')} ${timestamp} ${root}`, ...consoleArgs, ...suffixArgs); + this.printExpandedDetails(detailFn, normalizedCallsite); + } + splitModuleName(module) { + const value = String(module || LOGGER_NAME); + const parts = value.split('.'); + if (parts.length === 1 && value !== LOGGER_NAME) { + return { + root: LOGGER_NAME, + detail: value + }; + } + const root = parts.shift() || LOGGER_NAME; + return { + root, + detail: parts.join('.') + }; + } + stringifyArgs(args = []) { + return args.map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return arg.stack || arg.message; + } + if (typeof arg === 'object' && arg !== null) { + try { + return JSON.stringify(arg); + } + catch (_e) { + return String(arg); + } + } + return String(arg); + }).join(' '); + } + formatConsoleArgs(args = []) { + return args.flatMap((arg) => this.formatConsoleArg(arg)); + } + formatConsoleArg(arg) { + if (typeof arg === 'string') { + const payload = this.parseJsonPayloadFromString(arg); + if (payload) { + return payload.prefix ? [payload.prefix, payload.value] : [payload.value]; + } + } + return [arg]; + } + formatStyledConsoleArgs({ levelName, timestamp, root, args = [], suffix = '', color }) { + const outputArgs = [ + `%c ${levelName.padEnd(LEVEL_WIDTH, ' ')} %c ${timestamp} %c %c ${root} %c %c `, + `background:${color};color:#fff;font-weight:bold;`, + `background:${TIME_BG};color:#fff;font-weight:bold;`, + `background:${color};color:${color};`, + `background:${TIME_BG};color:#fff;font-weight:bold;`, + `background:${color};color:${color};`, + `color:${color};font-weight:bold;` + ]; + let format = outputArgs[0]; + let hasContent = false; + args.forEach((arg) => { + if (hasContent) { + format += ' '; + } + if (this.isExpandableConsoleArg(arg)) { + format += '%o'; + outputArgs.push(arg); + } + else { + format += this.escapeConsoleFormat(String(this.stringifyConsolePrimitive(arg))); + } + hasContent = true; + }); + if (suffix) { + if (hasContent) { + format += ' '; + } + format += `%c${this.escapeConsoleFormat(suffix)}`; + outputArgs.push(''); + } + outputArgs[0] = format; + return outputArgs; + } + isExpandableConsoleArg(arg) { + return (typeof arg === 'object' && arg !== null && !(arg instanceof Error)) + || typeof arg === 'function'; + } + stringifyConsolePrimitive(arg) { + if (arg instanceof Error) { + return arg.stack || arg.message; + } + return String(arg); + } + escapeConsoleFormat(value) { + return String(value).replace(/%/g, '%%'); + } + getCallsite() { + const stack = new Error().stack; + if (!stack) { + return ''; + } + const lines = stack.split('\n').slice(1); + for (const line of lines) { + const parsed = this.parseStackLine(line); + if (!parsed) { + continue; + } + const normalizedUrl = parsed.url.replace(/\\/g, '/'); + if (normalizedUrl.includes('/log_system/')) { + continue; + } + return this.createCallsite(parsed); + } + return ''; + } + parseStackLine(line) { + const match = String(line).trim().match(/(?:at\s+(?:.+?\s+\()?)?(.+?\.(?:mjs|js|tsx?|jsx)(?:[?#][^:)]*)?):(\d+):(\d+)\)?$/); + if (!match) { + return null; + } + const source = match[1]; + const atIndex = source.lastIndexOf('@'); + return { + url: atIndex >= 0 ? source.slice(atIndex + 1) : source, + line: match[2], + column: match[3], + }; + } + formatCallsite(callsite) { + if (!callsite) { + return ''; + } + if (typeof callsite === 'string') { + return callsite; + } + if (callsite.full) { + return callsite.full; + } + return `${callsite.url}:${callsite.line}:${callsite.column}`; + } + createCallsite(callsite) { + const full = this.formatCallsite(callsite); + return { + url: callsite.url, + line: callsite.line, + column: callsite.column, + full, + label: this.formatCompactCallsite(callsite) + }; + } + normalizeCallsite(callsite) { + if (!callsite) { + return null; + } + const full = this.formatCallsite(callsite); + if (!full) { + return null; + } + const objectCallsite = typeof callsite === 'object' && callsite !== null ? callsite : {}; + return { + url: objectCallsite.url || '', + line: objectCallsite.line || '', + column: objectCallsite.column || '', + full, + label: objectCallsite.label || this.formatCompactCallsite(callsite), + }; + } + formatCompactCallsite(callsite) { + const source = typeof callsite === 'string' + ? callsite.replace(/:\d+:\d+$/, '') + : callsite?.url || callsite?.full || ''; + const normalizedSource = String(source).split(/[?#]/)[0].replace(/\\/g, '/'); + const filename = normalizedSource.slice(normalizedSource.lastIndexOf('/') + 1); + return filename.replace(/\.(?:mjs|js|tsx?|jsx)$/i, '') || this.formatCallsite(callsite); + } + shouldGroupDetails(callsite) { + return this.config.compactCallsite !== false + && !!callsite?.full + && typeof console.groupCollapsed === 'function' + && typeof console.groupEnd === 'function'; + } + printExpandedDetails(consoleFn, callsite) { + if (!this.shouldGroupDetails(callsite)) { + return; + } + if (callsite?.full) { + consoleFn(`Source: ${callsite.full}`); + } + console.groupEnd(); + } + parseJsonPayloadFromString(value) { + const parsed = this.parseJsonLikeString(value); + if (parsed !== null) { + return { + prefix: '', + value: parsed + }; + } + const start = this.findJsonPayloadStart(value); + if (start <= 0) { + return null; + } + const payload = this.parseJsonLikeString(value.slice(start)); + if (payload === null) { + return null; + } + return { + prefix: value.slice(0, start).trimEnd(), + value: payload + }; + } + findJsonPayloadStart(value) { + const objectStart = value.indexOf('{'); + const arrayStart = value.indexOf('['); + if (objectStart < 0) { + return arrayStart; + } + if (arrayStart < 0) { + return objectStart; + } + return Math.min(objectStart, arrayStart); + } + parseJsonLikeString(value) { + const trimmed = value.trim(); + if (!trimmed || !/^[{[]/.test(trimmed)) { + return null; + } + try { + return JSON.parse(trimmed); + } + catch (_e) { + return null; + } + } + formatSuffix(detail, callsite, options = {}) { + const normalizedCallsite = this.normalizeCallsite(callsite); + const compactCallsite = options.compactCallsite ?? this.config.compactCallsite !== false; + const suffixDetail = normalizedCallsite + ? (compactCallsite ? normalizedCallsite.label : normalizedCallsite.full) + : detail; + return suffixDetail ? ` (${suffixDetail})` : ''; + } + serializeLogEntry(log) { + return { + timestamp: log.timestamp, + module: log.module, + level: log.level, + levelName: log.levelName, + args: log.args.map((arg) => { + if (typeof arg === 'object' && arg !== null) { + try { + return JSON.parse(JSON.stringify(arg)); + } + catch (_e) { + return String(arg); + } + } + return arg; + }), + callsite: log.callsite || '', + time: log.time instanceof Date ? log.time.toISOString() : log.time + }; + } + deserializeLogEntry(log) { + if (!log || typeof log !== 'object') { + return null; + } + const value = log; + if ('timestamp' in value && 'module' in value && 'level' in value) { + return { + timestamp: String(value.timestamp), + module: String(value.module), + level: this.normalizeLevel(value.level, LogLevel.INFO), + levelName: value.levelName || LEVEL_NAMES[value.level] || 'INFO', + args: Array.isArray(value.args) ? value.args : [], + callsite: value.callsite || '', + time: value.time ? new Date(value.time) : new Date() + }; + } + if ('t' in value && 'm' in value && 'l' in value) { + return { + timestamp: String(value.t), + module: String(value.m), + level: this.normalizeLevel(value.l, LogLevel.INFO), + levelName: LEVEL_NAMES[value.l] || 'INFO', + args: Array.isArray(value.a) ? value.a : [], + callsite: value.c || '', + time: new Date() + }; + } + return null; + } + /** + * Save logs to localStorage + */ + saveLogs() { + if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { + try { + const storedLogs = this.logs.map((log) => this.serializeLogEntry(log)); + localStorage.setItem(this.config.storageKey, JSON.stringify(storedLogs)); + } + catch (e) { + console.error('Failed to save logs to localStorage:', e); + } + } + } + /** + * Load logs from localStorage + */ + loadLogs() { + if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { + try { + const storedLogs = localStorage.getItem(this.config.storageKey); + if (storedLogs) { + this.logs = JSON.parse(storedLogs) + .map((log) => this.deserializeLogEntry(log)) + .filter((log) => Boolean(log)); + } + } + catch (e) { + console.error('Failed to load logs from localStorage:', e); + } + } + } + /** + * Save configuration to localStorage + */ + saveConfig() { + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(LOGGER_CONFIG_KEY, JSON.stringify(this.config)); + } + catch (e) { + console.error('Failed to save logger config to localStorage:', e); + } + } + } + /** + * Load configuration from localStorage + */ + loadConfig() { + if (typeof localStorage !== 'undefined') { + try { + const storedConfig = localStorage.getItem(LOGGER_CONFIG_KEY); + if (storedConfig) { + this.config = { ...this.config, ...JSON.parse(storedConfig) }; + this.configure(this.config); + } + } + catch (e) { + console.error('Failed to load logger config from localStorage:', e); + } + } + } + /** + * Clear all logs + */ + clearLogs() { + this.logs = []; + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(this.config.storageKey); + } + return this; + } + /** + * Export logs to file + * @param {'json' | 'txt'} format - Export format + */ + exportLogs(format = 'json') { + if (this.logs.length === 0) { + console.warn('No logs to export'); + return false; + } + let content; + let mimeType; + let extension; + if (format === 'json') { + content = JSON.stringify(this.logs.map((log) => this.serializeLogEntry(log)), null, 2); + mimeType = 'application/json'; + extension = 'json'; + } + else { + content = this.logs.map((log) => { + const { root, detail } = this.splitModuleName(log.module); + const suffix = this.formatSuffix(detail, log.callsite, { compactCallsite: false }); + return `${log.levelName.padEnd(LEVEL_WIDTH, ' ')} ${log.timestamp} ${root} ${this.stringifyArgs(log.args)}${suffix}`; + }).join('\n'); + mimeType = 'text/plain'; + extension = 'txt'; + } + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${LOGGER_EXPORT_PREFIX}_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + return true; + } + /** + * Log at DEBUG level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + debug(module, ...args) { + this.log(module, LogLevel.DEBUG, ...args); + } + /** + * Log at INFO level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + info(module, ...args) { + this.log(module, LogLevel.INFO, ...args); + } + /** + * Log at WARN level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + warn(module, ...args) { + this.log(module, LogLevel.WARN, ...args); + } + /** + * Log at ERROR level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + error(module, ...args) { + this.log(module, LogLevel.ERROR, ...args); + } + /** + * Log at ERROR level with exception stack trace + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + exception(module, ...args) { + const hasError = args.some(arg => arg instanceof Error); + if (!hasError) { + args.push(new Error("Captured stack trace")); + } + this.log(module, LogLevel.ERROR, ...args); + } + /** + * Log at FATAL level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + fatal(module, ...args) { + this.log(module, LogLevel.FATAL, ...args); + } +} +export const logger = new Logger(); +export const debug = (module, ...args) => logger.debug(module, ...args); +export const info = (module, ...args) => logger.info(module, ...args); +export const warn = (module, ...args) => logger.warn(module, ...args); +export const error = (module, ...args) => logger.error(module, ...args); +export const exception = (module, ...args) => logger.exception(module, ...args); +export const fatal = (module, ...args) => logger.fatal(module, ...args); +if (typeof window !== 'undefined') { + window[WINDOW_LOGGER_KEY] = logger; +} +export default logger; diff --git a/js/logger.js b/js/logger.js index 9253bd4..9ec392c 100644 --- a/js/logger.js +++ b/js/logger.js @@ -1,322 +1,8 @@ /** - * Logger - Centralny system logowania dla ComfyUI-LayerForge + * Backward-compatible exports for the LayerForge logger. * - * Funkcje: - * - Różne poziomy logowania (DEBUG, INFO, WARN, ERROR) - * - Możliwość włączania/wyłączania logów globalnie lub per moduł - * - Kolorowe logi w konsoli - * - Możliwość zapisywania logów do localStorage - * - Możliwość eksportu logów + * The canonical implementation lives in `src/log_system/logger.ts` and is + * compiled to `js/log_system/logger.js` for ComfyUI. */ -function padStart(str, targetLength, padString) { - targetLength = targetLength >> 0; - padString = String(padString || ' '); - if (str.length > targetLength) { - return String(str); - } - else { - targetLength = targetLength - str.length; - if (targetLength > padString.length) { - padString += padString.repeat(targetLength / padString.length); - } - return padString.slice(0, targetLength) + String(str); - } -} -export const LogLevel = { - DEBUG: 0, - INFO: 1, - WARN: 2, - ERROR: 3, - NONE: 4 -}; -const DEFAULT_CONFIG = { - globalLevel: LogLevel.INFO, - moduleSettings: {}, - useColors: true, - saveToStorage: false, - maxStoredLogs: 1000, - timestampFormat: 'HH:mm:ss', - storageKey: 'layerforge_logs' -}; -const COLORS = { - [LogLevel.DEBUG]: '#9e9e9e', - [LogLevel.INFO]: '#2196f3', - [LogLevel.WARN]: '#ff9800', - [LogLevel.ERROR]: '#f44336', -}; -const LEVEL_NAMES = { - [LogLevel.DEBUG]: 'DEBUG', - [LogLevel.INFO]: 'INFO', - [LogLevel.WARN]: 'WARN', - [LogLevel.ERROR]: 'ERROR', -}; -class Logger { - constructor() { - this.config = { ...DEFAULT_CONFIG }; - this.logs = []; - this.enabled = true; - this.loadConfig(); - } - /** - * Konfiguracja loggera - * @param {Partial} config - Obiekt konfiguracyjny - */ - configure(config) { - this.config = { ...this.config, ...config }; - this.saveConfig(); - return this; - } - /** - * Włącz/wyłącz logger globalnie - * @param {boolean} enabled - Czy logger ma być włączony - */ - setEnabled(enabled) { - this.enabled = enabled; - return this; - } - /** - * Ustaw globalny poziom logowania - * @param {LogLevels} level - Poziom logowania - */ - setGlobalLevel(level) { - this.config.globalLevel = level; - this.saveConfig(); - return this; - } - /** - * Ustaw poziom logowania dla konkretnego modułu - * @param {string} module - Nazwa modułu - * @param {LogLevels} level - Poziom logowania - */ - setModuleLevel(module, level) { - this.config.moduleSettings[module] = level; - this.saveConfig(); - return this; - } - /** - * Sprawdź, czy dany poziom logowania jest aktywny dla modułu - * @param {string} module - Nazwa modułu - * @param {LogLevels} level - Poziom logowania do sprawdzenia - * @returns {boolean} - Czy poziom jest aktywny - */ - isLevelEnabled(module, level) { - if (!this.enabled) - return false; - if (this.config.moduleSettings[module] !== undefined) { - return level >= this.config.moduleSettings[module]; - } - return level >= this.config.globalLevel; - } - /** - * Formatuj znacznik czasu - * @returns {string} - Sformatowany znacznik czasu - */ - formatTimestamp() { - const now = new Date(); - const format = this.config.timestampFormat; - return format - .replace('HH', padStart(String(now.getHours()), 2, '0')) - .replace('mm', padStart(String(now.getMinutes()), 2, '0')) - .replace('ss', padStart(String(now.getSeconds()), 2, '0')) - .replace('SSS', padStart(String(now.getMilliseconds()), 3, '0')); - } - /** - * Zapisz log - * @param {string} module - Nazwa modułu - * @param {LogLevels} level - Poziom logowania - * @param {any[]} args - Argumenty do zalogowania - */ - log(module, level, ...args) { - if (!this.isLevelEnabled(module, level)) - return; - const timestamp = this.formatTimestamp(); - const levelName = LEVEL_NAMES[level]; - const logData = { - timestamp, - module, - level, - levelName, - args, - time: new Date() - }; - if (this.config.saveToStorage) { - this.logs.push(logData); - if (this.logs.length > this.config.maxStoredLogs) { - this.logs.shift(); - } - this.saveLogs(); - } - this.printToConsole(logData); - } - /** - * Wyświetl log w konsoli - * @param {LogData} logData - Dane logu - */ - printToConsole(logData) { - const { timestamp, module, level, levelName, args } = logData; - const prefix = `[${timestamp}] [${module}] [${levelName}]`; - if (this.config.useColors && typeof console.log === 'function') { - const color = COLORS[level] || '#000000'; - console.log(`%c${prefix}`, `color: ${color}; font-weight: bold;`, ...args); - return; - } - console.log(prefix, ...args); - } - /** - * Zapisz logi do localStorage - */ - saveLogs() { - if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { - try { - const simplifiedLogs = this.logs.map((log) => ({ - t: log.timestamp, - m: log.module, - l: log.level, - a: log.args.map((arg) => { - if (typeof arg === 'object') { - try { - return JSON.stringify(arg); - } - catch (e) { - return String(arg); - } - } - return arg; - }) - })); - localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs)); - } - catch (e) { - console.error('Failed to save logs to localStorage:', e); - } - } - } - /** - * Załaduj logi z localStorage - */ - loadLogs() { - if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { - try { - const storedLogs = localStorage.getItem(this.config.storageKey); - if (storedLogs) { - this.logs = JSON.parse(storedLogs); - } - } - catch (e) { - console.error('Failed to load logs from localStorage:', e); - } - } - } - /** - * Zapisz konfigurację do localStorage - */ - saveConfig() { - if (typeof localStorage !== 'undefined') { - try { - localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config)); - } - catch (e) { - console.error('Failed to save logger config to localStorage:', e); - } - } - } - /** - * Załaduj konfigurację z localStorage - */ - loadConfig() { - if (typeof localStorage !== 'undefined') { - try { - const storedConfig = localStorage.getItem('layerforge_logger_config'); - if (storedConfig) { - this.config = { ...this.config, ...JSON.parse(storedConfig) }; - } - } - catch (e) { - console.error('Failed to load logger config from localStorage:', e); - } - } - } - /** - * Wyczyść wszystkie logi - */ - clearLogs() { - this.logs = []; - if (typeof localStorage !== 'undefined') { - localStorage.removeItem(this.config.storageKey); - } - return this; - } - /** - * Eksportuj logi do pliku - * @param {'json' | 'txt'} format - Format eksportu - */ - exportLogs(format = 'json') { - if (this.logs.length === 0) { - console.warn('No logs to export'); - return; - } - let content; - let mimeType; - let extension; - if (format === 'json') { - content = JSON.stringify(this.logs, null, 2); - mimeType = 'application/json'; - extension = 'json'; - } - else { - content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n'); - mimeType = 'text/plain'; - extension = 'txt'; - } - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `layerforge_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - /** - * Log na poziomie DEBUG - * @param {string} module - Nazwa modułu - * @param {any[]} args - Argumenty do zalogowania - */ - debug(module, ...args) { - this.log(module, LogLevel.DEBUG, ...args); - } - /** - * Log na poziomie INFO - * @param {string} module - Nazwa modułu - * @param {any[]} args - Argumenty do zalogowania - */ - info(module, ...args) { - this.log(module, LogLevel.INFO, ...args); - } - /** - * Log na poziomie WARN - * @param {string} module - Nazwa modułu - * @param {any[]} args - Argumenty do zalogowania - */ - warn(module, ...args) { - this.log(module, LogLevel.WARN, ...args); - } - /** - * Log na poziomie ERROR - * @param {string} module - Nazwa modułu - * @param {any[]} args - Argumenty do zalogowania - */ - error(module, ...args) { - this.log(module, LogLevel.ERROR, ...args); - } -} -export const logger = new Logger(); -export const debug = (module, ...args) => logger.debug(module, ...args); -export const info = (module, ...args) => logger.info(module, ...args); -export const warn = (module, ...args) => logger.warn(module, ...args); -export const error = (module, ...args) => logger.error(module, ...args); -if (typeof window !== 'undefined') { - window.LayerForgeLogger = logger; -} -export default logger; +export * from './log_system/logger.js'; +export { default } from './log_system/logger.js'; diff --git a/js/utils/LoggerUtils.js b/js/utils/LoggerUtils.js index 58b2424..fd8c5e1 100644 --- a/js/utils/LoggerUtils.js +++ b/js/utils/LoggerUtils.js @@ -1,74 +1,6 @@ /** - * LoggerUtils - Centralizacja inicjalizacji loggerów - * Eliminuje powtarzalny kod inicjalizacji loggera w każdym module + * Backward-compatible exports for the LayerForge logging helpers. + * + * The canonical implementation lives in `src/log_system/log_funcs.ts`. */ -import { logger, LogLevel } from "../logger.js"; -import { LOG_LEVEL } from '../config.js'; -/** - * Tworzy obiekt loggera dla modułu z predefiniowanymi metodami - * @param {string} moduleName - Nazwa modułu - * @returns {Logger} Obiekt z metodami logowania - */ -export function createModuleLogger(moduleName) { - logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]); - return { - debug: (...args) => logger.debug(moduleName, ...args), - info: (...args) => logger.info(moduleName, ...args), - warn: (...args) => logger.warn(moduleName, ...args), - error: (...args) => logger.error(moduleName, ...args) - }; -} -/** - * Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL - * @returns {Logger} Obiekt z metodami logowania - */ -export function createAutoLogger() { - const stack = new Error().stack; - const match = stack?.match(/\/([^\/]+)\.js/); - const moduleName = match ? match[1] : 'Unknown'; - return createModuleLogger(moduleName); -} -/** - * Wrapper dla operacji z automatycznym logowaniem błędów - * @param {Function} operation - Operacja do wykonania - * @param {Logger} log - Obiekt loggera - * @param {string} operationName - Nazwa operacji (dla logów) - * @returns {Function} Opakowana funkcja - */ -export function withErrorLogging(operation, log, operationName) { - return async function (...args) { - try { - log.debug(`Starting ${operationName}`); - const result = await operation.apply(this, args); - log.debug(`Completed ${operationName}`); - return result; - } - catch (error) { - log.error(`Error in ${operationName}:`, error); - throw error; - } - }; -} -/** - * Decorator dla metod klasy z automatycznym logowaniem - * @param {Logger} log - Obiekt loggera - * @param {string} methodName - Nazwa metody - */ -export function logMethod(log, methodName) { - return function (target, propertyKey, descriptor) { - const originalMethod = descriptor.value; - descriptor.value = async function (...args) { - try { - log.debug(`${methodName || propertyKey} started`); - const result = await originalMethod.apply(this, args); - log.debug(`${methodName || propertyKey} completed`); - return result; - } - catch (error) { - log.error(`${methodName || propertyKey} failed:`, error); - throw error; - } - }; - return descriptor; - }; -} +export { createAutoLogger, createModuleLogger, logMethod, withErrorLogging, } from '../log_system/log_funcs.js'; diff --git a/python/log_system/__init__.py b/python/log_system/__init__.py new file mode 100644 index 0000000..d2412e6 --- /dev/null +++ b/python/log_system/__init__.py @@ -0,0 +1,15 @@ +""" +@author: Azornes +@title: AzLogs +@version: 2.0.1 +@description: Logging Initializer +""" + +from .log_funcs import create_module_logger +from .logger import LogLevel, logger + +__all__ = [ + "LogLevel", + "create_module_logger", + "logger", +] diff --git a/python/log_system/config.py b/python/log_system/config.py new file mode 100644 index 0000000..16b7e60 --- /dev/null +++ b/python/log_system/config.py @@ -0,0 +1,18 @@ +""" +@author: Azornes +@title: AzLogs +@version: 2.0.1 +@description: Logging Configuration +""" +# Log level for development. +# Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL', 'NONE' +LOG_LEVEL = "ERROR" + +# Logger module name. Set to None to auto-detect from folder name. +LOG_MODULE_NAME = "LayerForge" + +# Enable ANSI colors in console logs. +USE_COLORS = True + +# Project name aliases stripped during module name normalization. +PROJECT_ALIASES = ["Comfyui-LayerForge", "comfyui-layerforge", "layerforge"] diff --git a/python/log_system/log_funcs.py b/python/log_system/log_funcs.py new file mode 100644 index 0000000..767dc3e --- /dev/null +++ b/python/log_system/log_funcs.py @@ -0,0 +1,201 @@ +""" +@author: Azornes +@title: AzLogs +@version: 2.0.1 +@description: Logging Initializer +""" +import logging +import os +import traceback + +_logger = logging.getLogger(__name__) +_initialized = False +_project_root = None +_default_module_name = __name__ + +try: + from .config import LOG_LEVEL, LOG_MODULE_NAME, PROJECT_ALIASES, USE_COLORS + from .logger import LogLevel, debug, error, exception, fatal, info, logger, warn + + def _find_project_root(start_path): + current = os.path.dirname(os.path.abspath(start_path)) + while current: + if os.path.isdir(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + break + current = parent + return os.path.dirname(os.path.dirname(os.path.abspath(start_path))) + + _project_root = _find_project_root(__file__) + _default_module_name = ( + LOG_MODULE_NAME + if LOG_MODULE_NAME is not None + else os.path.basename(_project_root) + ) + + logger.set_global_level(LogLevel[LOG_LEVEL]) + + logger.configure( + { + "log_to_file": True, + "log_dir": os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs"), + "use_colors": ( + logger.config["use_colors"] + if "AZLOGS_USE_COLORS" in os.environ + else USE_COLORS + ), + } + ) + + _initialized = True +except ImportError as e: + _initialized = False + _logger.error(f"Failed to initialize logger: {e}") + # Provide fallback values when import fails + LOG_MODULE_NAME = None + PROJECT_ALIASES = [] + + +def _normalize_module_name(module_name): + """Normalize a dotted module name for display in logs. + + Strips common prefixes (custom_nodes, project aliases) and ``__init__`` + suffixes to produce a clean, readable module path. + + ComfyUI sets ``__name__`` to hybrid values like + ``E:\\...\\comfyui-model-resolver.core.sources.model_list`` where the + first dotted segment is a full OS path. We detect this and convert + the path portion to a dotted name before applying the normal logic. + """ + module_name = str(module_name or "").strip() + if not module_name or module_name == "__main__": + return _default_module_name + + # --- path-to-dots conversion (required for ComfyUI's __name__) --- + if os.sep in module_name or "/" in module_name: + # Replace all path separators with dots so the rest of the logic + # can work uniformly on dotted segments. + normalized = module_name.replace("\\", ".").replace("/", ".") + # Remove drive letter prefix (e.g. "E:." → "") + if len(normalized) >= 2 and normalized[1] == ":": + normalized = normalized[2:].lstrip(".") + # Strip .py extension if present + if normalized.endswith(".py"): + normalized = normalized[:-3] + module_name = normalized + + parts = [p for p in module_name.split(".") if p] + if not parts: + return _default_module_name + + # Strip __init__ suffix + if parts[-1] == "__init__": + parts = parts[:-1] + + # Strip LOG_MODULE_NAME prefix if present (re-added later) + if LOG_MODULE_NAME and parts and parts[0] == LOG_MODULE_NAME: + parts = parts[1:] + + # Strip custom_nodes. prefix + if "custom_nodes" in parts: + idx = parts.index("custom_nodes") + parts = parts[idx + 2:] # skip custom_nodes + package folder + + # Strip project aliases from config + for alias in PROJECT_ALIASES: + if alias in parts: + parts = parts[parts.index(alias) + 1:] + break + + # Re-insert LOG_MODULE_NAME prefix at the start + package_name = LOG_MODULE_NAME or os.path.basename(_project_root or "") + if package_name and (not parts or parts[0] != package_name): + parts.insert(0, package_name) + + cleaned = ".".join(parts).strip(".") + return cleaned or _default_module_name + + +# Number of internal call frames between ModuleLogger methods and +# Python's logging.Logger.log(). Used to compute the correct +# ``stacklevel`` so that log records report the *caller's* file and +# line number instead of an internal wrapper. +# +# Call chain: ModuleLogger.method → module-level func → AzLogsLogger.log → logging.Logger.log +_INTERNAL_FRAMES = 3 + + +class ModuleLogger: + """Per-module logger with a fixed module name. + + Usage: + log = create_module_logger(__name__) + log.debug("message") + log.info("message") + log.info("downloaded", extra={"model": "xyz"}) + """ + + def __init__(self, module_name): + self.module_name = module_name + + def debug(self, *args, **kwargs): + if _initialized: + kwargs["stacklevel"] = kwargs.pop("stacklevel", 1) + _INTERNAL_FRAMES + debug(self.module_name, *args, **kwargs) + else: + print(f"[DEBUG] [{self.module_name}]", *args) + + def info(self, *args, **kwargs): + if _initialized: + kwargs["stacklevel"] = kwargs.pop("stacklevel", 1) + _INTERNAL_FRAMES + info(self.module_name, *args, **kwargs) + else: + print(f"[INFO] [{self.module_name}]", *args) + + def warning(self, *args, **kwargs): + if _initialized: + kwargs["stacklevel"] = kwargs.pop("stacklevel", 1) + _INTERNAL_FRAMES + warn(self.module_name, *args, **kwargs) + else: + print(f"[WARN] [{self.module_name}]", *args) + + def warn(self, *args, **kwargs): + self.warning(*args, **kwargs) + + def error(self, *args, **kwargs): + if _initialized: + kwargs["stacklevel"] = kwargs.pop("stacklevel", 1) + _INTERNAL_FRAMES + error(self.module_name, *args, **kwargs) + else: + print(f"[ERROR] [{self.module_name}]", *args) + + def exception(self, *args, **kwargs): + if _initialized: + kwargs["stacklevel"] = kwargs.pop("stacklevel", 1) + _INTERNAL_FRAMES + exception(self.module_name, *args, **kwargs) + else: + print(f"[ERROR] [{self.module_name}]", *args) + traceback.print_exc() + + def fatal(self, *args, **kwargs): + if _initialized: + kwargs["stacklevel"] = kwargs.pop("stacklevel", 1) + _INTERNAL_FRAMES + fatal(self.module_name, *args, **kwargs) + else: + print(f"[FATAL] [{self.module_name}]", *args) + + +def create_module_logger(module_name=None): + """Create a ModuleLogger with a normalized module name. + + Args: + module_name: Dotted module name, typically ``__name__``. + Required for proper module identification. + + Returns: + A :class:`ModuleLogger` instance. + """ + resolved_name = _normalize_module_name(module_name or _default_module_name) + return ModuleLogger(resolved_name) diff --git a/python/log_system/logger.py b/python/log_system/logger.py new file mode 100644 index 0000000..b6127b3 --- /dev/null +++ b/python/log_system/logger.py @@ -0,0 +1,658 @@ +""" +@author: Azornes +@title: AzLogs +@version: 2.0.1 +@description: Logging Setup - Central logging system + +Features: +- Different log levels (DEBUG, INFO, WARN, ERROR) +- Ability to enable/disable logs globally or per module +- Colored logs in console +- Log file rotation +- Configuration via environment variables +""" +import json +import logging +import os +import re +import sys +import threading +import time +import traceback +from enum import IntEnum +from logging.handlers import RotatingFileHandler + + +# Log levels +class LogLevel(IntEnum): + DEBUG = 10 + INFO = 20 + WARN = 30 + ERROR = 40 + FATAL = 50 + NONE = 100 + + +# Level mapping +LEVEL_MAP = { + LogLevel.DEBUG: logging.DEBUG, + LogLevel.INFO: logging.INFO, + LogLevel.WARN: logging.WARNING, + LogLevel.ERROR: logging.ERROR, + LogLevel.FATAL: logging.CRITICAL, + LogLevel.NONE: logging.CRITICAL + 1, +} + +# ANSI reset sequence (all coloring uses LEVEL_THEME RGB values) +ANSI_RESET = "\033[0m" + +# RGB color theme per log level +LEVEL_THEME = { + "DEBUG": (155, 89, 182), # #9B59B6 + "INFO": (46, 204, 113), # #2ECC71 + "WARN": (243, 156, 18), # #F39C12 + "ERROR": (192, 57, 43), # #C0392B + "FATAL": (231, 76, 60), # #E74C3C — brighter red than ERROR +} + +# Default configuration +DEFAULT_CONFIG = { + "global_level": LogLevel.INFO, + "module_settings": {}, + "use_colors": True, + "log_to_file": False, + "log_dir": "logs", + "max_file_size_mb": 10, + "backup_count": 5, + "timestamp_format": "%H:%M:%S", + "level_field_width": 5, + "source_field_width": 0, +} + + +_WINDOWS_VT_PROCESSING_ENABLED = False + + +def rotated_log_filename(default_name): + """Keep the .log extension at the end of rotated log filenames.""" + match = re.fullmatch(r"(.+)\.log\.(\d+)", str(default_name or "")) + if not match: + return default_name + return f"{match.group(1)}.{match.group(2)}.log" + + +def parse_rotated_log_filename(filename): + """Return the log family and rotation number for the current filename style.""" + name = os.path.basename(str(filename or "")) + match = re.fullmatch(r"(.+)\.(\d+)\.log", name) + if match: + return match.group(1), int(match.group(2)) + + if name.endswith(".log"): + return name[:-4], 0 + return None + + +def _enable_windows_virtual_terminal_processing(): + """Enable ANSI escape sequence handling for classic Windows consoles.""" + global _WINDOWS_VT_PROCESSING_ENABLED + if _WINDOWS_VT_PROCESSING_ENABLED or os.name != "nt": + return + + try: + import ctypes + + enable_virtual_terminal_processing = 0x0004 + std_handles = (-11, -12) + invalid_handle_value = ctypes.c_void_p(-1).value + + kernel32 = ctypes.windll.kernel32 + kernel32.GetStdHandle.restype = ctypes.c_void_p + enabled_any = False + for std_handle in std_handles: + handle = kernel32.GetStdHandle(std_handle) + if handle in (None, 0, invalid_handle_value): + continue + + mode = ctypes.c_uint32() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + continue + + new_mode = mode.value | enable_virtual_terminal_processing + if new_mode != mode.value: + kernel32.SetConsoleMode(handle, new_mode) + + enabled_any = True + + _WINDOWS_VT_PROCESSING_ENABLED = enabled_any + except Exception: + return + + +def _get_console_output_stream(): + """Return the console stream that preserves ANSI colors under wrapped launchers.""" + for stream_name in ("__stdout__", "__stderr__", "stdout", "stderr"): + stream = getattr(sys, stream_name, None) + if stream is None: + continue + if getattr(stream, "closed", False): + continue + return stream + return None + + +class SafeRotatingFileHandler(RotatingFileHandler): + """Rotating file handler that disables itself if a launcher breaks its stream.""" + + def __init__(self, *args, **kwargs): + self._disabled_by_stream_error = False + super().__init__(*args, **kwargs) + self.namer = rotated_log_filename + + def emit(self, record): + if self._disabled_by_stream_error: + return + + try: + if self.stream is not None and self.maxBytes > 0: + tell = getattr(self.stream, "tell", None) + if not callable(tell): + raise OSError( + "Log file stream does not support tell(); disabling file logs" + ) + super().emit(record) + except Exception: + self.handleError(record) + + def handleError(self, record): + self._disabled_by_stream_error = True + try: + self.close() + except Exception: + pass + + +class DirectConsoleHandler(logging.Handler): + """Console handler that writes directly to the original console stream.""" + + terminator = "\n" + + def __init__(self, stream=None): + super().__init__() + self.stream = stream + + def emit(self, record): + try: + stream = self.stream or _get_console_output_stream() + if stream is None: + return + stream.write(self.format(record) + self.terminator) + stream.flush() + except Exception: + self.handleError(record) + + +class ColoredFormatter(logging.Formatter): + """Formatter that adds colors to console logs""" + + def __init__( + self, + fmt=None, + datefmt=None, + use_colors=True, + level_field_width=DEFAULT_CONFIG["level_field_width"], + source_field_width=DEFAULT_CONFIG["source_field_width"], + include_milliseconds=True, + include_brackets=True, + ): + super().__init__(fmt, datefmt) + self.use_colors = use_colors + self.level_field_width = self._field_width(level_field_width) + self.source_field_width = self._field_width(source_field_width) + self.include_milliseconds = include_milliseconds + self.include_brackets = include_brackets + + def format(self, record): + # Get the formatted message from the record + message = record.getMessage() + if record.exc_info: + message += "\n" + self.formatException(record.exc_info) + + levelname = self._display_level_name(record.levelname) + level = self._format_level(levelname) + logger_name = self._display_logger_name(record.name) + logger_root, logger_detail = self._split_logger_name(logger_name) + source = ( + f"({logger_detail}:{record.lineno})" + if logger_detail + else f"({logger_root}:{record.lineno})" + ) + if self.source_field_width: + source = source.ljust(self.source_field_width) + + timestamp = self._format_time(record) + separator = " " + root_label = logger_root + if self.include_brackets: + timestamp = f"[{timestamp}]" + root_label = f"[{root_label}]" + if self.use_colors: + level = self._color_level_badge(levelname, level) + timestamp = self._color_context_badge(timestamp) + root_label = self._color_context_badge(root_label) + separator = self._color_separator(levelname, separator) + message = self._color_message(levelname, message) + return f"{level}{timestamp}{separator}{root_label}{separator} {message} {source}" + + return f"{level} {timestamp} {root_label} {message} {source}" + + def _field_width(self, value): + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 + + def _display_logger_name(self, name): + return str(name or "").removeprefix("azlogs.") + + def _split_logger_name(self, name): + parts = str(name or "").split(".", 1) + if len(parts) == 1: + return parts[0], "" + return parts[0], parts[1] + + def _display_level_name(self, levelname): + name = str(levelname or "") + if name == "WARNING": + return "WARN" + if name == "CRITICAL": + return "FATAL" + return name + + def _format_level(self, levelname): + if self.include_brackets: + width = self.level_field_width + 2 + return f"[{levelname}]".ljust(width) + return levelname.ljust(self.level_field_width) + + def _format_time(self, record): + timestamp = time.strftime( + self.datefmt or "%H:%M:%S", self.converter(record.created) + ) + if self.include_milliseconds: + return f"{timestamp}.{int(record.msecs):03d}" + return timestamp + + def _color_level_badge(self, levelname, text): + rgb = LEVEL_THEME.get(levelname) + if not rgb: + return text + r, g, b = rgb + return f"\033[1;97;48;2;{r};{g};{b}m {text} {ANSI_RESET}" + + def _color_context_badge(self, text): + return f"\033[97;48;2;38;63;76m {text} {ANSI_RESET}" + + def _color_separator(self, levelname, text): + rgb = LEVEL_THEME.get(levelname) + if not rgb: + return text + r, g, b = rgb + return f"\033[48;2;{r};{g};{b}m{text}{ANSI_RESET}" + + def _color_message(self, levelname, text): + rgb = LEVEL_THEME.get(levelname) + if not rgb: + # Fallback: cyan for unknown levels + return f"\033[96m{text}{ANSI_RESET}" + r, g, b = rgb + return f"\033[38;2;{r};{g};{b}m{text}{ANSI_RESET}" + + +class AzLogsLogger: + """Main logger class for AzLogs""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self.config = DEFAULT_CONFIG.copy() + self.enabled = True + self.loggers = {} + self.file_handlers = {} + self._lock = threading.RLock() + + # Load configuration from environment variables + self._load_config_from_env() + + self._initialized = True + + def _load_config_from_env(self): + """Load configuration from environment variables""" + + # Global level + if "AZLOGS_LOG_LEVEL" in os.environ: + level_name = os.environ["AZLOGS_LOG_LEVEL"].upper() + if hasattr(LogLevel, level_name): + self.config["global_level"] = getattr(LogLevel, level_name) + + # Module settings + if "AZLOGS_MODULE_LEVELS" in os.environ: + try: + module_settings = json.loads(os.environ["AZLOGS_MODULE_LEVELS"]) + for module, level_name in module_settings.items(): + if hasattr(LogLevel, level_name.upper()): + self.config["module_settings"][module] = getattr( + LogLevel, level_name.upper() + ) + except json.JSONDecodeError: + pass + + # Other settings + if "AZLOGS_USE_COLORS" in os.environ: + self.config["use_colors"] = ( + os.environ["AZLOGS_USE_COLORS"].lower() == "true" + ) + + if "AZLOGS_LOG_TO_FILE" in os.environ: + self.config["log_to_file"] = ( + os.environ["AZLOGS_LOG_TO_FILE"].lower() == "true" + ) + + if "AZLOGS_LOG_DIR" in os.environ: + self.config["log_dir"] = os.environ["AZLOGS_LOG_DIR"] + + if "AZLOGS_MAX_FILE_SIZE_MB" in os.environ: + try: + self.config["max_file_size_mb"] = int( + os.environ["AZLOGS_MAX_FILE_SIZE_MB"] + ) + except ValueError: + pass + + if "AZLOGS_BACKUP_COUNT" in os.environ: + try: + self.config["backup_count"] = int(os.environ["AZLOGS_BACKUP_COUNT"]) + except ValueError: + pass + + def configure(self, config): + """Configure the logger""" + self.config.update(config) + self.reset_loggers() + + if self.config.get("use_colors"): + _enable_windows_virtual_terminal_processing() + + # If file logging is enabled, ensure the directory exists + if self.config.get("log_to_file") and self.config.get("log_dir"): + try: + os.makedirs(self.config["log_dir"], exist_ok=True) + except OSError as e: + # This is a fatal situation, so use print + print( + f"[FATAL] Could not create log directory: {self.config['log_dir']}. Error: {e}" + ) + traceback.print_exc() + # Disable file logging to avoid further errors + self.config["log_to_file"] = False + + return self + + def set_enabled(self, enabled): + """Enable/disable the logger globally""" + self.enabled = enabled + return self + + def set_global_level(self, level): + """Set global logging level""" + self.config["global_level"] = level + return self + + def set_module_level(self, module, level): + """Set logging level for a specific module""" + self.config["module_settings"][module] = level + return self + + def is_level_enabled(self, module, level): + """Check if a given logging level is active for the module""" + if not self.enabled: + return False + + # Determine effective logging level, considering module and global settings + effective_level = self.config["module_settings"].get( + module, self.config["global_level"] + ) + + # If effective level is NONE, logging is completely disabled + if effective_level == LogLevel.NONE: + return False + + # Otherwise check if log level is high enough + return level >= effective_level + + def _sanitize_logger_name(self, value): + """Sanitize logger/module names for safe filesystem usage.""" + sanitized = re.sub(r'[<>:"/\\|?*\s]+', "_", str(value or "")).strip("._") + return sanitized or "default" + + def _root_module_name(self, module): + """Return the top-level logger name used for shared log files.""" + return str(module or "default").split(".", 1)[0] or "default" + + def _get_log_file(self, module): + safe_root = self._sanitize_logger_name(self._root_module_name(module)) + return os.path.join(self.config["log_dir"], f"azlogs_{safe_root}.log") + + def _get_file_handler(self, module): + """Get or create a shared rotating file handler for the root module.""" + log_file = self._get_log_file(module) + # Fast path without lock + if log_file in self.file_handlers: + return self.file_handlers[log_file] + + with self._lock: + # Double-check after acquiring lock + if log_file in self.file_handlers: + return self.file_handlers[log_file] + + os.makedirs(self.config["log_dir"], exist_ok=True) + file_handler = SafeRotatingFileHandler( + log_file, + maxBytes=self.config["max_file_size_mb"] * 1024 * 1024, + backupCount=self.config["backup_count"], + encoding="utf-8", + ) + file_formatter = ColoredFormatter( + datefmt="%Y-%m-%d %H:%M:%S", + use_colors=False, + source_field_width=self.config["source_field_width"], + include_milliseconds=True, + ) + file_handler.setFormatter(file_formatter) + self.file_handlers[log_file] = file_handler + return file_handler + + def reset_loggers(self): + """Remove configured handlers so new settings are applied cleanly.""" + with self._lock: + handlers_to_close = set() + + for module in self.loggers: + configured_logger = logging.getLogger(f"azlogs.{module}") + for handler in list(configured_logger.handlers): + configured_logger.removeHandler(handler) + if isinstance(handler, RotatingFileHandler): + handlers_to_close.add(handler) + + for handler in self.file_handlers.values(): + handlers_to_close.add(handler) + + for handler in handlers_to_close: + handler.close() + + self.loggers = {} + self.file_handlers = {} + return self + + def _get_logger(self, module): + """Get or create a logger for the module""" + # Fast path without lock + if module in self.loggers: + return self.loggers[module] + + with self._lock: + # Double-check after acquiring lock + if module in self.loggers: + return self.loggers[module] + + # Create new logger + new_logger = logging.getLogger(f"azlogs.{module}") + new_logger.setLevel(logging.DEBUG) # Set lowest level, filtering will be done later + new_logger.propagate = False + + # Add console handler + if self.config["use_colors"]: + _enable_windows_virtual_terminal_processing() + + console_stream = _get_console_output_stream() + console_handler = DirectConsoleHandler(console_stream) + console_formatter = ColoredFormatter( + fmt="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", + datefmt=self.config["timestamp_format"], + use_colors=self.config["use_colors"], + level_field_width=self.config["level_field_width"], + source_field_width=self.config["source_field_width"], + include_brackets=False, + ) + console_handler.setFormatter(console_formatter) + new_logger.addHandler(console_handler) + + # Add file handler if file logging is enabled + if self.config["log_to_file"]: + try: + file_handler = self._get_file_handler(module) + new_logger.addHandler(file_handler) + except OSError as e: + self.config["log_to_file"] = False + new_logger.warning( + "AzLogs: disabling file logging after handler setup failed for %s: %s", + self._get_log_file(module), + e, + ) + + self.loggers[module] = new_logger + return new_logger + + def log(self, module, level, *args, **kwargs): + """Write log""" + if not self.is_level_enabled(module, level): + return + + logger = self._get_logger(module) + + # Convert arguments to string + message = " ".join(str(arg) for arg in args) + + # Extract known kwargs + exc_info = kwargs.get("exc_info") + stacklevel = kwargs.get("stacklevel", 1) + extra = kwargs.get("extra") + + # Map LogLevel to logging level + log_level = LEVEL_MAP.get(level, logging.INFO) + + # Write log + logger.log(log_level, message, exc_info=exc_info, stacklevel=stacklevel, extra=extra) + + def debug(self, module, *args, **kwargs): + """Log at DEBUG level""" + self.log(module, LogLevel.DEBUG, *args, **kwargs) + + def info(self, module, *args, **kwargs): + """Log at INFO level""" + self.log(module, LogLevel.INFO, *args, **kwargs) + + def warn(self, module, *args, **kwargs): + """Log at WARN level""" + self.log(module, LogLevel.WARN, *args, **kwargs) + + def error(self, module, *args, **kwargs): + """Log at ERROR level""" + self.log(module, LogLevel.ERROR, *args, **kwargs) + + def exception(self, module, *args, **kwargs): + """Log exception at ERROR level""" + kwargs["exc_info"] = True + self.log(module, LogLevel.ERROR, *args, **kwargs) + + def fatal(self, module, *args, **kwargs): + """Log at FATAL level""" + self.log(module, LogLevel.FATAL, *args, **kwargs) + + +# Singleton +logger = AzLogsLogger() + + +# Helper functions +def debug(module, *args, **kwargs): + """Log at DEBUG level""" + logger.log(module, LogLevel.DEBUG, *args, **kwargs) + + +def info(module, *args, **kwargs): + """Log at INFO level""" + logger.log(module, LogLevel.INFO, *args, **kwargs) + + +def warn(module, *args, **kwargs): + """Log at WARN level""" + logger.log(module, LogLevel.WARN, *args, **kwargs) + + +def error(module, *args, **kwargs): + """Log at ERROR level""" + logger.log(module, LogLevel.ERROR, *args, **kwargs) + + +def exception(module, *args, **kwargs): + """Log exception at ERROR level""" + kwargs["exc_info"] = True + logger.log(module, LogLevel.ERROR, *args, **kwargs) + + +def fatal(module, *args, **kwargs): + """Log at FATAL level""" + logger.log(module, LogLevel.FATAL, *args, **kwargs) + + +# Function to quickly enable/disable debugging +def set_debug(enabled=True): + """Enable/disable debugging globally""" + if enabled: + logger.set_global_level(LogLevel.DEBUG) + else: + logger.set_global_level(LogLevel.INFO) + return logger + + +# Function to enable/disable file logging +def set_file_logging(enabled=True, log_dir=None): + """Enable/disable logging to file""" + logger.config["log_to_file"] = enabled + if log_dir: + logger.config["log_dir"] = log_dir + os.makedirs(log_dir, exist_ok=True) + + # Reset loggers to apply new settings + logger.reset_loggers() + return logger diff --git a/python/logger.py b/python/logger.py index bc8e35d..63c48d0 100644 --- a/python/logger.py +++ b/python/logger.py @@ -1,323 +1,59 @@ -""" -Logger - Centralny system logowania dla ComfyUI-LayerForge (Python) +"""Backward-compatible exports for the LayerForge logging system. -Funkcje: -- Różne poziomy logowania (DEBUG, INFO, WARN, ERROR) -- Możliwość włączania/wyłączania logów globalnie lub per moduł -- Kolorowe logi w konsoli -- Rotacja plików logów -- Konfiguracja przez zmienne środowiskowe +The implementation lives in :mod:`python.log_system`, matching the Model +Resolver layout. Existing LayerForge imports can continue using +``python.logger`` while all callers share the same logger instance. """ -import os -import sys -import json -import logging -import datetime -from enum import IntEnum -from logging.handlers import RotatingFileHandler -import traceback +from .log_system.logger import ( + ANSI_RESET, + DEFAULT_CONFIG, + LEVEL_MAP, + LEVEL_THEME, + AzLogsLogger, + ColoredFormatter, + DirectConsoleHandler, + LogLevel, + SafeRotatingFileHandler, + logger, + parse_rotated_log_filename, + rotated_log_filename, + set_debug, + set_file_logging, +) +from .log_system.log_funcs import ( + create_module_logger, + debug, + error, + exception, + fatal, + info, + warn, +) -# Poziomy logowania -class LogLevel(IntEnum): - DEBUG = 10 - INFO = 20 - WARN = 30 - ERROR = 40 - NONE = 100 +LayerForgeLogger = AzLogsLogger -# Mapowanie poziomów logowania -LEVEL_MAP = { - LogLevel.DEBUG: logging.DEBUG, - LogLevel.INFO: logging.INFO, - LogLevel.WARN: logging.WARNING, - LogLevel.ERROR: logging.ERROR, - LogLevel.NONE: logging.CRITICAL + 1 -} - -# Kolory ANSI dla różnych poziomów logowania -COLORS = { - LogLevel.DEBUG: '\033[90m', # Szary - LogLevel.INFO: '\033[94m', # Niebieski - LogLevel.WARN: '\033[93m', # Żółty - LogLevel.ERROR: '\033[91m', # Czerwony - 'RESET': '\033[0m' # Reset -} - -# Konfiguracja domyślna -DEFAULT_CONFIG = { - 'global_level': LogLevel.INFO, - 'module_settings': {}, - 'use_colors': True, - 'log_to_file': False, - 'log_dir': 'logs', - 'max_file_size_mb': 10, - 'backup_count': 5, - 'timestamp_format': '%H:%M:%S', -} - -class ColoredFormatter(logging.Formatter): - """Formatter dodający kolory do logów w konsoli""" - - def __init__(self, fmt=None, datefmt=None, use_colors=True): - super().__init__(fmt, datefmt) - self.use_colors = use_colors - - def format(self, record): - # Get the formatted message from the record - message = record.getMessage() - if record.exc_info: - message += "\n" + self.formatException(record.exc_info) - - levelname = record.levelname - - # Build the log prefix - prefix = '[{}] [{}] [{}]'.format( - self.formatTime(record, self.datefmt), - record.name, - record.levelname - ) - - # Apply color and bold styling to the prefix - if self.use_colors and hasattr(LogLevel, levelname): - level_enum = getattr(LogLevel, levelname) - if level_enum in COLORS: - # Apply bold (\033[1m) and color, then reset - prefix = f"\033[1m{COLORS[level_enum]}{prefix}{COLORS['RESET']}" - - return f"{prefix} {message}" - -class LayerForgeLogger: - """Główna klasa loggera dla LayerForge""" - - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super(LayerForgeLogger, cls).__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self): - if self._initialized: - return - - self.config = DEFAULT_CONFIG.copy() - self.enabled = True - self.loggers = {} - - # Załaduj konfigurację ze zmiennych środowiskowych - self._load_config_from_env() - - self._initialized = True - - def _load_config_from_env(self): - """Załaduj konfigurację ze zmiennych środowiskowych""" - - # Poziom globalny - if 'LAYERFORGE_LOG_LEVEL' in os.environ: - level_name = os.environ['LAYERFORGE_LOG_LEVEL'].upper() - if hasattr(LogLevel, level_name): - self.config['global_level'] = getattr(LogLevel, level_name) - - # Ustawienia modułów - if 'LAYERFORGE_MODULE_LEVELS' in os.environ: - try: - module_settings = json.loads(os.environ['LAYERFORGE_MODULE_LEVELS']) - for module, level_name in module_settings.items(): - if hasattr(LogLevel, level_name.upper()): - self.config['module_settings'][module] = getattr(LogLevel, level_name.upper()) - except json.JSONDecodeError: - pass - - # Inne ustawienia - if 'LAYERFORGE_USE_COLORS' in os.environ: - self.config['use_colors'] = os.environ['LAYERFORGE_USE_COLORS'].lower() == 'true' - - if 'LAYERFORGE_LOG_TO_FILE' in os.environ: - self.config['log_to_file'] = os.environ['LAYERFORGE_LOG_TO_FILE'].lower() == 'true' - - if 'LAYERFORGE_LOG_DIR' in os.environ: - self.config['log_dir'] = os.environ['LAYERFORGE_LOG_DIR'] - - if 'LAYERFORGE_MAX_FILE_SIZE_MB' in os.environ: - try: - self.config['max_file_size_mb'] = int(os.environ['LAYERFORGE_MAX_FILE_SIZE_MB']) - except ValueError: - pass - - if 'LAYERFORGE_BACKUP_COUNT' in os.environ: - try: - self.config['backup_count'] = int(os.environ['LAYERFORGE_BACKUP_COUNT']) - except ValueError: - pass - - def configure(self, config): - """Konfiguracja loggera""" - self.config.update(config) - - # Jeśli włączono logowanie do pliku, upewnij się, że katalog istnieje - if self.config.get('log_to_file') and self.config.get('log_dir'): - try: - os.makedirs(self.config['log_dir'], exist_ok=True) - except OSError as e: - # To jest sytuacja krytyczna, więc użyjmy print - print(f"[CRITICAL] Could not create log directory: {self.config['log_dir']}. Error: {e}") - traceback.print_exc() - # Wyłącz logowanie do pliku, aby uniknąć dalszych błędów - self.config['log_to_file'] = False - - return self - - def set_enabled(self, enabled): - """Włącz/wyłącz logger globalnie""" - self.enabled = enabled - return self - - def set_global_level(self, level): - """Ustaw globalny poziom logowania""" - self.config['global_level'] = level - return self - - def set_module_level(self, module, level): - """Ustaw poziom logowania dla konkretnego modułu""" - self.config['module_settings'][module] = level - return self - - def is_level_enabled(self, module, level): - """Sprawdź, czy dany poziom logowania jest aktywny dla modułu""" - if not self.enabled: - return False - - # Ustal efektywny poziom logowania, biorąc pod uwagę ustawienia modułu i globalne - effective_level = self.config['module_settings'].get(module, self.config['global_level']) - - # Jeśli efektywny poziom to NONE, logowanie jest całkowicie wyłączone - if effective_level == LogLevel.NONE: - return False - - # W przeciwnym razie sprawdź, czy poziom loga jest wystarczająco wysoki - return level >= effective_level - - def _get_logger(self, module): - """Pobierz lub utwórz logger dla modułu""" - if module in self.loggers: - return self.loggers[module] - - # Utwórz nowy logger - logger = logging.getLogger(f"layerforge.{module}") - logger.setLevel(logging.DEBUG) # Ustaw najniższy poziom, filtrowanie będzie później - logger.propagate = False - - # Dodaj handler dla konsoli - console_handler = logging.StreamHandler(sys.stdout) - console_formatter = ColoredFormatter( - fmt='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', - datefmt=self.config['timestamp_format'], - use_colors=self.config['use_colors'] - ) - console_handler.setFormatter(console_formatter) - logger.addHandler(console_handler) - - # Dodaj handler dla pliku, jeśli włączono logowanie do pliku - if self.config['log_to_file']: - log_file = os.path.join(self.config['log_dir'], f"layerforge_{module}.log") - file_handler = RotatingFileHandler( - log_file, - maxBytes=self.config['max_file_size_mb'] * 1024 * 1024, - backupCount=self.config['backup_count'], - encoding='utf-8' - ) - file_formatter = logging.Formatter( - fmt='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - file_handler.setFormatter(file_formatter) - logger.addHandler(file_handler) - - self.loggers[module] = logger - return logger - - def log(self, module, level, *args, **kwargs): - """Zapisz log""" - if not self.is_level_enabled(module, level): - return - - logger = self._get_logger(module) - - # Konwertuj argumenty na string - message = " ".join(str(arg) for arg in args) - - # Dodaj informacje o wyjątku, jeśli podano - exc_info = kwargs.get('exc_info', None) - - # Mapuj poziom LogLevel na poziom logging - log_level = LEVEL_MAP.get(level, logging.INFO) - - # Zapisz log - logger.log(log_level, message, exc_info=exc_info) - - def debug(self, module, *args, **kwargs): - """Log na poziomie DEBUG""" - self.log(module, LogLevel.DEBUG, *args, **kwargs) - - def info(self, module, *args, **kwargs): - """Log na poziomie INFO""" - self.log(module, LogLevel.INFO, *args, **kwargs) - - def warn(self, module, *args, **kwargs): - """Log na poziomie WARN""" - self.log(module, LogLevel.WARN, *args, **kwargs) - - def error(self, module, *args, **kwargs): - """Log na poziomie ERROR""" - self.log(module, LogLevel.ERROR, *args, **kwargs) - - def exception(self, module, *args): - """Log wyjątku na poziomie ERROR""" - self.log(module, LogLevel.ERROR, *args, exc_info=True) - -# Singleton -logger = LayerForgeLogger() - -# Funkcje pomocnicze -def debug(module, *args, **kwargs): - """Log na poziomie DEBUG""" - logger.debug(module, *args, **kwargs) - -def info(module, *args, **kwargs): - """Log na poziomie INFO""" - logger.info(module, *args, **kwargs) - -def warn(module, *args, **kwargs): - """Log na poziomie WARN""" - logger.warn(module, *args, **kwargs) - -def error(module, *args, **kwargs): - """Log na poziomie ERROR""" - logger.error(module, *args, **kwargs) - -def exception(module, *args): - """Log wyjątku na poziomie ERROR""" - logger.exception(module, *args) - -# Funkcja do szybkiego włączania/wyłączania debugowania -def set_debug(enabled=True): - """Włącz/wyłącz debugowanie globalnie""" - if enabled: - logger.set_global_level(LogLevel.DEBUG) - else: - logger.set_global_level(LogLevel.INFO) - return logger - -# Funkcja do włączania/wyłączania logowania do pliku -def set_file_logging(enabled=True, log_dir=None): - """Włącz/wyłącz logowanie do pliku""" - logger.config['log_to_file'] = enabled - if log_dir: - logger.config['log_dir'] = log_dir - os.makedirs(log_dir, exist_ok=True) - - # Zresetuj loggery, aby zastosować nowe ustawienia - logger.loggers = {} - return logger +__all__ = [ + "ANSI_RESET", + "DEFAULT_CONFIG", + "LEVEL_MAP", + "LEVEL_THEME", + "AzLogsLogger", + "ColoredFormatter", + "DirectConsoleHandler", + "LayerForgeLogger", + "LogLevel", + "SafeRotatingFileHandler", + "create_module_logger", + "debug", + "error", + "exception", + "fatal", + "info", + "logger", + "parse_rotated_log_filename", + "rotated_log_filename", + "set_debug", + "set_file_logging", + "warn", +] diff --git a/src/config.ts b/src/config.ts index 6ed400f..c9f962f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,12 @@ -import { LogLevel } from "./logger"; - -// Log level for development. -// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE' -export const LOG_LEVEL: keyof typeof LogLevel = 'NONE'; +/** + * Backward-compatible exports for the LayerForge logging configuration. + * + * The canonical configuration lives in `src/log_system/config.ts`, matching + * the Model Resolver project layout. + */ +export { + DEFAULT_LOGGER_NAME, + LOG_LEVEL, + LOG_MODULE_NAME, + USE_COLORS, +} from './log_system/config.js'; diff --git a/src/log_system/config.ts b/src/log_system/config.ts new file mode 100644 index 0000000..6b86bf0 --- /dev/null +++ b/src/log_system/config.ts @@ -0,0 +1,18 @@ +/** +author: Azornes +title: AzLogs +version: 1.5.6 +description: Logging Configuration +*/ +// Log level for development. +// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL', 'NONE' +export const LOG_LEVEL = 'ERROR'; + +// Logger module name. Set to null to use the default project name. +export const LOG_MODULE_NAME = "LayerForge"; + +// Default project/logger name used when LOG_MODULE_NAME is not set. +export const DEFAULT_LOGGER_NAME = 'LayerForge'; + +// Enable styled console logs. +export const USE_COLORS = true; diff --git a/src/log_system/log_funcs.ts b/src/log_system/log_funcs.ts new file mode 100644 index 0000000..d718092 --- /dev/null +++ b/src/log_system/log_funcs.ts @@ -0,0 +1,81 @@ +/** + * AzLogs helper functions. + * + * This is the typed LayerForge port of Model Resolver's log system helper + * module. Keeping these helpers in their own module lets every frontend + * feature create a consistent module-scoped logger. + */ + +import { logger } from './logger.js'; + +export interface Logger { + debug: (...args: any[]) => void; + info: (...args: any[]) => void; + warn: (...args: any[]) => void; + error: (...args: any[]) => void; + exception: (...args: any[]) => void; + fatal: (...args: any[]) => void; +} + +/** Create a logger object with a fixed module name. */ +export function createModuleLogger(moduleName: string): Logger { + return { + debug: (...args: any[]) => logger.debug(moduleName, ...args), + info: (...args: any[]) => logger.info(moduleName, ...args), + warn: (...args: any[]) => logger.warn(moduleName, ...args), + error: (...args: any[]) => logger.error(moduleName, ...args), + exception: (...args: any[]) => logger.exception(moduleName, ...args), + fatal: (...args: any[]) => logger.fatal(moduleName, ...args), + }; +} + +/** Create a logger using the first JavaScript filename found in the stack. */ +export function createAutoLogger(): Logger { + const stack = new Error().stack; + const match = stack?.match(/\/([^/]+)\.js/); + const moduleName = match ? match[1] : 'Unknown'; + return createModuleLogger(moduleName); +} + +/** Wrap an async operation with start, completion, and failure logging. */ +export function withErrorLogging any>( + operation: T, + log: Logger, + operationName: string, +): (...args: Parameters) => Promise>> { + return async function (this: unknown, ...args: Parameters): Promise>> { + try { + log.debug(`Starting ${operationName}`); + const result = await operation.apply(this, args); + log.debug(`Completed ${operationName}`); + return result; + } catch (error) { + log.error(`Error in ${operationName}:`, error); + throw error; + } + }; +} + +/** Decorate a class method with start, completion, and failure logging. */ +export function logMethod(log: Logger, methodName?: string) { + return function ( + _target: object, + propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ): PropertyDescriptor { + const originalMethod = descriptor.value as (...args: any[]) => any; + descriptor.value = async function (this: unknown, ...args: any[]) { + const name = methodName || String(propertyKey); + try { + log.debug(`${name} started`); + const result = await originalMethod.apply(this, args); + log.debug(`${name} completed`); + return result; + } catch (error) { + log.error(`${name} failed:`, error); + throw error; + } + }; + return descriptor; + }; +} diff --git a/src/log_system/logger.ts b/src/log_system/logger.ts new file mode 100644 index 0000000..eb4a3c7 --- /dev/null +++ b/src/log_system/logger.ts @@ -0,0 +1,822 @@ +/** +author: Azornes +title: AzLogs +version: 1.5.6 +description: Logging Setup - Central logging system + +Features: +logger - Central logging system +- Multiple log levels (DEBUG, INFO, WARN, ERROR) +- Ability to enable/disable logging globally or per module +- Colorful logs in the console +- Ability to save logs to localStorage +- Ability to export logs +*/ + +import { DEFAULT_LOGGER_NAME, LOG_LEVEL, LOG_MODULE_NAME, USE_COLORS } from './config.js'; + +export type LogLevels = (typeof LogLevel)[keyof typeof LogLevel]; + +export interface LoggerConfig { + enabled: boolean; + globalLevel: LogLevels; + moduleSettings: Record; + useColors: boolean; + saveToStorage: boolean; + maxStoredLogs: number; + timestampFormat: string; + includeMilliseconds: boolean; + compactCallsite: boolean; + storageKey: string; +} + +export interface Callsite { + url: string; + line: string; + column: string; + full: string; + label: string; +} + +export interface LogData { + timestamp: string; + module: string; + level: LogLevels; + levelName: string; + args: any[]; + callsite: Callsite | string; + time: Date | string; +} + +interface ParsedJsonPayload { + prefix: string; + value: unknown; +} + +type CallsiteInput = string | Partial | null | undefined; + +interface StyledConsoleArgs { + levelName: string; + timestamp: string; + root: string; + args?: unknown[]; + suffix?: string; + color: string; +} + +function padStart(str: string, targetLength: number, padString: string): string { + targetLength = targetLength >> 0; + padString = String(padString || ' '); + if (str.length > targetLength) { + return String(str); + } + else { + targetLength = targetLength - str.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); + } + return padString.slice(0, targetLength) + String(str); + } +} +function sanitizeKeyPart(value: string): string { + return String(value).replace(/[^a-zA-Z0-9_-]+/g, '_'); +} +function toPascalCase(value: string): string { + return String(value) + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} +const LOGGER_NAME = LOG_MODULE_NAME || DEFAULT_LOGGER_NAME; +const STORAGE_PREFIX = sanitizeKeyPart(LOGGER_NAME); +const LOGGER_CONFIG_KEY = `${STORAGE_PREFIX}_logger_config`; +const LOGGER_STORAGE_KEY = `${STORAGE_PREFIX}_logs`; +const LOGGER_EXPORT_PREFIX = STORAGE_PREFIX; +const WINDOW_LOGGER_KEY = `${toPascalCase(LOGGER_NAME)}Logger`; +export const LogLevel = { + DEBUG: 10, + INFO: 20, + WARN: 30, + ERROR: 40, + FATAL: 50, + NONE: 100 +} as const; +const DEFAULT_CONFIG: LoggerConfig = { + enabled: true, + globalLevel: LogLevel[LOG_LEVEL] ?? LogLevel.INFO, + moduleSettings: {}, + useColors: USE_COLORS, + saveToStorage: false, + maxStoredLogs: 1000, + timestampFormat: 'HH:mm:ss', + includeMilliseconds: true, + compactCallsite: true, + storageKey: LOGGER_STORAGE_KEY +}; +const COLORS: Record = { + [LogLevel.DEBUG]: '#9B59B6', + [LogLevel.INFO]: '#2ECC71', + [LogLevel.WARN]: '#F39C12', + [LogLevel.ERROR]: '#C0392B', + [LogLevel.FATAL]: '#E74C3C', +}; +const LEVEL_NAMES: Record = { + [LogLevel.DEBUG]: 'DEBUG', + [LogLevel.INFO]: 'INFO', + [LogLevel.WARN]: 'WARN', + [LogLevel.ERROR]: 'ERROR', + [LogLevel.FATAL]: 'FATAL', +}; +const CONSOLE_METHODS: Record = { + [LogLevel.DEBUG]: 'debug', + [LogLevel.INFO]: 'info', + [LogLevel.WARN]: 'warn', + [LogLevel.ERROR]: 'error', + [LogLevel.FATAL]: 'error', +}; +const TIME_BG = '#263f4c'; +const LEVEL_WIDTH = 5; + +export class Logger { + public config: LoggerConfig; + public logs: LogData[]; + public enabled: boolean; + + constructor() { + this.config = { ...DEFAULT_CONFIG }; + this.logs = []; + this.enabled = true; + this.loadConfig(); + this.enabled = this.config.enabled !== false; + this.loadLogs(); + } + /** + * Configure the logger + * @param {Partial} config - Configuration object + */ + configure(config: Partial): this { + this.config = { ...this.config, ...config }; + if (Object.prototype.hasOwnProperty.call(config, 'enabled')) { + this.enabled = config.enabled !== false; + } + this.config.globalLevel = this.normalizeLevel(this.config.globalLevel, DEFAULT_CONFIG.globalLevel); + if (!this.config.moduleSettings || typeof this.config.moduleSettings !== 'object') { + this.config.moduleSettings = {}; + } else { + Object.entries(this.config.moduleSettings).forEach(([module, level]) => { + this.config.moduleSettings[module] = this.normalizeLevel(level, this.config.globalLevel); + }); + } + this.saveConfig(); + return this; + } + /** + * Enable/disable logger globally + * @param {boolean} enabled - Whether the logger should be enabled + */ + setEnabled(enabled: boolean): this { + this.enabled = Boolean(enabled); + this.config.enabled = this.enabled; + this.saveConfig(); + return this; + } + /** + * Set global logging level + * @param {LogLevels} level - Logging level + */ + setGlobalLevel(level: unknown): this { + this.config.globalLevel = this.normalizeLevel(level, DEFAULT_CONFIG.globalLevel); + this.saveConfig(); + return this; + } + normalizeLevel(level: unknown, fallback: LogLevels = this.config.globalLevel): LogLevels { + if (typeof level === 'number' && Object.values(LogLevel).includes(level as LogLevels)) { + return level as LogLevels; + } + if (typeof level === 'string') { + let normalized = level.trim().toUpperCase(); + if (normalized === 'WARNING') normalized = 'WARN'; + if (normalized === 'CRITICAL') normalized = 'FATAL'; + if (Object.prototype.hasOwnProperty.call(LogLevel, normalized)) { + return LogLevel[normalized as keyof typeof LogLevel]; + } + } + return fallback; + } + getLevelName(level: unknown = this.config.globalLevel): string { + const normalizedLevel = this.normalizeLevel(level, this.config.globalLevel); + return Object.entries(LogLevel).find(([, value]) => value === normalizedLevel)?.[0] || 'INFO'; + } + setGlobalAndModuleLevel(level: unknown): this { + const normalizedLevel = this.normalizeLevel(level, this.config.globalLevel); + this.config.globalLevel = normalizedLevel; + Object.keys(this.config.moduleSettings || {}).forEach((module) => { + this.config.moduleSettings[module] = normalizedLevel; + }); + this.saveConfig(); + return this; + } + /** + * Set logging level for a specific module + * @param {string} module - Module name + * @param {LogLevels} level - Logging level + */ + setModuleLevel(module: string, level: unknown): this { + this.config.moduleSettings[module] = this.normalizeLevel(level, this.config.globalLevel); + this.saveConfig(); + return this; + } + /** + * Check if a given logging level is active for a module + * @param {string} module - Module name + * @param {LogLevels} level - Logging level to check + * @returns {boolean} - Whether the level is active + */ + isLevelEnabled(module: string, level: LogLevels): boolean { + if (!this.enabled) + return false; + if (this.config.moduleSettings[module] !== undefined) { + return level >= this.config.moduleSettings[module]; + } + return level >= this.config.globalLevel; + } + /** + * Format timestamp + * @returns {string} - Formatted timestamp + */ + formatTimestamp(): string { + const now = new Date(); + const format = this.config.timestampFormat || DEFAULT_CONFIG.timestampFormat; + const milliseconds = padStart(String(now.getMilliseconds()), 3, '0'); + const timestamp = format + .replace('HH', padStart(String(now.getHours()), 2, '0')) + .replace('mm', padStart(String(now.getMinutes()), 2, '0')) + .replace('ss', padStart(String(now.getSeconds()), 2, '0')) + .replace('SSS', milliseconds); + if (this.config.includeMilliseconds !== false && !format.includes('SSS')) { + return `${timestamp}.${milliseconds}`; + } + return timestamp; + } + /** + * Save log + * @param {string} module - Module name + * @param {LogLevels} level - Logging level + * @param {any[]} args - Arguments to log + */ + log(module: string, level: LogLevels, ...args: any[]): void { + if (!this.isLevelEnabled(module, level)) + return; + const timestamp = this.formatTimestamp(); + const levelName = LEVEL_NAMES[level]; + const callsite = this.getCallsite(); + const logData = { + timestamp, + module, + level, + levelName, + args, + callsite, + time: new Date() + }; + this.logs.push(logData); + if (this.logs.length > this.config.maxStoredLogs) { + this.logs.shift(); + } + if (this.config.saveToStorage) { + this.saveLogs(); + } + this.printToConsole(logData); + } + /** + * Display log to console + * @param {LogData} logData - Log data + */ + printToConsole(logData: LogData): void { + const { timestamp, module, level, levelName, args, callsite } = logData; + const consoleMethod = CONSOLE_METHODS[level] || 'log'; + const consoleRecord = console as unknown as Record void>; + const consoleFn = typeof consoleRecord[consoleMethod] === 'function' + ? consoleRecord[consoleMethod].bind(console) + : console.log.bind(console); + const detailFn = typeof console.log === 'function' + ? console.log.bind(console) + : consoleFn; + const normalizedCallsite = this.normalizeCallsite(callsite); + const shouldGroupDetails = this.shouldGroupDetails(normalizedCallsite); + const outputFn = shouldGroupDetails + ? console.groupCollapsed.bind(console) + : consoleFn; + const { root, detail } = this.splitModuleName(module); + const consoleArgs = this.formatConsoleArgs(args); + const suffix = this.formatSuffix(detail, normalizedCallsite); + const suffixArgs = suffix ? [suffix.trimStart()] : []; + if (this.config.useColors && typeof consoleFn === 'function') { + const color = COLORS[level] || '#000000'; + outputFn(...this.formatStyledConsoleArgs({ + levelName, + timestamp, + root, + args: consoleArgs, + suffix: suffixArgs[0] || '', + color + })); + this.printExpandedDetails(detailFn, normalizedCallsite); + return; + } + outputFn(`${levelName.padEnd(LEVEL_WIDTH, ' ')} ${timestamp} ${root}`, ...consoleArgs, ...suffixArgs); + this.printExpandedDetails(detailFn, normalizedCallsite); + } + + splitModuleName(module: string): { root: string; detail: string } { + const value = String(module || LOGGER_NAME); + const parts = value.split('.'); + if (parts.length === 1 && value !== LOGGER_NAME) { + return { + root: LOGGER_NAME, + detail: value + }; + } + const root = parts.shift() || LOGGER_NAME; + return { + root, + detail: parts.join('.') + }; + } + + stringifyArgs(args: unknown[] = []): string { + return args.map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return arg.stack || arg.message; + } + if (typeof arg === 'object' && arg !== null) { + try { + return JSON.stringify(arg); + } + catch (_e) { + return String(arg); + } + } + return String(arg); + }).join(' '); + } + + formatConsoleArgs(args: unknown[] = []): unknown[] { + return args.flatMap((arg) => this.formatConsoleArg(arg)); + } + + formatConsoleArg(arg: unknown): unknown[] { + if (typeof arg === 'string') { + const payload = this.parseJsonPayloadFromString(arg); + if (payload) { + return payload.prefix ? [payload.prefix, payload.value] : [payload.value]; + } + } + return [arg]; + } + + formatStyledConsoleArgs({ levelName, timestamp, root, args = [], suffix = '', color }: StyledConsoleArgs): any[] { + const outputArgs: any[] = [ + `%c ${levelName.padEnd(LEVEL_WIDTH, ' ')} %c ${timestamp} %c %c ${root} %c %c `, + `background:${color};color:#fff;font-weight:bold;`, + `background:${TIME_BG};color:#fff;font-weight:bold;`, + `background:${color};color:${color};`, + `background:${TIME_BG};color:#fff;font-weight:bold;`, + `background:${color};color:${color};`, + `color:${color};font-weight:bold;` + ]; + + let format = outputArgs[0]; + let hasContent = false; + args.forEach((arg) => { + if (hasContent) { + format += ' '; + } + if (this.isExpandableConsoleArg(arg)) { + format += '%o'; + outputArgs.push(arg); + } + else { + format += this.escapeConsoleFormat(String(this.stringifyConsolePrimitive(arg))); + } + hasContent = true; + }); + + if (suffix) { + if (hasContent) { + format += ' '; + } + format += `%c${this.escapeConsoleFormat(suffix)}`; + outputArgs.push(''); + } + + outputArgs[0] = format; + return outputArgs; + } + + isExpandableConsoleArg(arg: unknown): boolean { + return (typeof arg === 'object' && arg !== null && !(arg instanceof Error)) + || typeof arg === 'function'; + } + + stringifyConsolePrimitive(arg: unknown): string { + if (arg instanceof Error) { + return arg.stack || arg.message; + } + return String(arg); + } + + escapeConsoleFormat(value: string): string { + return String(value).replace(/%/g, '%%'); + } + + getCallsite(): Callsite | '' { + const stack = new Error().stack; + if (!stack) { + return ''; + } + + const lines = stack.split('\n').slice(1); + for (const line of lines) { + const parsed = this.parseStackLine(line); + if (!parsed) { + continue; + } + + const normalizedUrl = parsed.url.replace(/\\/g, '/'); + if (normalizedUrl.includes('/log_system/')) { + continue; + } + + return this.createCallsite(parsed); + } + + return ''; + } + + parseStackLine(line: string): Pick | null { + const match = String(line).trim().match(/(?:at\s+(?:.+?\s+\()?)?(.+?\.(?:mjs|js|tsx?|jsx)(?:[?#][^:)]*)?):(\d+):(\d+)\)?$/); + if (!match) { + return null; + } + + const source = match[1]; + const atIndex = source.lastIndexOf('@'); + return { + url: atIndex >= 0 ? source.slice(atIndex + 1) : source, + line: match[2], + column: match[3], + }; + } + + formatCallsite(callsite: CallsiteInput): string { + if (!callsite) { + return ''; + } + if (typeof callsite === 'string') { + return callsite; + } + if (callsite.full) { + return callsite.full; + } + return `${callsite.url}:${callsite.line}:${callsite.column}`; + } + + createCallsite(callsite: Pick): Callsite { + const full = this.formatCallsite(callsite); + return { + url: callsite.url, + line: callsite.line, + column: callsite.column, + full, + label: this.formatCompactCallsite(callsite) + }; + } + + normalizeCallsite(callsite: CallsiteInput): Callsite | null { + if (!callsite) { + return null; + } + const full = this.formatCallsite(callsite); + if (!full) { + return null; + } + const objectCallsite = typeof callsite === 'object' && callsite !== null ? callsite : {}; + return { + url: objectCallsite.url || '', + line: objectCallsite.line || '', + column: objectCallsite.column || '', + full, + label: objectCallsite.label || this.formatCompactCallsite(callsite), + }; + } + + formatCompactCallsite(callsite: CallsiteInput): string { + const source = typeof callsite === 'string' + ? callsite.replace(/:\d+:\d+$/, '') + : callsite?.url || callsite?.full || ''; + const normalizedSource = String(source).split(/[?#]/)[0].replace(/\\/g, '/'); + const filename = normalizedSource.slice(normalizedSource.lastIndexOf('/') + 1); + return filename.replace(/\.(?:mjs|js|tsx?|jsx)$/i, '') || this.formatCallsite(callsite); + } + + shouldGroupDetails(callsite: Callsite | null): boolean { + return this.config.compactCallsite !== false + && !!callsite?.full + && typeof console.groupCollapsed === 'function' + && typeof console.groupEnd === 'function'; + } + + printExpandedDetails(consoleFn: (...args: any[]) => void, callsite: Callsite | null): void { + if (!this.shouldGroupDetails(callsite)) { + return; + } + if (callsite?.full) { + consoleFn(`Source: ${callsite.full}`); + } + console.groupEnd(); + } + + parseJsonPayloadFromString(value: string): ParsedJsonPayload | null { + const parsed = this.parseJsonLikeString(value); + if (parsed !== null) { + return { + prefix: '', + value: parsed + }; + } + const start = this.findJsonPayloadStart(value); + if (start <= 0) { + return null; + } + const payload = this.parseJsonLikeString(value.slice(start)); + if (payload === null) { + return null; + } + return { + prefix: value.slice(0, start).trimEnd(), + value: payload + }; + } + + findJsonPayloadStart(value: string): number { + const objectStart = value.indexOf('{'); + const arrayStart = value.indexOf('['); + if (objectStart < 0) { + return arrayStart; + } + if (arrayStart < 0) { + return objectStart; + } + return Math.min(objectStart, arrayStart); + } + + parseJsonLikeString(value: string): unknown | null { + const trimmed = value.trim(); + if (!trimmed || !/^[{[]/.test(trimmed)) { + return null; + } + try { + return JSON.parse(trimmed); + } + catch (_e) { + return null; + } + } + + formatSuffix(detail: string, callsite: CallsiteInput, options: { compactCallsite?: boolean } = {}): string { + const normalizedCallsite = this.normalizeCallsite(callsite); + const compactCallsite = options.compactCallsite ?? this.config.compactCallsite !== false; + const suffixDetail = normalizedCallsite + ? (compactCallsite ? normalizedCallsite.label : normalizedCallsite.full) + : detail; + return suffixDetail ? ` (${suffixDetail})` : ''; + } + + serializeLogEntry(log: LogData): Record { + return { + timestamp: log.timestamp, + module: log.module, + level: log.level, + levelName: log.levelName, + args: log.args.map((arg) => { + if (typeof arg === 'object' && arg !== null) { + try { + return JSON.parse(JSON.stringify(arg)); + } + catch (_e) { + return String(arg); + } + } + return arg; + }), + callsite: log.callsite || '', + time: log.time instanceof Date ? log.time.toISOString() : log.time + }; + } + deserializeLogEntry(log: unknown): LogData | null { + if (!log || typeof log !== 'object') { + return null; + } + const value = log as Record; + if ('timestamp' in value && 'module' in value && 'level' in value) { + return { + timestamp: String(value.timestamp), + module: String(value.module), + level: this.normalizeLevel(value.level, LogLevel.INFO), + levelName: value.levelName || LEVEL_NAMES[value.level] || 'INFO', + args: Array.isArray(value.args) ? value.args : [], + callsite: value.callsite || '', + time: value.time ? new Date(value.time) : new Date() + }; + } + if ('t' in value && 'm' in value && 'l' in value) { + return { + timestamp: String(value.t), + module: String(value.m), + level: this.normalizeLevel(value.l, LogLevel.INFO), + levelName: LEVEL_NAMES[value.l] || 'INFO', + args: Array.isArray(value.a) ? value.a : [], + callsite: value.c || '', + time: new Date() + }; + } + return null; + } + /** + * Save logs to localStorage + */ + saveLogs(): void { + if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { + try { + const storedLogs = this.logs.map((log) => this.serializeLogEntry(log)); + localStorage.setItem(this.config.storageKey, JSON.stringify(storedLogs)); + } + catch (e) { + console.error('Failed to save logs to localStorage:', e); + } + } + } + /** + * Load logs from localStorage + */ + loadLogs(): void { + if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { + try { + const storedLogs = localStorage.getItem(this.config.storageKey); + if (storedLogs) { + this.logs = JSON.parse(storedLogs) + .map((log: unknown) => this.deserializeLogEntry(log)) + .filter((log: LogData | null): log is LogData => Boolean(log)); + } + } + catch (e) { + console.error('Failed to load logs from localStorage:', e); + } + } + } + /** + * Save configuration to localStorage + */ + saveConfig(): void { + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(LOGGER_CONFIG_KEY, JSON.stringify(this.config)); + } + catch (e) { + console.error('Failed to save logger config to localStorage:', e); + } + } + } + /** + * Load configuration from localStorage + */ + loadConfig(): void { + if (typeof localStorage !== 'undefined') { + try { + const storedConfig = localStorage.getItem(LOGGER_CONFIG_KEY); + if (storedConfig) { + this.config = { ...this.config, ...JSON.parse(storedConfig) }; + this.configure(this.config); + } + } + catch (e) { + console.error('Failed to load logger config from localStorage:', e); + } + } + } + /** + * Clear all logs + */ + clearLogs(): this { + this.logs = []; + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(this.config.storageKey); + } + return this; + } + /** + * Export logs to file + * @param {'json' | 'txt'} format - Export format + */ + exportLogs(format: 'json' | 'txt' = 'json'): boolean { + if (this.logs.length === 0) { + console.warn('No logs to export'); + return false; + } + let content; + let mimeType; + let extension; + if (format === 'json') { + content = JSON.stringify(this.logs.map((log) => this.serializeLogEntry(log)), null, 2); + mimeType = 'application/json'; + extension = 'json'; + } + else { + content = this.logs.map((log) => { + const { root, detail } = this.splitModuleName(log.module); + const suffix = this.formatSuffix(detail, log.callsite, { compactCallsite: false }); + return `${log.levelName.padEnd(LEVEL_WIDTH, ' ')} ${log.timestamp} ${root} ${this.stringifyArgs(log.args)}${suffix}`; + }).join('\n'); + mimeType = 'text/plain'; + extension = 'txt'; + } + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${LOGGER_EXPORT_PREFIX}_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + return true; + } + /** + * Log at DEBUG level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + debug(module: string, ...args: any[]): void { + this.log(module, LogLevel.DEBUG, ...args); + } + /** + * Log at INFO level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + info(module: string, ...args: any[]): void { + this.log(module, LogLevel.INFO, ...args); + } + /** + * Log at WARN level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + warn(module: string, ...args: any[]): void { + this.log(module, LogLevel.WARN, ...args); + } + /** + * Log at ERROR level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + error(module: string, ...args: any[]): void { + this.log(module, LogLevel.ERROR, ...args); + } + /** + * Log at ERROR level with exception stack trace + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + exception(module: string, ...args: any[]): void { + const hasError = args.some(arg => arg instanceof Error); + if (!hasError) { + args.push(new Error("Captured stack trace")); + } + this.log(module, LogLevel.ERROR, ...args); + } + /** + * Log at FATAL level + * @param {string} module - Module name + * @param {any[]} args - Arguments to log + */ + fatal(module: string, ...args: any[]): void { + this.log(module, LogLevel.FATAL, ...args); + } +} +export const logger = new Logger(); +export const debug = (module: string, ...args: any[]): void => logger.debug(module, ...args); +export const info = (module: string, ...args: any[]): void => logger.info(module, ...args); +export const warn = (module: string, ...args: any[]): void => logger.warn(module, ...args); +export const error = (module: string, ...args: any[]): void => logger.error(module, ...args); +export const exception = (module: string, ...args: any[]): void => logger.exception(module, ...args); +export const fatal = (module: string, ...args: any[]): void => logger.fatal(module, ...args); +if (typeof window !== 'undefined') { + (window as unknown as Window & Record)[WINDOW_LOGGER_KEY] = logger; +} +export default logger; diff --git a/src/logger.ts b/src/logger.ts index 5d682f2..9ec392c 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,374 +1,8 @@ /** - * Logger - Centralny system logowania dla ComfyUI-LayerForge + * Backward-compatible exports for the LayerForge logger. * - * Funkcje: - * - Różne poziomy logowania (DEBUG, INFO, WARN, ERROR) - * - Możliwość włączania/wyłączania logów globalnie lub per moduł - * - Kolorowe logi w konsoli - * - Możliwość zapisywania logów do localStorage - * - Możliwość eksportu logów + * The canonical implementation lives in `src/log_system/logger.ts` and is + * compiled to `js/log_system/logger.js` for ComfyUI. */ - -function padStart(str: string, targetLength: number, padString: string): string { - targetLength = targetLength >> 0; - padString = String(padString || ' '); - if (str.length > targetLength) { - return String(str); - } else { - targetLength = targetLength - str.length; - if (targetLength > padString.length) { - padString += padString.repeat(targetLength / padString.length); - } - return padString.slice(0, targetLength) + String(str); - } -} - -export const LogLevel = { - DEBUG: 0, - INFO: 1, - WARN: 2, - ERROR: 3, - NONE: 4 -} as const; - -export type LogLevels = typeof LogLevel[keyof typeof LogLevel]; - -interface LoggerConfig { - globalLevel: LogLevels; - moduleSettings: { [key: string]: LogLevels }; - useColors: boolean; - saveToStorage: boolean; - maxStoredLogs: number; - timestampFormat: string; - storageKey: string; -} - -interface LogData { - timestamp: string; - module: string; - level: LogLevels; - levelName: string; - args: any[]; - time: Date; -} - -const DEFAULT_CONFIG: LoggerConfig = { - globalLevel: LogLevel.INFO, - moduleSettings: {}, - useColors: true, - saveToStorage: false, - maxStoredLogs: 1000, - timestampFormat: 'HH:mm:ss', - storageKey: 'layerforge_logs' -}; - -const COLORS: { [key: number]: string } = { - [LogLevel.DEBUG]: '#9e9e9e', - [LogLevel.INFO]: '#2196f3', - [LogLevel.WARN]: '#ff9800', - [LogLevel.ERROR]: '#f44336', -}; - -const LEVEL_NAMES: { [key: number]: string } = { - [LogLevel.DEBUG]: 'DEBUG', - [LogLevel.INFO]: 'INFO', - [LogLevel.WARN]: 'WARN', - [LogLevel.ERROR]: 'ERROR', -}; - -class Logger { - private config: LoggerConfig; - private enabled: boolean; - private logs: LogData[]; - constructor() { - this.config = {...DEFAULT_CONFIG}; - this.logs = []; - this.enabled = true; - this.loadConfig(); - } - - /** - * Konfiguracja loggera - * @param {Partial} config - Obiekt konfiguracyjny - */ - configure(config: Partial): this { - this.config = {...this.config, ...config}; - this.saveConfig(); - return this; - } - - /** - * Włącz/wyłącz logger globalnie - * @param {boolean} enabled - Czy logger ma być włączony - */ - setEnabled(enabled: boolean): this { - this.enabled = enabled; - return this; - } - - /** - * Ustaw globalny poziom logowania - * @param {LogLevels} level - Poziom logowania - */ - setGlobalLevel(level: LogLevels): this { - this.config.globalLevel = level; - this.saveConfig(); - return this; - } - - /** - * Ustaw poziom logowania dla konkretnego modułu - * @param {string} module - Nazwa modułu - * @param {LogLevels} level - Poziom logowania - */ - setModuleLevel(module: string, level: LogLevels): this { - this.config.moduleSettings[module] = level; - this.saveConfig(); - return this; - } - - /** - * Sprawdź, czy dany poziom logowania jest aktywny dla modułu - * @param {string} module - Nazwa modułu - * @param {LogLevels} level - Poziom logowania do sprawdzenia - * @returns {boolean} - Czy poziom jest aktywny - */ - isLevelEnabled(module: string, level: LogLevels): boolean { - if (!this.enabled) return false; - if (this.config.moduleSettings[module] !== undefined) { - return level >= this.config.moduleSettings[module]; - } - return level >= this.config.globalLevel; - } - - /** - * Formatuj znacznik czasu - * @returns {string} - Sformatowany znacznik czasu - */ - formatTimestamp(): string { - const now = new Date(); - const format = this.config.timestampFormat; - return format - .replace('HH', padStart(String(now.getHours()), 2, '0')) - .replace('mm', padStart(String(now.getMinutes()), 2, '0')) - .replace('ss', padStart(String(now.getSeconds()), 2, '0')) - .replace('SSS', padStart(String(now.getMilliseconds()), 3, '0')); - } - - /** - * Zapisz log - * @param {string} module - Nazwa modułu - * @param {LogLevels} level - Poziom logowania - * @param {any[]} args - Argumenty do zalogowania - */ - log(module: string, level: LogLevels, ...args: any[]): void { - if (!this.isLevelEnabled(module, level)) return; - - const timestamp = this.formatTimestamp(); - const levelName = LEVEL_NAMES[level]; - const logData: LogData = { - timestamp, - module, - level, - levelName, - args, - time: new Date() - }; - if (this.config.saveToStorage) { - this.logs.push(logData); - if (this.logs.length > this.config.maxStoredLogs) { - this.logs.shift(); - } - this.saveLogs(); - } - this.printToConsole(logData); - } - - /** - * Wyświetl log w konsoli - * @param {LogData} logData - Dane logu - */ - printToConsole(logData: LogData): void { - const {timestamp, module, level, levelName, args} = logData; - const prefix = `[${timestamp}] [${module}] [${levelName}]`; - if (this.config.useColors && typeof console.log === 'function') { - const color = COLORS[level] || '#000000'; - console.log(`%c${prefix}`, `color: ${color}; font-weight: bold;`, ...args); - return; - } - console.log(prefix, ...args); - } - - /** - * Zapisz logi do localStorage - */ - saveLogs(): void { - if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { - try { - const simplifiedLogs = this.logs.map((log) => ({ - t: log.timestamp, - m: log.module, - l: log.level, - a: log.args.map((arg: any) => { - if (typeof arg === 'object') { - try { - return JSON.stringify(arg); - } catch (e) { - return String(arg); - } - } - return arg; - }) - })); - - localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs)); - } catch (e) { - console.error('Failed to save logs to localStorage:', e); - } - } - } - - /** - * Załaduj logi z localStorage - */ - loadLogs(): void { - if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { - try { - const storedLogs = localStorage.getItem(this.config.storageKey); - if (storedLogs) { - this.logs = JSON.parse(storedLogs); - } - } catch (e) { - console.error('Failed to load logs from localStorage:', e); - } - } - } - - /** - * Zapisz konfigurację do localStorage - */ - saveConfig(): void { - if (typeof localStorage !== 'undefined') { - try { - localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config)); - } catch (e) { - console.error('Failed to save logger config to localStorage:', e); - } - } - } - - /** - * Załaduj konfigurację z localStorage - */ - loadConfig(): void { - if (typeof localStorage !== 'undefined') { - try { - const storedConfig = localStorage.getItem('layerforge_logger_config'); - if (storedConfig) { - this.config = {...this.config, ...JSON.parse(storedConfig)}; - } - } catch (e) { - console.error('Failed to load logger config from localStorage:', e); - } - } - } - - /** - * Wyczyść wszystkie logi - */ - clearLogs(): this { - this.logs = []; - if (typeof localStorage !== 'undefined') { - localStorage.removeItem(this.config.storageKey); - } - return this; - } - - /** - * Eksportuj logi do pliku - * @param {'json' | 'txt'} format - Format eksportu - */ - exportLogs(format: 'json' | 'txt' = 'json'): void { - if (this.logs.length === 0) { - console.warn('No logs to export'); - return; - } - - let content: string; - let mimeType: string; - let extension: string; - - if (format === 'json') { - content = JSON.stringify(this.logs, null, 2); - mimeType = 'application/json'; - extension = 'json'; - } else { - content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n'); - mimeType = 'text/plain'; - extension = 'txt'; - } - const blob = new Blob([content], {type: mimeType}); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `layerforge_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - - /** - * Log na poziomie DEBUG - * @param {string} module - Nazwa modułu - * @param {any[]} args - Argumenty do zalogowania - */ - debug(module: string, ...args: any[]): void { - this.log(module, LogLevel.DEBUG, ...args); - } - - /** - * Log na poziomie INFO - * @param {string} module - Nazwa modułu - * @param {any[]} args - Argumenty do zalogowania - */ - info(module: string, ...args: any[]): void { - this.log(module, LogLevel.INFO, ...args); - } - - /** - * Log na poziomie WARN - * @param {string} module - Nazwa modułu - * @param {any[]} args - Argumenty do zalogowania - */ - warn(module: string, ...args: any[]): void { - this.log(module, LogLevel.WARN, ...args); - } - - /** - * Log na poziomie ERROR - * @param {string} module - Nazwa modułu - * @param {any[]} args - Argumenty do zalogowania - */ - error(module: string, ...args: any[]): void { - this.log(module, LogLevel.ERROR, ...args); - } -} - -export const logger = new Logger(); -export const debug = (module: string, ...args: any[]) => logger.debug(module, ...args); -export const info = (module: string, ...args: any[]) => logger.info(module, ...args); -export const warn = (module: string, ...args: any[]) => logger.warn(module, ...args); -export const error = (module: string, ...args: any[]) => logger.error(module, ...args); - -declare global { - interface Window { - LayerForgeLogger: Logger; - } -} - -if (typeof window !== 'undefined') { - window.LayerForgeLogger = logger; -} - -export default logger; +export * from './log_system/logger.js'; +export { default } from './log_system/logger.js'; diff --git a/src/utils/LoggerUtils.ts b/src/utils/LoggerUtils.ts index c00bba2..f8fe7e5 100644 --- a/src/utils/LoggerUtils.ts +++ b/src/utils/LoggerUtils.ts @@ -1,92 +1,12 @@ /** - * LoggerUtils - Centralizacja inicjalizacji loggerów - * Eliminuje powtarzalny kod inicjalizacji loggera w każdym module + * Backward-compatible exports for the LayerForge logging helpers. + * + * The canonical implementation lives in `src/log_system/log_funcs.ts`. */ - -import {logger, LogLevel} from "../logger.js"; -import { LOG_LEVEL } from '../config.js'; - -export interface Logger { - debug: (...args: any[]) => void; - info: (...args: any[]) => void; - warn: (...args: any[]) => void; - error: (...args: any[]) => void; -} - -/** - * Tworzy obiekt loggera dla modułu z predefiniowanymi metodami - * @param {string} moduleName - Nazwa modułu - * @returns {Logger} Obiekt z metodami logowania - */ -export function createModuleLogger(moduleName: string): Logger { - logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL as keyof typeof LogLevel]); - - return { - debug: (...args: any[]) => logger.debug(moduleName, ...args), - info: (...args: any[]) => logger.info(moduleName, ...args), - warn: (...args: any[]) => logger.warn(moduleName, ...args), - error: (...args: any[]) => logger.error(moduleName, ...args) - }; -} - -/** - * Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL - * @returns {Logger} Obiekt z metodami logowania - */ -export function createAutoLogger(): Logger { - const stack = new Error().stack; - const match = stack?.match(/\/([^\/]+)\.js/); - const moduleName = match ? match[1] : 'Unknown'; - - return createModuleLogger(moduleName); -} - -/** - * Wrapper dla operacji z automatycznym logowaniem błędów - * @param {Function} operation - Operacja do wykonania - * @param {Logger} log - Obiekt loggera - * @param {string} operationName - Nazwa operacji (dla logów) - * @returns {Function} Opakowana funkcja - */ -export function withErrorLogging any>( - operation: T, - log: Logger, - operationName: string -): (...args: Parameters) => Promise> { - return async function(this: any, ...args: Parameters): Promise> { - try { - log.debug(`Starting ${operationName}`); - const result = await operation.apply(this, args); - log.debug(`Completed ${operationName}`); - return result; - } catch (error) { - log.error(`Error in ${operationName}:`, error); - throw error; - } - }; -} - -/** - * Decorator dla metod klasy z automatycznym logowaniem - * @param {Logger} log - Obiekt loggera - * @param {string} methodName - Nazwa metody - */ -export function logMethod(log: Logger, methodName?: string) { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { - const originalMethod = descriptor.value; - - descriptor.value = async function (...args: any[]) { - try { - log.debug(`${methodName || propertyKey} started`); - const result = await originalMethod.apply(this, args); - log.debug(`${methodName || propertyKey} completed`); - return result; - } catch (error) { - log.error(`${methodName || propertyKey} failed:`, error); - throw error; - } - }; - - return descriptor; - }; -} +export { + createAutoLogger, + createModuleLogger, + logMethod, + withErrorLogging, +} from '../log_system/log_funcs.js'; +export type { Logger } from '../log_system/log_funcs.js'; diff --git a/tests/test_log_rotation.py b/tests/test_log_rotation.py new file mode 100644 index 0000000..f9ce146 --- /dev/null +++ b/tests/test_log_rotation.py @@ -0,0 +1,81 @@ +import logging +import os +import tempfile + +from python.log_system.logger import ( + ANSI_RESET, + ColoredFormatter, + SafeRotatingFileHandler, + parse_rotated_log_filename, + rotated_log_filename, +) + + +def test_colored_formatter_uses_root_label_and_context_badges(): + formatter = ColoredFormatter( + use_colors=True, + include_milliseconds=False, + include_brackets=True, + ) + record = logging.LogRecord( + "azlogs.module", + logging.INFO, + "test_file.py", + 12, + "message", + (), + None, + ) + + formatted = formatter.format(record) + badge_prefix = "\033[97;48;2;38;63;76m " + timestamp = formatter._format_time(record) + + assert f"{badge_prefix}[{timestamp}] {ANSI_RESET}" in formatted + assert "[module]" in formatted + assert formatted.count(badge_prefix) == 2 + + +def test_rotated_log_filename_keeps_log_as_final_extension(): + path = os.path.join("logs", "azlogs_LayerForge.log.3") + assert rotated_log_filename(path) == os.path.join("logs", "azlogs_LayerForge.3.log") + assert rotated_log_filename("logs/azlogs_LayerForge.log") == "logs/azlogs_LayerForge.log" + + +def test_parse_rotated_log_filename_accepts_current_style_and_rejects_legacy(): + assert parse_rotated_log_filename("azlogs_LayerForge.2.log") == ("azlogs_LayerForge", 2) + assert parse_rotated_log_filename("azlogs_LayerForge.log") == ("azlogs_LayerForge", 0) + assert parse_rotated_log_filename("other.txt") is None + assert parse_rotated_log_filename("azlogs_LayerForge.log.2") is None + + +def test_safe_rotating_file_handler_keeps_log_extension_last(): + with tempfile.TemporaryDirectory() as temp_dir: + log_path = os.path.join(temp_dir, "azlogs_LayerForge.log") + test_logger = logging.getLogger(f"test-log-rotation-{id(log_path)}") + test_logger.handlers.clear() + test_logger.propagate = False + test_logger.setLevel(logging.INFO) + handler = SafeRotatingFileHandler( + log_path, + maxBytes=32, + backupCount=2, + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + test_logger.addHandler(handler) + + try: + test_logger.info("first message large enough to rotate") + test_logger.info("second message large enough to rotate") + test_logger.info("third message large enough to rotate") + handler.flush() + finally: + test_logger.removeHandler(handler) + handler.close() + + files = set(os.listdir(temp_dir)) + assert "azlogs_LayerForge.log" in files + assert "azlogs_LayerForge.1.log" in files + assert "azlogs_LayerForge.2.log" in files + assert "azlogs_LayerForge.log.1" not in files diff --git a/tests/test_log_system.mjs b/tests/test_log_system.mjs new file mode 100644 index 0000000..7169741 --- /dev/null +++ b/tests/test_log_system.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { Logger, LogLevel } from '../js/log_system/logger.js'; +import { createModuleLogger, withErrorLogging } from '../js/log_system/log_funcs.js'; +import * as legacyLogger from '../js/logger.js'; + + +function silenceConsole() { + const methods = ['debug', 'info', 'warn', 'error', 'log', 'groupCollapsed', 'groupEnd']; + const originals = Object.fromEntries(methods.map((method) => [method, console[method]])); + for (const method of methods) { + console[method] = () => {}; + } + return () => { + for (const [method, original] of Object.entries(originals)) { + console[method] = original; + } + }; +} + + +test('canonical logger handles levels, retention, callsites, and compatibility exports', () => { + const restoreConsole = silenceConsole(); + try { + const logger = new Logger(); + logger.configure({ + enabled: true, + globalLevel: LogLevel.DEBUG, + useColors: false, + saveToStorage: false, + maxStoredLogs: 3, + compactCallsite: false, + }); + + logger.debug('LayerForge.test', 'debug message'); + logger.info('LayerForge.test', 'info message'); + logger.error('LayerForge.test', 'error message'); + logger.fatal('LayerForge.test', 'fatal message'); + + assert.equal(logger.logs.length, 3); + assert.equal(logger.logs[0].level, LogLevel.INFO); + assert.equal(logger.logs.at(-1).level, LogLevel.FATAL); + assert.equal(logger.splitModuleName('LayerForge.canvas').detail, 'canvas'); + assert.equal(logger.normalizeLevel('warning'), LogLevel.WARN); + assert.equal(logger.normalizeLevel('critical'), LogLevel.FATAL); + assert.equal(logger.parseJsonPayloadFromString('message {"value": 1}').value.value, 1); + assert.match(logger.formatSuffix('canvas', 'Canvas.ts:10:2'), /Canvas\.ts:10:2/); + + logger.setModuleLevel('quiet', LogLevel.NONE); + assert.equal(logger.isLevelEnabled('quiet', LogLevel.ERROR), false); + logger.setEnabled(false); + assert.equal(logger.isLevelEnabled('LayerForge.test', LogLevel.ERROR), false); + + assert.equal(legacyLogger.Logger, Logger); + assert.equal(typeof createModuleLogger('LayerForge.test').error, 'function'); + } finally { + restoreConsole(); + } +}); + + +test('logger exception preserves an Error argument and helper wrappers report lifecycle', async () => { + const restoreConsole = silenceConsole(); + try { + const logger = new Logger(); + logger.configure({ + enabled: true, + globalLevel: LogLevel.DEBUG, + useColors: false, + saveToStorage: false, + compactCallsite: false, + }); + + logger.exception('LayerForge.test', 'failed', new Error('boom')); + assert.equal(logger.logs.at(-1).args.at(-1).message, 'boom'); + + const events = []; + const wrapped = withErrorLogging( + async (value) => value + 1, + { + debug: (message) => events.push(message), + error: (message) => events.push(message), + }, + 'increment', + ); + + assert.equal(await wrapped(2), 3); + assert.deepEqual(events, ['Starting increment', 'Completed increment']); + } finally { + restoreConsole(); + } +}); diff --git a/tests/test_python_logger.py b/tests/test_python_logger.py index 2dab68b..9021cfd 100644 --- a/tests/test_python_logger.py +++ b/tests/test_python_logger.py @@ -1,13 +1,13 @@ import logging +import importlib import sys import pytest -from python.logger import ( +from python.log_system.log_funcs import error, exception +from python.log_system.logger import ( ColoredFormatter, LogLevel, - error, - exception, logger, set_debug, set_file_logging, @@ -20,13 +20,13 @@ def reset_logger_state(): original_module_settings = logger.config["module_settings"].copy() original_enabled = logger.enabled + logger.reset_loggers() logger.set_enabled(True) logger.config["module_settings"] = {} logger.config["log_to_file"] = False - logger.loggers = {} yield - logger.loggers = {} + logger.reset_loggers() logger.config.clear() logger.config.update(original_config) logger.config["module_settings"] = original_module_settings @@ -63,7 +63,7 @@ def test_colored_formatter_contains_timestamp_module_and_level(): formatted = formatter.format(record) - assert "[layerforge.test]" in formatted + assert "[layerforge]" in formatted assert "[INFO]" in formatted assert "hello world" in formatted @@ -85,18 +85,18 @@ def test_colored_formatter_includes_exception_details_and_color(): formatter = ColoredFormatter(datefmt="%H:%M:%S", use_colors=True) formatted = formatter.format(record) - assert "\x1b[1m" in formatted + assert "\x1b[1;97;" in formatted assert "ValueError: boom" in formatted def test_environment_configuration_parses_valid_and_invalid_values(monkeypatch, tmp_path): - monkeypatch.setenv("LAYERFORGE_LOG_LEVEL", "DEBUG") - monkeypatch.setenv("LAYERFORGE_MODULE_LEVELS", '{"canvas": "ERROR", "ignored": "unknown"}') - monkeypatch.setenv("LAYERFORGE_USE_COLORS", "false") - monkeypatch.setenv("LAYERFORGE_LOG_TO_FILE", "true") - monkeypatch.setenv("LAYERFORGE_LOG_DIR", str(tmp_path / "logs")) - monkeypatch.setenv("LAYERFORGE_MAX_FILE_SIZE_MB", "2") - monkeypatch.setenv("LAYERFORGE_BACKUP_COUNT", "3") + monkeypatch.setenv("AZLOGS_LOG_LEVEL", "DEBUG") + monkeypatch.setenv("AZLOGS_MODULE_LEVELS", '{"canvas": "ERROR", "ignored": "unknown"}') + monkeypatch.setenv("AZLOGS_USE_COLORS", "false") + monkeypatch.setenv("AZLOGS_LOG_TO_FILE", "true") + monkeypatch.setenv("AZLOGS_LOG_DIR", str(tmp_path / "logs")) + monkeypatch.setenv("AZLOGS_MAX_FILE_SIZE_MB", "2") + monkeypatch.setenv("AZLOGS_BACKUP_COUNT", "3") logger._load_config_from_env() @@ -107,9 +107,9 @@ def test_environment_configuration_parses_valid_and_invalid_values(monkeypatch, assert logger.config["max_file_size_mb"] == 2 assert logger.config["backup_count"] == 3 - monkeypatch.setenv("LAYERFORGE_MODULE_LEVELS", "not-json") - monkeypatch.setenv("LAYERFORGE_MAX_FILE_SIZE_MB", "not-an-int") - monkeypatch.setenv("LAYERFORGE_BACKUP_COUNT", "not-an-int") + monkeypatch.setenv("AZLOGS_MODULE_LEVELS", "not-json") + monkeypatch.setenv("AZLOGS_MAX_FILE_SIZE_MB", "not-an-int") + monkeypatch.setenv("AZLOGS_BACKUP_COUNT", "not-an-int") logger._load_config_from_env() assert logger.config["module_settings"]["canvas"] == LogLevel.ERROR @@ -119,8 +119,9 @@ def test_configure_disables_file_logging_when_directory_creation_fails(monkeypat def raise_os_error(*args, **kwargs): raise OSError("access denied") - monkeypatch.setattr("python.logger.os.makedirs", raise_os_error) - monkeypatch.setattr("python.logger.traceback.print_exc", lambda: None) + logger_module = importlib.import_module("python.log_system.logger") + monkeypatch.setattr(logger_module.os, "makedirs", raise_os_error) + monkeypatch.setattr(logger_module.traceback, "print_exc", lambda: None) logger.configure({"log_to_file": True, "log_dir": "blocked"}) @@ -135,7 +136,7 @@ def test_file_logging_writes_to_configured_directory(tmp_path): logger.info("file-test", "message") logger.info("file-test", "second message") - log_files = list(log_directory.glob("layerforge_*.log")) + log_files = list(log_directory.glob("azlogs_*.log")) assert len(log_files) == 1 assert "message" in log_files[0].read_text(encoding="utf-8")