From c0ced4cda20fabae3f0b586aab630df8eebbb5e7 Mon Sep 17 00:00:00 2001 From: Tiara Rodney Date: Fri, 13 Mar 2026 23:09:50 +0100 Subject: [PATCH 1/4] feat(log-record): add message, name, msg, args fields and getMessage --- src/log-record.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/log-record.ts b/src/log-record.ts index 6c6f335..cafca77 100644 --- a/src/log-record.ts +++ b/src/log-record.ts @@ -48,13 +48,32 @@ export class LogRecord { public readonly levelno: LogLevel; public readonly levelname: string|LogLevel; public readonly scope: string; + public readonly name: string; + public readonly msg: string; + public readonly args: any[]|undefined; + public message: string = ''; public readonly created: MillisecondsSinceUnixEpoch = Date.now(); + public asctime: string = ''; constructor(scope: string, options: LogRecordOptions) { this.levelno = options.level; this.levelname = getLevelName(options.level); this.scope = scope; + this.name = scope; + this.msg = options.msg; + this.args = options.args; + } + + getMessage(): string { + let msg = String(this.msg); + if (this.args && this.args.length > 0) { + msg = this.args.reduce( + (s, arg) => s.replace('%s', String(arg)), + msg + ); + } + return msg; } } From 7b9e88ef59b1350b6bd9aad651e0498ad0f6f26d Mon Sep 17 00:00:00 2001 From: Tiara Rodney Date: Fri, 13 Mar 2026 23:14:49 +0100 Subject: [PATCH 2/4] feat(formatter): implement %-style formatting, formatTime, formatError PercentFormatterStyle._format() now performs %(key)s/d/f substitution against LogRecord attributes. Formatter.format() sets record.message via getMessage() and conditionally calls formatTime() for asctime. formatTime() uses JS Date with strftime-style tokens (%Y,%m,%d,%H, %M,%S) or ISO8601 fallback. formatError() returns Error.stack. BASIC_FORMAT updated to use %(levelname)s. Fixed datefmt type in config.ts. --- src/config.ts | 2 +- src/formatter.ts | 151 ++++++++++++++++++++++++----------------------- 2 files changed, 77 insertions(+), 76 deletions(-) diff --git a/src/config.ts b/src/config.ts index d33cfa4..57ba2d1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -121,7 +121,7 @@ export function basicConfig(options: BasicConfigOptions) { const filename = options.filename ?? null; const stream = options.stream ?? null; const filemode = options.filemode ?? 'a'; - const dateformat = options.datefmt ?? null; + const dateformat = options.datefmt; const style = options.style ?? '%'; const level = options.level ?? null; diff --git a/src/formatter.ts b/src/formatter.ts index cf1d6e6..542d3ac 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -10,14 +10,20 @@ export interface PercentFormatterStyleOptions { defaults: {[key: string]: any}; } +/** + * %-style formatting for log records. + * + * Substitutes %(name)s, %(name)d, %(name)f placeholders from record + * attributes. + */ class PercentFormatterStyle { public static defaultFormat = '%(message)s'; public static asctimeFormat = '%(asctime)s'; public static asctimeSearch = '%(asctime)'; - public static validationPattern = + public static validationPattern = /%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]/; - private fmt: string; + public fmt: string; private defaults: {[key: string]: any}; constructor(options: PercentFormatterStyleOptions) { @@ -26,7 +32,7 @@ class PercentFormatterStyle { } usesTime(): boolean { - return this.fmt.match(PercentFormatterStyle.asctimeFormat) ? true : false + return this.fmt.indexOf(PercentFormatterStyle.asctimeSearch) >= 0; } /** @@ -42,12 +48,32 @@ class PercentFormatterStyle { } protected _format(record: LogRecord): string { - var defaults = this.defaults; - var values: {[key: string]: any}|null; - if (defaults) { values = {...this.defaults, ...Object.entries(record)} } - else { values = Object.entries(record) } - //TODO: implement formatting - return 'would do some formatting'; + const values: {[key: string]: any} = { + ...this.defaults, + ...(record as unknown as {[key: string]: any}), + }; + + return this.fmt.replace( + /%\((\w+)\)([#0+ -]*(?:\*|\d+)?(?:\.(?:\*|\d+))?[diouxefgcrsa%])/g, + (_match, key, spec) => { + if (!(key in values)) { + throw new ValueError(`formatting field not found in record: ${key}`); + } + const val = values[key]; + const conversion = spec[spec.length - 1]; + switch (conversion) { + case 'd': + case 'i': + return String(Math.floor(Number(val))); + case 'f': + return String(Number(val)); + case 's': + return String(val); + default: + return String(val); + } + } + ); } format(record: LogRecord): string { @@ -55,12 +81,13 @@ class PercentFormatterStyle { return this._format(record) } catch (e) { + if (e instanceof ValueError) { throw e } throw new ValueError(`formatting field not found in record: ${e}`) } } } -const BASIC_FORMAT = '%(level)s:%(name)s:%(message)s'; +const BASIC_FORMAT = '%(levelname)s:%(name)s:%(message)s'; export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = { '%': [PercentFormatterStyle, BASIC_FORMAT], @@ -68,7 +95,7 @@ export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOption export interface FormatterOptions { fmt?: string - datefmt?: any + datefmt?: string style?: string validate?: boolean defaults?: {[key: string]: any} @@ -95,53 +122,26 @@ export interface FormatterOptions { * WARNING, ERROR, CRITICAL) * %(levelname)s Text logging level for the message ("DEBUG", "INFO", * "WARNING", "ERROR", "CRITICAL") - * %(pathname)s Full pathname of the source file where the logging - * call was issued (if available) - * %(filename)s Filename portion of pathname - * %(module)s Module (name portion of filename) - * %(lineno)d Source line number where the logging call was issued - * (if available) - * %(funcName)s Function name - * %(created)f Time when the LogRecord was created (time.time_ns() / 1e9 + * %(created)f Time when the LogRecord was created (Date.now() * return value) * %(asctime)s Textual time when the LogRecord was created - * %(msecs)d Millisecond portion of the creation time - * %(relativeCreated)d Time in milliseconds when the LogRecord was created, - * relative to the time the logging module was loaded - * (typically at application startup time) - * %(thread)d Thread ID (if available) - * %(threadName)s Thread name (if available) - * %(taskName)s Task name (if available) - * %(process)d Process ID (if available) * %(message)s The result of record.getMessage(), computed just as * the record is emitted */ export class Formatter { - public static defaultTimeFormat = '%Y-%M'; - public static defaultMsecFormat = '%s,%30d'; + public static defaultTimeFormat = 'YYYY-MM-DDTHH:mm:ss'; + public static defaultMsecFormat = '%s.%03d'; - protected style: any; + protected style: PercentFormatterStyle; protected fmt: string; - protected datefmt: any; + protected datefmt: string|undefined; - /** - * Initialize the formatter with specified format strings. - * - * Initialize the formatter either with the specified format string, or a - * default as described above. Allow for specialized date formatting with - * the optional datefmt argument. If datefmt is omitted, you get an - * ISO8601-like (or RFC 3339-like) format. - * - * Use a style parameter of '%', '{' or '$' to specify that you want to - * use one of %-formatting, :meth:`str.format` (``{}``) formatting or - * :class:`string.Template` formatting in your format string. - */ constructor(options?: FormatterOptions) { options = options ?? {}; - var style = options.style ?? '%'; - var validate = options.validate ?? true; + const style = options.style ?? '%'; + const validate = options.validate ?? true; - if (!Object.keys(STYLES).includes(style ?? '')) { + if (!Object.keys(STYLES).includes(style)) { throw new ValueError(`style must be one of: ${Object.keys(STYLES).join(', ')}`) } @@ -157,23 +157,10 @@ export class Formatter { this.datefmt = options.datefmt; } - /** - * Return the creation time of the specified LogRecord as formatted text. - * - * This method should be called from format() by a formatter which - * wants to make use of a formatted time. This method can be overridden - * in formatters to provide for any specific requirement, but the - * basic behaviour is as follows: if datefmt (a string) is specified, - * it is used with time.strftime() to format the creation time of the - * record. Otherwise, an ISO8601-like (or RFC 3339-like) format is used. - * The resulting string is returned. This function uses a user-configurable - * function to convert the creation time to a tuple. By default, - * time.localtime() is used; to change this for a particular formatter - * instance, set the 'converter' attribute to a function with the same - * signature as time.localtime() or time.gmtime(). To change it for all - * formatters, for example if you want all logging times to be shown in GMT, - * set the 'converter' attribute in the Formatter class. - */ + usesTime(): boolean { + return this.style.usesTime(); + } + /** * Format the specified record as text. * @@ -185,31 +172,45 @@ export class Formatter { * the event time. */ format(record: LogRecord): string { + record.message = record.getMessage(); + + if (this.usesTime()) { + record.asctime = this.formatTime(record, this.datefmt); + } + return this.style.format(record); } - formatTime(record: LogRecord, datefmt?: any): string { + /** + * Return the creation time of the specified LogRecord as formatted text. + * + * If datefmt is specified, it is used as a strftime-style format string. + * Supported tokens: %Y, %m, %d, %H, %M, %S. + * Otherwise, an ISO8601-like format is used. + */ + formatTime(record: LogRecord, datefmt?: string): string { + const dt = new Date(record.created); - //TODO: record.created if (datefmt) { - //TODO: time.strftime - } - else { - //TODO: time.strftime + return datefmt + .replace('%Y', String(dt.getFullYear())) + .replace('%m', String(dt.getMonth() + 1).padStart(2, '0')) + .replace('%d', String(dt.getDate()).padStart(2, '0')) + .replace('%H', String(dt.getHours()).padStart(2, '0')) + .replace('%M', String(dt.getMinutes()).padStart(2, '0')) + .replace('%S', String(dt.getSeconds()).padStart(2, '0')); } - return 'some time'; + const iso = dt.toISOString(); + return iso.replace('T', ' ').replace('Z', ''); } /** * Format and return the specified exception information as a string. - - * This default implementation just uses - * traceback.print_exception() */ formatError(ei: MyError): string { - //TODO - return 'some error'; + if (ei.stack) { return ei.stack } + return String(ei); } } From 3422cfb7998b6df04b64f4ba82a658212c76c440 Mon Sep 17 00:00:00 2001 From: Tiara Rodney Date: Fri, 13 Mar 2026 23:22:10 +0100 Subject: [PATCH 3/4] test(formatter): add tests for formatting, formatTime, formatError Cover %-style substitution with %(name)s, %(levelno)d, %(levelname)s, multiple placeholders, unknown field errors, asctime population, ISO8601 and custom datefmt time formatting, Error.stack formatting, usesTime detection, and LogRecord.getMessage with %s arg substitution. --- tests/unit/formatter.test.ts | 143 +++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/unit/formatter.test.ts diff --git a/tests/unit/formatter.test.ts b/tests/unit/formatter.test.ts new file mode 100644 index 0000000..c105629 --- /dev/null +++ b/tests/unit/formatter.test.ts @@ -0,0 +1,143 @@ +import {expect, jest, test} from '@jest/globals'; +import { Formatter, DEFAULT_FORMATTER } from '../../src/formatter'; +import { LogRecord } from '../../src/log-record'; +import { DEBUG, WARNING, INFO } from '../../src/log-level'; +import { MyError } from '../../src/helper/error'; + +function makeRecord(level: number, msg: string): LogRecord { + return new LogRecord('test.module', { level, msg }); +} + +describe('Formatter', () => { + describe('constructor', () => { + test('uses default format when no options given', () => { + const fmt = new Formatter(); + const record = makeRecord(WARNING, 'hello'); + const result = fmt.format(record); + expect(result).toBe('hello'); + }); + + test('accepts custom format string', () => { + const fmt = new Formatter({ fmt: '%(levelname)s - %(message)s' }); + const record = makeRecord(DEBUG, 'debug msg'); + expect(fmt.format(record)).toBe('DEBUG - debug msg'); + }); + + test('throws on invalid style', () => { + expect(() => new Formatter({ style: '{' })).toThrow('style must be one of'); + }); + }); + + describe('format', () => { + test('substitutes %(name)s with logger scope', () => { + const fmt = new Formatter({ fmt: '[%(name)s] %(message)s' }); + const record = makeRecord(INFO, 'test'); + expect(fmt.format(record)).toBe('[test.module] test'); + }); + + test('substitutes %(levelno)d with numeric level', () => { + const fmt = new Formatter({ fmt: '%(levelno)d: %(message)s' }); + const record = makeRecord(WARNING, 'warn'); + expect(fmt.format(record)).toBe('30: warn'); + }); + + test('substitutes %(levelname)s with level name', () => { + const fmt = new Formatter({ fmt: '%(levelname)s %(message)s' }); + const record = makeRecord(DEBUG, 'msg'); + expect(fmt.format(record)).toBe('DEBUG msg'); + }); + + test('handles multiple placeholders', () => { + const fmt = new Formatter({ + fmt: '%(levelname)s:%(name)s:%(levelno)d:%(message)s' + }); + const record = makeRecord(WARNING, 'multi'); + expect(fmt.format(record)).toBe('WARNING:test.module:30:multi'); + }); + + test('throws on unknown field', () => { + const fmt = new Formatter({ fmt: '%(nonexistent)s' }); + const record = makeRecord(DEBUG, 'test'); + expect(() => fmt.format(record)).toThrow('formatting field not found'); + }); + + test('populates asctime when format uses it', () => { + const fmt = new Formatter({ + fmt: '%(asctime)s %(message)s' + }); + const record = makeRecord(INFO, 'timed'); + const result = fmt.format(record); + expect(result).toContain('timed'); + expect(record.asctime.length).toBeGreaterThan(0); + }); + + test('does not populate asctime when format does not use it', () => { + const fmt = new Formatter({ fmt: '%(message)s' }); + const record = makeRecord(INFO, 'no time'); + fmt.format(record); + expect(record.asctime).toBe(''); + }); + }); + + describe('formatTime', () => { + test('returns ISO8601-like string by default', () => { + const fmt = new Formatter(); + const record = makeRecord(INFO, 'test'); + const result = fmt.formatTime(record); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + }); + + test('uses custom datefmt with strftime tokens', () => { + const fmt = new Formatter(); + const record = makeRecord(INFO, 'test'); + const result = fmt.formatTime(record, '%Y/%m/%d'); + expect(result).toMatch(/^\d{4}\/\d{2}\/\d{2}$/); + }); + }); + + describe('formatError', () => { + test('returns stack trace when available', () => { + const fmt = new Formatter(); + const err = new MyError('test error'); + const result = fmt.formatError(err); + expect(result).toContain('test error'); + expect(result).toContain('\n'); + }); + + test('returns string representation when no stack', () => { + const fmt = new Formatter(); + const err = new MyError('no stack'); + err.stack = undefined; + const result = fmt.formatError(err); + expect(result).toContain('no stack'); + }); + }); + + describe('usesTime', () => { + test('returns true when format contains asctime', () => { + const fmt = new Formatter({ fmt: '%(asctime)s %(message)s' }); + expect(fmt.usesTime()).toBe(true); + }); + + test('returns false when format does not contain asctime', () => { + const fmt = new Formatter({ fmt: '%(message)s' }); + expect(fmt.usesTime()).toBe(false); + }); + }); +}); + +describe('LogRecord.getMessage', () => { + test('returns the message string', () => { + const record = makeRecord(INFO, 'plain message'); + expect(record.getMessage()).toBe('plain message'); + }); + + test('substitutes %s args into message', () => { + const record = new LogRecord('test', { + level: INFO, + msg: 'hello %s, you have %s items', + args: ['world', '5'], + }); + expect(record.getMessage()).toBe('hello world, you have 5 items'); + }); +}); From 67803747c4480a44d997c268545a7e81996b5d5e Mon Sep 17 00:00:00 2001 From: Tiara Rodney Date: Fri, 13 Mar 2026 23:26:39 +0100 Subject: [PATCH 4/4] todo(7): done Implemented %-style field substitution in PercentFormatterStyle, JS Date-based formatTime with strftime token support and ISO8601 fallback, Error.stack-based formatError, and LogRecord.getMessage. Added message, name, msg, args fields to LogRecord. --- TODO | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO b/TODO index c8e1e43..490168d 100644 --- a/TODO +++ b/TODO @@ -102,7 +102,7 @@ Content-Type: application/issue ID: 7 Type: feature Title: implement Formatter subsystem -Status: in-progress +Status: done Priority: high Created: 2026-03-13 Relationships: dependsOn:1