Merge branch 'feature/7' into dev
This commit is contained in:
commit
1cab520ba1
5 changed files with 240 additions and 77 deletions
2
TODO
2
TODO
|
|
@ -102,7 +102,7 @@ Content-Type: application/issue
|
||||||
ID: 7
|
ID: 7
|
||||||
Type: feature
|
Type: feature
|
||||||
Title: implement Formatter subsystem
|
Title: implement Formatter subsystem
|
||||||
Status: in-progress
|
Status: done
|
||||||
Priority: high
|
Priority: high
|
||||||
Created: 2026-03-13
|
Created: 2026-03-13
|
||||||
Relationships: dependsOn:1
|
Relationships: dependsOn:1
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export function basicConfig(options: BasicConfigOptions) {
|
||||||
const filename = options.filename ?? null;
|
const filename = options.filename ?? null;
|
||||||
const stream = options.stream ?? null;
|
const stream = options.stream ?? null;
|
||||||
const filemode = options.filemode ?? 'a';
|
const filemode = options.filemode ?? 'a';
|
||||||
const dateformat = options.datefmt ?? null;
|
const dateformat = options.datefmt;
|
||||||
const style = options.style ?? '%';
|
const style = options.style ?? '%';
|
||||||
const level = options.level ?? null;
|
const level = options.level ?? null;
|
||||||
|
|
||||||
|
|
|
||||||
149
src/formatter.ts
149
src/formatter.ts
|
|
@ -10,6 +10,12 @@ export interface PercentFormatterStyleOptions {
|
||||||
defaults: {[key: string]: any};
|
defaults: {[key: string]: any};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* %-style formatting for log records.
|
||||||
|
*
|
||||||
|
* Substitutes %(name)s, %(name)d, %(name)f placeholders from record
|
||||||
|
* attributes.
|
||||||
|
*/
|
||||||
class PercentFormatterStyle {
|
class PercentFormatterStyle {
|
||||||
public static defaultFormat = '%(message)s';
|
public static defaultFormat = '%(message)s';
|
||||||
public static asctimeFormat = '%(asctime)s';
|
public static asctimeFormat = '%(asctime)s';
|
||||||
|
|
@ -17,7 +23,7 @@ class PercentFormatterStyle {
|
||||||
public static validationPattern =
|
public static validationPattern =
|
||||||
/%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]/;
|
/%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]/;
|
||||||
|
|
||||||
private fmt: string;
|
public fmt: string;
|
||||||
private defaults: {[key: string]: any};
|
private defaults: {[key: string]: any};
|
||||||
|
|
||||||
constructor(options: PercentFormatterStyleOptions) {
|
constructor(options: PercentFormatterStyleOptions) {
|
||||||
|
|
@ -26,7 +32,7 @@ class PercentFormatterStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
usesTime(): boolean {
|
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 {
|
protected _format(record: LogRecord): string {
|
||||||
var defaults = this.defaults;
|
const values: {[key: string]: any} = {
|
||||||
var values: {[key: string]: any}|null;
|
...this.defaults,
|
||||||
if (defaults) { values = {...this.defaults, ...Object.entries(record)} }
|
...(record as unknown as {[key: string]: any}),
|
||||||
else { values = Object.entries(record) }
|
};
|
||||||
//TODO: implement formatting
|
|
||||||
return 'would do some formatting';
|
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 {
|
format(record: LogRecord): string {
|
||||||
|
|
@ -55,12 +81,13 @@ class PercentFormatterStyle {
|
||||||
return this._format(record)
|
return this._format(record)
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
if (e instanceof ValueError) { throw e }
|
||||||
throw new ValueError(`formatting field not found in record: ${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]} = {
|
export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = {
|
||||||
'%': [PercentFormatterStyle, BASIC_FORMAT],
|
'%': [PercentFormatterStyle, BASIC_FORMAT],
|
||||||
|
|
@ -68,7 +95,7 @@ export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOption
|
||||||
|
|
||||||
export interface FormatterOptions {
|
export interface FormatterOptions {
|
||||||
fmt?: string
|
fmt?: string
|
||||||
datefmt?: any
|
datefmt?: string
|
||||||
style?: string
|
style?: string
|
||||||
validate?: boolean
|
validate?: boolean
|
||||||
defaults?: {[key: string]: any}
|
defaults?: {[key: string]: any}
|
||||||
|
|
@ -95,53 +122,26 @@ export interface FormatterOptions {
|
||||||
* WARNING, ERROR, CRITICAL)
|
* WARNING, ERROR, CRITICAL)
|
||||||
* %(levelname)s Text logging level for the message ("DEBUG", "INFO",
|
* %(levelname)s Text logging level for the message ("DEBUG", "INFO",
|
||||||
* "WARNING", "ERROR", "CRITICAL")
|
* "WARNING", "ERROR", "CRITICAL")
|
||||||
* %(pathname)s Full pathname of the source file where the logging
|
* %(created)f Time when the LogRecord was created (Date.now()
|
||||||
* 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
|
|
||||||
* return value)
|
* return value)
|
||||||
* %(asctime)s Textual time when the LogRecord was created
|
* %(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
|
* %(message)s The result of record.getMessage(), computed just as
|
||||||
* the record is emitted
|
* the record is emitted
|
||||||
*/
|
*/
|
||||||
export class Formatter {
|
export class Formatter {
|
||||||
public static defaultTimeFormat = '%Y-%M';
|
public static defaultTimeFormat = 'YYYY-MM-DDTHH:mm:ss';
|
||||||
public static defaultMsecFormat = '%s,%30d';
|
public static defaultMsecFormat = '%s.%03d';
|
||||||
|
|
||||||
protected style: any;
|
protected style: PercentFormatterStyle;
|
||||||
protected fmt: string;
|
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) {
|
constructor(options?: FormatterOptions) {
|
||||||
options = options ?? {};
|
options = options ?? {};
|
||||||
var style = options.style ?? '%';
|
const style = options.style ?? '%';
|
||||||
var validate = options.validate ?? true;
|
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(', ')}`)
|
throw new ValueError(`style must be one of: ${Object.keys(STYLES).join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,23 +157,10 @@ export class Formatter {
|
||||||
this.datefmt = options.datefmt;
|
this.datefmt = options.datefmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
usesTime(): boolean {
|
||||||
* Return the creation time of the specified LogRecord as formatted text.
|
return this.style.usesTime();
|
||||||
*
|
}
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Format the specified record as text.
|
* Format the specified record as text.
|
||||||
*
|
*
|
||||||
|
|
@ -185,31 +172,45 @@ export class Formatter {
|
||||||
* the event time.
|
* the event time.
|
||||||
*/
|
*/
|
||||||
format(record: LogRecord): string {
|
format(record: LogRecord): string {
|
||||||
|
record.message = record.getMessage();
|
||||||
|
|
||||||
|
if (this.usesTime()) {
|
||||||
|
record.asctime = this.formatTime(record, this.datefmt);
|
||||||
|
}
|
||||||
|
|
||||||
return this.style.format(record);
|
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) {
|
if (datefmt) {
|
||||||
//TODO: time.strftime
|
return datefmt
|
||||||
}
|
.replace('%Y', String(dt.getFullYear()))
|
||||||
else {
|
.replace('%m', String(dt.getMonth() + 1).padStart(2, '0'))
|
||||||
//TODO: time.strftime
|
.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.
|
* Format and return the specified exception information as a string.
|
||||||
|
|
||||||
* This default implementation just uses
|
|
||||||
* traceback.print_exception()
|
|
||||||
*/
|
*/
|
||||||
formatError(ei: MyError): string {
|
formatError(ei: MyError): string {
|
||||||
//TODO
|
if (ei.stack) { return ei.stack }
|
||||||
return 'some error';
|
return String(ei);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,32 @@ export class LogRecord {
|
||||||
public readonly levelno: LogLevel;
|
public readonly levelno: LogLevel;
|
||||||
public readonly levelname: string|LogLevel;
|
public readonly levelname: string|LogLevel;
|
||||||
public readonly scope: string;
|
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 readonly created: MillisecondsSinceUnixEpoch = Date.now();
|
||||||
|
public asctime: string = '';
|
||||||
|
|
||||||
constructor(scope: string, options: LogRecordOptions) {
|
constructor(scope: string, options: LogRecordOptions) {
|
||||||
this.levelno = options.level;
|
this.levelno = options.level;
|
||||||
this.levelname = getLevelName(options.level);
|
this.levelname = getLevelName(options.level);
|
||||||
this.scope = scope;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
143
tests/unit/formatter.test.ts
Normal file
143
tests/unit/formatter.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue