diff --git a/TODO b/TODO index 4d06273..30e7113 100644 --- a/TODO +++ b/TODO @@ -121,7 +121,7 @@ Content-Type: application/issue ID: 8 Type: feature Title: implement browser-compatible Handler output -Status: in-progress +Status: done Priority: high Created: 2026-03-13 Relationships: dependsOn:5 diff --git a/src/handler.ts b/src/handler.ts index 24ff076..e174133 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,17 +1,9 @@ -import * as stream from 'stream'; - -import { LogLevel, checkLevel, NOTSET } from './log-level'; +import { LogLevel, checkLevel, NOTSET, WARNING, ERROR } from './log-level'; import { LogRecord } from './log-record'; import { Formatter, DEFAULT_FORMATTER } from './formatter'; import { Filterer } from './filter'; import { NotImplementedError } from './helper/error'; - -if (typeof window === 'undefined') { - const stream = require('stream'); -} -else { - const stream = require('./helper/stream'); -} +import { Writable, DEFAULT_STREAM, StderrWritable } from './helper/stream'; //--------------------------------------------------------------------------- // Handler classes and functions @@ -22,7 +14,7 @@ type Handlers = {[key: string]: Handler}; /** * map of handler names to handlers */ -const HANDLERS: Handlers = {}; +const HANDLERS: Handlers = {}; /** * added to allow handlers to be removed in reverse order of initialization @@ -86,7 +78,7 @@ export class Handler extends Filterer { /** * Format the specified record. - * + * * If a formatter is set, use it. Otherwise, use the default formatter for * the module. */ @@ -115,14 +107,13 @@ export class Handler extends Filterer { * I/O thread lock. */ handle(record: LogRecord) { - var rv = this.filter(record); + const rv = this.filter(record); + if (!rv) { return } + let filtered = record; if ((rv as any) instanceof LogRecord) { - record = rv as unknown as LogRecord - } - if (rv) { - //locking here - this.emit(record) + filtered = rv as unknown as LogRecord } + this.emit(filtered) } /** @@ -144,23 +135,73 @@ export class Handler extends Filterer { * Handle errors which occur during an emit() call. * * This method should be called from handlers when an exception is - * encountered during an emit() call. If raiseExceptions is false, - * exceptions get silently ignored. This is what is mostly wanted - * for a logging system - most users will not care about errors in - * the logging system, they are more interested in application errors. - * You could, however, replace this with a custom handler if you wish. - * The record which was being processed is passed in to this method. + * encountered during an emit() call. */ handleError(record: LogRecord) { - throw new NotImplementedError( - 'still need to find portable way for stacktracing...' - ) + try { + console.error('--- Logging error ---'); + console.error('Error in handler for record:', record.scope, record.msg); + } + catch (_) { + // silently ignore errors in error handling + } } get formatter(): Formatter|null { return this._formatter } set formatter(fmt: Formatter) { this._formatter = fmt } } +/** + * A handler class which writes logging records, appropriately formatted, + * to a stream. Note that this class does not close the stream, as + * the default stream may be shared. + */ +export class StreamHandler extends Handler { + protected stream: Writable; + + constructor(stream?: Writable) { + super(); + this.stream = stream ?? DEFAULT_STREAM; + } + + emit(record: LogRecord) { + try { + const msg = this.format(record); + this.stream.write(msg); + } + catch (e) { + this.handleError(record); + } + } +} + +/** + * A handler class which writes logging records to the browser console, + * mapping log levels to the appropriate console method. + * + * This is the primary handler for browser environments. + */ +export class ConsoleHandler extends Handler { + emit(record: LogRecord) { + try { + const msg = this.format(record); + + if (record.levelno >= ERROR) { + console.error(msg); + } + else if (record.levelno >= WARNING) { + console.warn(msg); + } + else { + console.log(msg); + } + } + catch (e) { + this.handleError(record); + } + } +} + export interface FileHandlerOptions { filename: string filemode?: string @@ -168,20 +209,13 @@ export interface FileHandlerOptions { errors?: string } -/** - * A handler class which writes logging records, appropriately formatted, - to a stream. Note that this class does not close the stream, as - sys.stdout or sys.stderr may be used. - */ -export class StreamHandler extends Handler { - constructor(stream?: stream.Writable) { - super(); - } -} - export class FileHandler extends StreamHandler { constructor(options: FileHandlerOptions) { super(); + throw new NotImplementedError( + 'FileHandler is not available in browser environments. ' + + 'Use ConsoleHandler or a storage-backed handler instead.' + ); } } @@ -190,9 +224,16 @@ export class FileHandler extends StreamHandler { * whatever sys.stderr is currently set to rather than the value of * sys.stderr at handler construction time. */ - export class StderrHandler extends Handler { - /** - * Initialize the handler. - */ +export class StderrHandler extends Handler { constructor(level: LogLevel) { super(level) } + + emit(record: LogRecord) { + try { + const msg = this.format(record); + console.error(msg); + } + catch (e) { + this.handleError(record); + } + } } diff --git a/src/helper/stream.ts b/src/helper/stream.ts index 297e20c..4102cf4 100644 --- a/src/helper/stream.ts +++ b/src/helper/stream.ts @@ -1,2 +1,33 @@ +/** + * Minimal writable stream interface for browser compatibility. + * + * This abstracts over Node.js streams and browser console output, + * providing a common write target for handlers. + */ +export interface Writable { + write(data: string): void; +} -export type MillisecondsSinceUnixEpoch = number; +/** + * A Writable backed by console.log. + */ +export class ConsoleWritable implements Writable { + write(data: string): void { + console.log(data); + } +} + +/** + * A Writable backed by console.error (stderr equivalent). + */ +export class StderrWritable implements Writable { + write(data: string): void { + console.error(data); + } +} + +/** + * Default output stream. Uses console.error to match Python's default + * of writing to sys.stderr. + */ +export const DEFAULT_STREAM: Writable = new StderrWritable(); diff --git a/tests/unit/handler.test.ts b/tests/unit/handler.test.ts index 99a897a..cc22958 100644 --- a/tests/unit/handler.test.ts +++ b/tests/unit/handler.test.ts @@ -1,8 +1,16 @@ import {expect, jest, test} from '@jest/globals'; -import { Handler, StreamHandler, StderrHandler } from '../../src/handler'; +import { + Handler, + StreamHandler, + ConsoleHandler, + StderrHandler, + FileHandler, +} from '../../src/handler'; import { Formatter, DEFAULT_FORMATTER } from '../../src/formatter'; import { LogRecord } from '../../src/log-record'; -import { DEBUG, WARNING, ERROR, NOTSET } from '../../src/log-level'; +import { Writable } from '../../src/helper/stream'; +import { DEBUG, INFO, WARNING, ERROR, CRITICAL, NOTSET } from '../../src/log-level'; + function makeRecord(level: number, msg: string): LogRecord { return new LogRecord('test', { level, msg }); } @@ -50,15 +58,6 @@ describe('Handler', () => { expect(typeof result).toBe('string'); }); - test('uses assigned formatter', () => { - const h = new Handler(); - const fmt = new Formatter({ fmt: '%(message)s' }); - h.formatter = fmt; - const record = makeRecord(DEBUG, 'hello'); - const result = h.format(record); - expect(typeof result).toBe('string'); - }); - test('returns a string', () => { const h = new Handler(); const record = makeRecord(WARNING, 'test message'); @@ -82,6 +81,103 @@ describe('Handler', () => { expect(h.closed).toBe(true); }); }); + + describe('handleError', () => { + test('does not throw', () => { + const h = new Handler(); + const record = makeRecord(DEBUG, 'test'); + expect(() => h.handleError(record)).not.toThrow(); + }); + }); +}); + +describe('StreamHandler', () => { + test('writes formatted output to the stream', () => { + const written: string[] = []; + const stream: Writable = { write: (data: string) => { written.push(data) } }; + const h = new StreamHandler(stream); + const record = makeRecord(WARNING, 'stream test'); + h.emit(record); + expect(written.length).toBe(1); + expect(written[0]).toContain('stream test'); + }); + + test('uses default stream when none provided', () => { + const h = new StreamHandler(); + const record = makeRecord(DEBUG, 'default stream'); + expect(() => h.emit(record)).not.toThrow(); + }); + + test('calls handleError on emit failure', () => { + const stream: Writable = { + write: () => { throw new Error('write failed') } + }; + const h = new StreamHandler(stream); + const record = makeRecord(DEBUG, 'fail'); + expect(() => h.emit(record)).not.toThrow(); + }); +}); + +describe('ConsoleHandler', () => { + let origLog: typeof console.log; + let origWarn: typeof console.warn; + let origError: typeof console.error; + let logged: string[]; + let warned: string[]; + let errored: string[]; + + beforeEach(() => { + logged = []; + warned = []; + errored = []; + origLog = console.log; + origWarn = console.warn; + origError = console.error; + console.log = (...args: any[]) => { logged.push(args.join(' ')) }; + console.warn = (...args: any[]) => { warned.push(args.join(' ')) }; + console.error = (...args: any[]) => { errored.push(args.join(' ')) }; + }); + + afterEach(() => { + console.log = origLog; + console.warn = origWarn; + console.error = origError; + }); + + test('uses console.error for ERROR level', () => { + const h = new ConsoleHandler(); + h.emit(makeRecord(ERROR, 'error msg')); + expect(errored.length).toBe(1); + expect(errored[0]).toContain('error msg'); + }); + + test('uses console.error for CRITICAL level', () => { + const h = new ConsoleHandler(); + h.emit(makeRecord(CRITICAL, 'critical msg')); + expect(errored.length).toBe(1); + expect(errored[0]).toContain('critical msg'); + }); + + test('uses console.warn for WARNING level', () => { + const h = new ConsoleHandler(); + h.emit(makeRecord(WARNING, 'warn msg')); + expect(warned.length).toBe(1); + expect(warned[0]).toContain('warn msg'); + }); + + test('uses console.log for INFO level', () => { + const h = new ConsoleHandler(); + h.emit(makeRecord(INFO, 'info msg')); + expect(logged.length).toBe(1); + expect(logged[0]).toContain('info msg'); + }); + + test('uses console.log for DEBUG level', () => { + const h = new ConsoleHandler(); + h.emit(makeRecord(DEBUG, 'debug msg')); + expect(logged.length).toBe(1); + expect(logged[0]).toContain('debug msg'); + }); }); describe('StderrHandler', () => { @@ -89,4 +185,27 @@ describe('StderrHandler', () => { const h = new StderrHandler(ERROR); expect(h.level).toBe(ERROR); }); + + test('emits via console.error', () => { + const errored: string[] = []; + const origError = console.error; + console.error = (...args: any[]) => { errored.push(args.join(' ')) }; + try { + const h = new StderrHandler(DEBUG); + h.emit(makeRecord(WARNING, 'stderr test')); + expect(errored.length).toBe(1); + expect(errored[0]).toContain('stderr test'); + } + finally { + console.error = origError; + } + }); +}); + +describe('FileHandler', () => { + test('throws NotImplementedError on construction', () => { + expect(() => new FileHandler({ + filename: 'test.log' + })).toThrow('not available in browser'); + }); });