From 5a05a41e5969016d447b9c243c41755dc1b9229e Mon Sep 17 00:00:00 2001 From: "Rodney, Tiara" Date: Fri, 25 Apr 2025 22:42:55 +0200 Subject: [PATCH] refactor(src): introduce submodules --- src/config.ts | 202 +++++++ src/filter.ts | 114 ++++ src/formatter.ts | 202 +++++++ src/handler.ts | 199 +++++++ src/index.ts | 1317 +-------------------------------------------- src/log-level.ts | 118 ++++ src/log-record.ts | 77 +++ src/logger.ts | 324 +++++++++++ src/manager.ts | 127 +++++ tests/test.ts | 52 +- 10 files changed, 1400 insertions(+), 1332 deletions(-) create mode 100644 src/config.ts create mode 100644 src/filter.ts create mode 100644 src/formatter.ts create mode 100644 src/handler.ts create mode 100644 src/log-level.ts create mode 100644 src/log-record.ts create mode 100644 src/logger.ts create mode 100644 src/manager.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..594d01d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,202 @@ + +import { MANAGER } from './manager'; +import { ValueError } from './helper/error'; +import { STYLES, Formatter } from './formatter'; +import { StreamHandler, FileHandler, Handler } from './handler'; +import { LogLevel } from './log-level'; + +//--------------------------------------------------------------------------- +// Configuration classes and functions +//--------------------------------------------------------------------------- + +/** + * options for basic configuration of logging module + */ +export interface BasicConfigOptions { + /* + * Specifies that a FileHandler be created, using the specified filename, + * rather than a StreamHandler. + */ + filename?: string; + + /** + * Specifies the mode to open the file, if filename is specified (if + * filemode is unspecified, it defaults to 'a') + */ + filemode?: string; + + /** + * Use the specified format string for the handler. + */ + format?: string; + + /** + * Use the specified date/time format. + * + */ + datefmt?: string; + + /** + * If a format string is specified, use this to specify the type of format + * string (possible values '%', '{', '$', for %-formatting, + * :meth:`str.format` and :class:`string.Template`- defaults to '%'). + * + * TODO: switch to enum + */ + style?: string; + + /** + * Set the root logger level to the specified level. + */ + level?: LogLevel; + + /** + * Use the specified stream to initialize the StreamHandler. Note that this + * argument is incompatible with 'filename' - if both are present, 'stream' + * is ignored. + * + * TODO: + */ + stream?: any; + + /** + * If specified, this should be an iterable of already created handlers, + * which will be added to the root logger. Any handler in the list which + * does not have a formatter assigned will be assigned the formatter created + * in this function. + */ + handlers?: Handler[]; + + /** + * If this keyword is specified as true, any existing handlers attached to + * the root logger are removed and closed, before carrying out the + * configuration as specified by the other arguments. + */ + force?: boolean; + + /** + * If specified together with a filename, this encoding is passed to the + * created FileHandler, causing it to be used when the file is opened. + */ + encoding?: string; + + /** + * If specified together with a filename, this value is + * passed to the created FileHandler, causing it to be used + * when the file is opened in text mode. If not specified, + * the default value is `backslashreplace`. + */ + errors?: string|null; +} + +/** + * Do basic configuration for the logging system. + * + * This function does nothing if the root logger already has handlers + * configured, unless the keyword argument *force* is set to ``True``. + * It is a convenience method intended for use by simple scripts + * to do one-shot configuration of the logging package. + * + * The default behaviour is to create a StreamHandler which writes to + * sys.stderr, set a formatter using the BASIC_FORMAT format string, and + * add the handler to the root logger. + * + * A number of optional keyword arguments may be specified, which can alter + * the default behaviour. + * + * Note that you could specify a stream created using open(filename, mode) + * rather than passing the filename and mode in. However, it should be + * remembered that StreamHandler does not close its stream (since it may be + * using sys.stdout or sys.stderr), whereas FileHandler closes its stream + * when the handler is closed. + * + * TODO: refactor logic, there apparently is some redundancy in the original + * code + */ +export function basicConfig(options: BasicConfigOptions) { + const force = options.force ?? false; + var encoding = options.encoding ?? undefined; + var errors: string|undefined = options.errors ?? 'backslashreplace'; + var handlers = options.handlers ?? []; + const filename = options.filename ?? null; + const stream = options.stream ?? null; + const filemode = options.filemode ?? 'a'; + const dateformat = options.filemode ?? null; + const style = options.filemode ?? '%'; + const level = options.level ?? null; + + if (!Object.keys(STYLES).includes(style)) { + throw new ValueError( + `style must be one of: ${Object.keys(STYLES).join(', ')}` + ); + } + + if (force) { + for (var i = 0; i < MANAGER.root.handlers.length; i += 1) { + let h: Handler = MANAGER.root.handlers[i]; + MANAGER.root.removeHandler(h); + h.close(); + } + } + + if (handlers.length == 0) { + if (handlers === null && stream && filename) { + throw new ValueError( + "'stream' and 'filename' should not be specified together" + ); + } + + else if (stream || filename) { + throw new ValueError( + "'stream' or 'filename' should not be specified together" + + "with 'handlers'" + ); + } + + if (handlers === null) { + var h: Handler; + + if (filename) { + if (filemode.match('b')) { errors = undefined } + else { encoding = 'utf-8' } + + h = new FileHandler({ + filename: filename, + filemode: filemode, + 'encoding': encoding, + errors: errors + }); + } + + else { h = new StreamHandler(stream) } + + handlers = [h]; + } + + for (var i = 0; i < handlers.length; i += 1) { + let h = handlers[i]; + + if (h.formatter === null) { + h.formatter = new Formatter({ + fmt: options.format ?? STYLES[style][1], + datefmt: dateformat, + style: style + }); + } + + MANAGER.root.addHandler(h); + } + + if (level !== null) { MANAGER.root.setLevel(level) } + + if (options) { + // runtime interface guard, please let me stay. 🥺 + // the interface does not allow for additional members, but the + // runtime environment has no concept of interfaces. We can stick to + // the original implementation + const keys = Object.keys(options).join(', '); + + throw new ValueError(`Unrecognised argument(s): ${keys}`); + } + } +} diff --git a/src/filter.ts b/src/filter.ts new file mode 100644 index 0000000..a6c26b2 --- /dev/null +++ b/src/filter.ts @@ -0,0 +1,114 @@ +import { LogRecord } from './log-record'; + +//--------------------------------------------------------------------------- +// Filter classes and functions +//--------------------------------------------------------------------------- + +export type FilterCallable = (record: LogRecord) => boolean; + +/** + * Filter instances are used to perform arbitrary filtering of LogRecords. + * + * Loggers and Handlers can optionally use Filter instances to filter records as + * desired. The base filter class only allows events which are below a certain + * point in the logger hierarchy. For example, a filter initialized with "A.B" + * will allow events logged by loggers "A.B", initialized with the empty string, + * all events are passed. + */ +export class Filter { + public readonly scope: string; + public readonly slen: number; + + /** + * Initialize with the name of the logger which ,together with its children, + * will have its events allowed through the filter. If no name is specified, + * allow every event. + * + * @param name - name of logging scope + */ + constructor(scope: string) { + this.scope = scope ?? ''; + this.slen = this.scope.length; + } + + /** + * Inspect a record, if it should be logged. + * + * Returns true if the record should be logged, or false otherwise. If + * deemed appropriate, the record may be modified in-place. + * + * @param - scope of log record to inspect + * @param - log record to inspect + */ + filter(record: LogRecord): boolean { + if (this.slen == 0 || this.scope == record.scope) { return true } + else if (!record.scope.substring(0, this.slen)) { return false } + return (record.scope[this.slen] == '.') + } +} + +export class Filterer { + filters: Filter[] = []; + + /** + * Add the specified filter to this handler. + * + * @param filter + */ + addFilter(filter: Filter) { + if (!this.filters.includes(filter)) { this.filters.push(filter) } + } + + /** + * Remove the specified filter from this handler. + * + * @param filter + */ + removeFilter(filter: Filter) { + if (this.filters.includes(filter)) { + this.filters.splice(this.filters.indexOf(filter), 1) + } + } + + /** + * Determine if a record is loggable by consulting all the filters. + * + * The default is to allow the record to be logged; any filter can veto this + * by returning a false value. + * If a filter attached to a handler returns a log record instance, then + * that instance is used in place of the original log record in any further + * processing of the event by that handler. + * If a filter returns any other true value, the original log record is used + * in any further processing of the event by that handler. + * + * If none of the filters return false values, this method returns a log + * record. + * + * If any of the filters return a false value, this method returns a false + * value. + * + * @param filter + */ + filter(record: LogRecord): LogRecord|null { + + for (var i = 0; i < this.filters.length; i += 1) { + let result: boolean|LogRecord = false; + + let filter = this.filters[i]; + + if (typeof (filter as Filter).filter == 'function') { + result = (filter as Filter).filter(record) + } + else { + result = (filter as unknown as FilterCallable)(record) + } + + if (!result) { return null } + + if ((result as any) instanceof LogRecord) { record = result as unknown as LogRecord } + } + + return record + } +} + diff --git a/src/formatter.ts b/src/formatter.ts new file mode 100644 index 0000000..7e0197c --- /dev/null +++ b/src/formatter.ts @@ -0,0 +1,202 @@ +import { MyError, ValueError } from './helper/error'; +import { LogRecord } from './log-record'; + +//--------------------------------------------------------------------------- +// Formatter classes and functions +//--------------------------------------------------------------------------- + +export interface PercentFormatterStyleOptions { + fmt?: string, + defaults: {[key: string]: any}; +} + +class PercentFormatterStyle { + public static defaultFormat = '%(message)s'; + public static asctimeFormat = '%(asctime)s'; + public static asctimeSearch = '%(asctime)'; + public static validationPattern = + /%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]/; + + private fmt: string; + private defaults: {[key: string]: any}; + + constructor(options: PercentFormatterStyleOptions) { + this.fmt = options.fmt ?? PercentFormatterStyle.defaultFormat; + this.defaults = options.defaults; + } + + usesTime(): boolean { + return this.fmt.match(PercentFormatterStyle.asctimeFormat) ? true : false + } + + /** + * Validate the input format, ensure it matches the correct style + */ + validate() { + if (!PercentFormatterStyle.validationPattern.test(this.fmt)) { + throw new ValueError( + `Invalid format '${this.fmt}' for ` + + `'${PercentFormatterStyle.defaultFormat[0]}'` + ) + } + } + + 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'; + } + + format(record: LogRecord): string { + try { + return this._format(record) + } + catch (e) { + throw new ValueError(`formatting field not found in record: ${e}`) + } + } +} + +const BASIC_FORMAT = '%(level)s:%(name)s:%(message)s'; + +export const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = { + '%': [PercentFormatterStyle, BASIC_FORMAT], +} + +export interface FormatterOptions { + fmt?: string + datefmt?: any + style?: string + validate?: boolean + defaults?: {[key: string]: any} +} + +/** + * Formatter instances are used to convert a LogRecord to text. + * + * Formatters need to know how a LogRecord is constructed. They are + * responsible for converting a LogRecord to (usually) a string which can + * be interpreted by either a human or an external system. The base Formatter + * allows a formatting string to be specified. If none is supplied, the + * style-dependent default value, "%(message)s", "{message}", or + * "${message}", is used. + * + * The Formatter can be initialized with a format string which makes use of + * knowledge of the LogRecord attributes - e.g. the default value mentioned + * above makes use of the fact that the user's message and arguments are pre- + * formatted into a LogRecord's message attribute. Currently, the useful + * attributes in a LogRecord are described by: + * + * %(name)s Name of the logger (logging channel) + * %(levelno)s Numeric logging level for the message (DEBUG, INFO, + * 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 + * 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'; + + protected style: any; + protected fmt: string; + protected datefmt: any; + + /** + * 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; + + if (!Object.keys(STYLES).includes(style ?? '')) { + throw new ValueError(`style must be one of: ${Object.keys(STYLES).join(', ')}`) + } + + this.style = new STYLES[style][0]({ + fmt: options.fmt, + defaults: options.defaults ?? {} + }); + + if (validate) { this.style.validate() } + + this.fmt = this.style.fmt; + + 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. + */ + formatTime(record: LogRecord, datefmt?: any): string { + + //TODO: record.created + if (datefmt) { + //TODO: time.strftime + } + else { + //TODO: time.strftime + } + + return 'some time'; + } + + /** + * 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'; + } +} + +export const DEFAULT_FORMATTER = new Formatter(); diff --git a/src/handler.ts b/src/handler.ts new file mode 100644 index 0000000..c75db06 --- /dev/null +++ b/src/handler.ts @@ -0,0 +1,199 @@ +import * as stream from 'stream'; + +import { LogLevel, checkLevel, NOTSET } 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'); +} + +//--------------------------------------------------------------------------- +// Handler classes and functions +//---------------------------------------------------------------------------- + +type Handlers = {[key: string]: Handler}; + +/** + * map of handler names to handlers + */ +const HANDLERS: Handlers = {}; + +/** + * added to allow handlers to be removed in reverse order of initialization + */ +const HANDLER_LIST: WeakRef[] = []; + +/** + * Add a handler to the internal cleanup list using a weak reference. + * + * @param handler - + */ +function addHandlerRef(handler: Handler) { + HANDLER_LIST.push(new WeakRef(handler)); +} + +/** + * Get a handler with the specified *name*, or None if there isn't one with + * that name. + */ +export function getHandlerByName(name: string): Handler|null { + return HANDLERS[name] ?? null +} + +/** + * Return all known handler names as an immutable set + */ +export function getHandlerNames(): Handlers { return Object.freeze(HANDLERS) } + +/** + * Handler instances dispatch logging events to specific destinations. + * + * The base handler class. Acts as a placeholder which defines the Handler + * interface. Handlers can optionally use Formatter instances to format + * records as desired. By default, no formatter is specified; in this case, + * the 'raw' message as determined by record.message is logged. + */ +export class Handler extends Filterer { + + protected _scope: string|null = null; + protected _formatter: Formatter|null = null; + protected _level: number; + protected _closed: boolean = false; + + /** + * Initializes the instance - basically setting the formatter to None + * and the filter list to empty + */ + constructor(level?: LogLevel) { + super(); + this._level = checkLevel(level ?? NOTSET); + // Add the handler to the global HANDLER_LIST (for cleanup on shutdown) + addHandlerRef(this); + } + + get level(): number { return this._level } + set level(level: LogLevel|string) { this.level = checkLevel(level) } + + get scope(): string|null { return this._scope } + set scope(scope: string) { this._scope = scope } + get closed(): boolean { return this._closed } + + /** + * Format the specified record. + * + * If a formatter is set, use it. Otherwise, use the default formatter for + * the module. + */ + format(record: LogRecord) { + var fmt: Formatter|null = null; + + if (this.formatter) { fmt = this.formatter } + else { fmt = DEFAULT_FORMATTER } + } + + /** + * Do whatever it takes to actually log the specified logging record. + * + * This version is intended to be implemented by subclasses and so raises a + * NotImplementedError. + */ + emit(record: LogRecord) { + throw new NotImplementedError( + 'emit must be implemented by Handler subclass' + ) + } + + /** + * Conditionally emit the specfied logging record. + * + * Emission depends on filters which may have been added to the handler. + * Wrap the actual emission of the record with acquisition/release of the + * I/O thread lock. + */ + handle(record: LogRecord) { + var rv = this.filter(record); + if ((rv as any) instanceof LogRecord) { + record = rv as unknown as LogRecord + } + if (rv) { + //locking here + this.emit(record) + } + } + + /** + * Tidy up any resources used by the handler + * + * This version removes the handler from an internal map of handlers, which + * is used for handler lookup by scope. Subclasses should ensure that this + * gets called from overriden close() methods. + */ + close() { + this._closed = true; + + if (this.scope && Object.keys(HANDLERS).includes(this.scope)) { + delete HANDLERS[this.scope] + } + } + + /** + * 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. + */ + handleError(record: LogRecord) { + throw new NotImplementedError( + 'still need to find portable way for stacktracing...' + ) + } + + set formatter(fmt: Formatter) { this._formatter = fmt } +} + +export interface FileHandlerOptions { + filename: string + filemode?: string + encoding?: string + 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(); + } +} + +/** + * This class is like a StreamHandler using sys.stderr, but always uses + * 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. + */ + constructor(level: LogLevel) { super(level) } +} diff --git a/src/index.ts b/src/index.ts index e5f0039..c35e33c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,1321 +87,26 @@ * * @module logging */ -import { - NotImplementedError, - MyError, - ValueError, - KeyError, - StackTrace -} from './helper/error'; -import { MillisecondsSinceUnixEpoch } from './helper/datetime'; -import * as stream from 'stream'; -if (typeof window === 'undefined') { - const stream = require('stream'); -} -else { - const stream = require('./helper/stream'); -} +export * as config from './config'; +export * as filter from './filter'; +export * as formatter from './formatter'; +export * as handler from './handler'; +// screw community conventions, whoever came up with the idea of aliasing +// imports in pascal case, or camel case doesn't seem to care about naming +// collisions. I'm sticking to kebab case as this avoids naming collisions. +export * as log_level from './log-level'; +export * as log_record from './log-record'; +export * as logger from './logger'; +export * as manager from './manager'; -/*--------------------------------------------------------------------------- - Level related stuff - --------------------------------------------------------------------------- - Default levels and level names, these can be replaced with any positive set - of values having corresponding names. There is a pseudo-level, NOTSET, which - is only really there as a lower limit for user-defined levels. Handlers and - loggers are initialized with NOTSET so that they will log all messages, even - at user-defined levels. -*/ -export type LogLevel = number; -/** - * An indication that something unexpected happened, or that a problem might - * occur in the near future (e.g. ‘disk space low’). The software is still - * working as expected. - */ -export const CRITICAL = 50; -export const FATAL = CRITICAL; -/** - * Due to a more serious problem, the software has not been able to perform some - * function. - */ -export const ERROR = 40; -/** - * An indication that something unexpected happened, or that a problem might - * occur in the near future (e.g. ‘disk space low’). The software is still - * working as expected. - */ -export const WARNING = 30; -export const WARN = WARNING; -/** - * Confirmation that things are working as expected. - */ -export const INFO = 20; -/** - * Detailed information, typically only of interest to a developer trying to - * diagnose a problem. - */ -export const DEBUG = 10; -/** - * When set on a logger, indicates that ancestor loggers are to be consulted to - * determine the effective level. If that still resolves to NOTSET, then all - * events are logged. When set on a handler, all events are handled. - */ -export const NOTSET = 0; -const LEVELTONAME: {[key: number]: string} = { - [CRITICAL]: 'CRITICAL', - [ERROR]: 'ERROR', - [WARNING]: 'WARNING', - [INFO]: 'INFO', - [DEBUG]: 'DEBUG', - [NOTSET]: 'NOTSET' -} -const NAMETOLEVEL: {[key: string]: number} = { - CRITICAL: CRITICAL, - ERROR: ERROR, - WARNING: WARNING, - INFO: INFO, - DEBUG: DEBUG, - NOTSET: NOTSET, -} - -function getLevelNamesMapping() { - return Object.assign({}, NAMETOLEVEL); -} - -/** - * Return the textual or numeric representation of logging level 'level' - * - * @param level - */ -export function getLevelName(level: string|number): string|number { - var result: string|number = LEVELTONAME[level as number]; - if (result !== undefined) { return result } - result = NAMETOLEVEL[level as string]; - if (result !== undefined) { return result } - return `Level ${level}`; -} - -/** - * Associate 'levelName' with 'level' - * - * @param level - * @param levelName - */ -export function addLevelName(level: number, levelName: string) { - LEVELTONAME[level] = levelName; - NAMETOLEVEL[levelName] = level; -} - -function checkLevel(level: number|string): number { - var rv: number; - - if (typeof level == 'number') { rv = level } - - else if (typeof level == 'string') { - if (!Object.keys(NAMETOLEVEL).includes(level as string)) { - throw new Error(`Unknown level: ${level}`) - } - - rv = NAMETOLEVEL[level] - } - - else { - throw new Error(`Level not a number or valid string: ${level}`) - } - - return rv -} - -export type ExecutionInfo = [string, Error, StackTrace]; - -//--------------------------------------------------------------------------- -// The logging record -//--------------------------------------------------------------------------- - -/** - * options for instantiating a new log record - */ -export interface LogRecordOptions { - /** - * The numeric level of the logging event (such as 10 for DEBUG, 20 for - * INFO, etc). Note that this is converted to two attributes of the - * LogRecord: levelno for the numeric value and levelname for the - * corresponding level name. - */ - level: number, - file?: string, - /** - * The line number in the source file where the logging call was made. - */ - lno?: number, - /** - * The event description message, which can be a %-format string with - * placeholders for variable data, or an arbitrary object (see Using - * arbitrary objects as messages). - */ - msg: string, - /** - * Variable data to merge into the msg argument to obtain the event - * description. - */ - args?: any[], -} - -export type LogRecordFactory = { (name: string, options: LogRecordOptions): LogRecord }; - -/** - * LogRecord instances are created every time something is logged. They contain - * all the information pertinent to the event being logged. The main - * information parssed in is msg and args, which are combined using str(msg) % - * args to create the message field of the record. The record also includes - * information such as when the record was created, the source line where the - * logging call was made, and any exception information to be logged. - */ -export class LogRecord { - public readonly levelno: LogLevel; - public readonly levelname: string|LogLevel; - public readonly scope: string; - - public readonly created: MillisecondsSinceUnixEpoch = Date.now(); - - constructor(scope: string, options: LogRecordOptions) { - this.levelno = options.level; - this.levelname = getLevelName(options.level); - this.scope = scope; - } -} - -var logRecordFactory = (scope: string, options: LogRecordOptions) => { - return new LogRecord(scope, options) -}; - -/** - * Define which class use when instantiating log records. - * - * @param factory - A callable which will be called to instantiate a log record. - * Pass a clojure, if your factory is a class already. - */ -export function setLogRecordFactory(factory: LogRecordFactory) { - logRecordFactory = factory -} - -export function getLogRecordFactory(): LogRecordFactory { - return logRecordFactory -} - -//--------------------------------------------------------------------------- -// Formatter classes and functions -//--------------------------------------------------------------------------- - -export interface PercentFormatterStyleOptions { - fmt?: string, - defaults: {[key: string]: any}; -} - -class PercentFormatterStyle { - public static defaultFormat = '%(message)s'; - public static asctimeFormat = '%(asctime)s'; - public static asctimeSearch = '%(asctime)'; - public static validationPattern = - /%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]/; - - private fmt: string; - private defaults: {[key: string]: any}; - - constructor(options: PercentFormatterStyleOptions) { - this.fmt = options.fmt ?? PercentFormatterStyle.defaultFormat; - this.defaults = options.defaults; - } - - usesTime(): boolean { - return this.fmt.match(PercentFormatterStyle.asctimeFormat) ? true : false - } - - /** - * Validate the input format, ensure it matches the correct style - */ - validate() { - if (!PercentFormatterStyle.validationPattern.test(this.fmt)) { - throw new ValueError( - `Invalid format '${this.fmt}' for ` + - `'${PercentFormatterStyle.defaultFormat[0]}'` - ) - } - } - - 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'; - } - - format(record: LogRecord): string { - try { - return this._format(record) - } - catch (e) { - throw new ValueError(`formatting field not found in record: ${e}`) - } - } -} - -const BASIC_FORMAT = '%(level)s:%(name)s:%(message)s'; - -const STYLES: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = { - '%': [PercentFormatterStyle, BASIC_FORMAT], -} - -export interface FormatterOptions { - fmt?: string - datefmt?: any - style?: string - validate?: boolean - defaults?: {[key: string]: any} -} - -/** - * Formatter instances are used to convert a LogRecord to text. - * - * Formatters need to know how a LogRecord is constructed. They are - * responsible for converting a LogRecord to (usually) a string which can - * be interpreted by either a human or an external system. The base Formatter - * allows a formatting string to be specified. If none is supplied, the - * style-dependent default value, "%(message)s", "{message}", or - * "${message}", is used. - * - * The Formatter can be initialized with a format string which makes use of - * knowledge of the LogRecord attributes - e.g. the default value mentioned - * above makes use of the fact that the user's message and arguments are pre- - * formatted into a LogRecord's message attribute. Currently, the useful - * attributes in a LogRecord are described by: - * - * %(name)s Name of the logger (logging channel) - * %(levelno)s Numeric logging level for the message (DEBUG, INFO, - * 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 - * 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'; - - protected style: any; - protected fmt: string; - protected datefmt: any; - - /** - * 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; - - if (!Object.keys(STYLES).includes(style ?? '')) { - throw new ValueError(`style must be one of: ${Object.keys(STYLES).join(', ')}`) - } - - this.style = new STYLES[style][0]({ - fmt: options.fmt, - defaults: options.defaults ?? {} - }); - - if (validate) { this.style.validate() } - - this.fmt = this.style.fmt; - - 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. - */ - formatTime(record: LogRecord, datefmt?: any): string { - - //TODO: record.created - if (datefmt) { - //TODO: time.strftime - } - else { - //TODO: time.strftime - } - - return 'some time'; - } - - /** - * 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'; - } -} - -const DEFAULT_FORMATTER = new Formatter(); - -//--------------------------------------------------------------------------- -// Filter classes and functions -//--------------------------------------------------------------------------- - -export type FilterCallable = (record: LogRecord) => boolean; - -/** - * Filter instances are used to perform arbitrary filtering of LogRecords. - * - * Loggers and Handlers can optionally use Filter instances to filter records as - * desired. The base filter class only allows events which are below a certain - * point in the logger hierarchy. For example, a filter initialized with "A.B" - * will allow events logged by loggers "A.B", initialized with the empty string, - * all events are passed. - */ -export class Filter { - public readonly scope: string; - public readonly slen: number; - - /** - * Initialize with the name of the logger which ,together with its children, - * will have its events allowed through the filter. If no name is specified, - * allow every event. - * - * @param name - name of logging scope - */ - constructor(scope: string) { - this.scope = scope ?? ''; - this.slen = this.scope.length; - } - - /** - * Inspect a record, if it should be logged. - * - * Returns true if the record should be logged, or false otherwise. If - * deemed appropriate, the record may be modified in-place. - * - * @param - scope of log record to inspect - * @param - log record to inspect - */ - filter(record: LogRecord): boolean { - if (this.slen == 0 || this.scope == record.scope) { return true } - else if (!record.scope.substring(0, this.slen)) { return false } - return (record.scope[this.slen] == '.') - } -} - -export class Filterer { - filters: Filter[] = []; - - /** - * Add the specified filter to this handler. - * - * @param filter - */ - addFilter(filter: Filter) { - if (!this.filters.includes(filter)) { this.filters.push(filter) } - } - - /** - * Remove the specified filter from this handler. - * - * @param filter - */ - removeFilter(filter: Filter) { - if (this.filters.includes(filter)) { - this.filters.splice(this.filters.indexOf(filter), 1) - } - } - - /** - * Determine if a record is loggable by consulting all the filters. - * - * The default is to allow the record to be logged; any filter can veto this - * by returning a false value. - * If a filter attached to a handler returns a log record instance, then - * that instance is used in place of the original log record in any further - * processing of the event by that handler. - * If a filter returns any other true value, the original log record is used - * in any further processing of the event by that handler. - * - * If none of the filters return false values, this method returns a log - * record. - * - * If any of the filters return a false value, this method returns a false - * value. - * - * @param filter - */ - filter(record: LogRecord): LogRecord|null { - - for (var i = 0; i < this.filters.length; i += 1) { - let result: boolean|LogRecord = false; - - let filter = this.filters[i]; - - if (typeof (filter as Filter).filter == 'function') { - result = (filter as Filter).filter(record) - } - else { - result = (filter as unknown as FilterCallable)(record) - } - - if (!result) { return null } - - if ((result as any) instanceof LogRecord) { record = result as unknown as LogRecord } - } - - return record - } -} - -var throwErrors: boolean = true; - -//--------------------------------------------------------------------------- -// Handler classes and functions -//---------------------------------------------------------------------------- - -type Handlers = {[key: string]: Handler}; - -/** - * map of handler names to handlers - */ -const HANDLERS: Handlers = {}; - -/** - * added to allow handlers to be removed in reverse order of initialization - */ -const HANDLER_LIST: WeakRef[] = []; - -/** - * Add a handler to the internal cleanup list using a weak reference. - * - * @param handler - - */ -function addHandlerRef(handler: Handler) { - HANDLER_LIST.push(new WeakRef(handler)); -} - -/** - * Get a handler with the specified *name*, or None if there isn't one with - * that name. - */ -export function getHandlerByName(name: string): Handler|null { - return HANDLERS[name] ?? null -} - -/** - * Return all known handler names as an immutable set - */ -export function getHandlerNames(): Handlers { return Object.freeze(HANDLERS) } - -/** - * Handler instances dispatch logging events to specific destinations. - * - * The base handler class. Acts as a placeholder which defines the Handler - * interface. Handlers can optionally use Formatter instances to format - * records as desired. By default, no formatter is specified; in this case, - * the 'raw' message as determined by record.message is logged. - */ -export class Handler extends Filterer { - - protected _scope: string|null = null; - protected _formatter: Formatter|null = null; - protected _level: number; - protected _closed: boolean = false; - - /** - * Initializes the instance - basically setting the formatter to None - * and the filter list to empty - */ - constructor(level?: LogLevel) { - super(); - this._level = checkLevel(level ?? NOTSET); - // Add the handler to the global HANDLER_LIST (for cleanup on shutdown) - addHandlerRef(this); - } - - get level(): number { return this._level } - set level(level: LogLevel|string) { this.level = checkLevel(level) } - - get scope(): string|null { return this._scope } - set scope(scope: string) { this._scope = scope } - get closed(): boolean { return this._closed } - - /** - * Format the specified record. - * - * If a formatter is set, use it. Otherwise, use the default formatter for - * the module. - */ - format(record: LogRecord) { - var fmt: Formatter|null = null; - - if (this.formatter) { fmt = this.formatter } - else { fmt = DEFAULT_FORMATTER } - } - - /** - * Do whatever it takes to actually log the specified logging record. - * - * This version is intended to be implemented by subclasses and so raises a - * NotImplementedError. - */ - emit(record: LogRecord) { - throw new NotImplementedError( - 'emit must be implemented by Handler subclass' - ) - } - - /** - * Conditionally emit the specfied logging record. - * - * Emission depends on filters which may have been added to the handler. - * Wrap the actual emission of the record with acquisition/release of the - * I/O thread lock. - */ - handle(record: LogRecord) { - var rv = this.filter(record); - if ((rv as any) instanceof LogRecord) { - record = rv as unknown as LogRecord - } - if (rv) { - //locking here - this.emit(record) - } - } - - /** - * Tidy up any resources used by the handler - * - * This version removes the handler from an internal map of handlers, which - * is used for handler lookup by scope. Subclasses should ensure that this - * gets called from overriden close() methods. - */ - close() { - this._closed = true; - - if (this.scope && Object.keys(HANDLERS).includes(this.scope)) { - delete HANDLERS[this.scope] - } - } - - /** - * 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. - */ - handleError(record: LogRecord) { - throw new NotImplementedError( - 'still need to find portable way for stacktracing...' - ) - } - - set formatter(fmt: Formatter) { this._formatter = fmt } -} - -export interface FileHandlerOptions { - filename: string - filemode?: string - encoding?: string - 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(); - } -} - -/** - * This class is like a StreamHandler using sys.stderr, but always uses - * 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. - */ - constructor(level: LogLevel) { super(level) } -} - -const DEFAULT_LAST_RESORT = new StderrHandler(WARNING); - -export var lastResort = DEFAULT_LAST_RESORT; - -//--------------------------------------------------------------------------- -// Manager classes and functions -//--------------------------------------------------------------------------- - -/** - * Placeholder instance - */ -export class Placeholder { - protected loggers: Logger[] = []; - - /** - * initialize with the specified logger being a child of this placeholder. - */ - constructor(logger: Logger) { this.push(logger) } - - /** - * add the specified logger as a child of this placeholder - */ - public push(logger: Logger) { - if (!this.loggers.includes(logger)) { this.loggers.push(logger) } - } -} - -/** - * There is [under normal circumstances] just one Manager intance, which holds - * the hierarchy of loggers. - */ -export class Manager { - public readonly root: RootLogger; - protected _disable: number = 0; - public emittedNoHandlerWarning: boolean = false; - protected loggers: {[key: string]: Logger} = {}; - protected _loggerClass: LoggerClass|null = null; - protected _logRecordFactory: LogRecordFactory|null = null; - - public get disable(): number { return this._disable } - - public set disable(level: LogLevel) { this._disable = checkLevel(level) } - - /** - * Initialize the manager with the root node of the logger hierarchy - */ - constructor(root: RootLogger) { - this.root = root; - } - - /** - * Get a logger with the specified name (scope name), creating it, if it - * does not yet exist. This name is a dot-separated hierarchical name, such - * as "a", "a.b", "a.b.c" or similar. - * - * If a PlaceHolder existed for the specified name [i.e. the logger didn't - * exist but a child of it did], replace it with the created logger and fix - * up the parent/child references which pointed to the placeholder to now - * point to the logger. - */ - getLogger(scope: string) { - var rv: null|Logger = null; - - if (typeof scope != 'string') { - - rv = this.loggers[scope]; - - if (rv instanceof Placeholder) { - var ph = rv; - rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET); - } - else { - rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET); - this.loggers[scope] = rv; - } - } - } - - /** - * Set the class to be used when instantiating a logger with this Manager. - */ - set loggerClass(class_: LoggerClass) { - if (class_ !== Logger) { - if (!(class_.prototype instanceof Logger)) { - throw new TypeError("logger not derived from logging.Logger: ") - } - } - - this._loggerClass = class_; - } - - /** - * Set the factory to be used when instantiating a log record with this - * Manager. - */ - set logRecordFactory(factory: LogRecordFactory) { - this._logRecordFactory = factory; - } - - /** - * clear the cache for all loggers in loggerDict - */ - public clear() { - Object.values(this.loggers).forEach((logger) => { - logger.clear() - }); - } -} - -//--------------------------------------------------------------------------- -// Logger classes and functions -//--------------------------------------------------------------------------- - -export type LoggerClass = { new(): Logger }; - -/** - * context of a logging event/trigger - */ -export interface LogOptions{ - /** - * - */ - excInfo: ExecutionInfo|Error|null, - /** - * - */ - extra: {[key: string]: any}|null, - /** - * - */ - stackInfo: boolean, - /** - * - */ - stackLevel: number -} - -const DEFAULT_LOG_OPTIONS: LogOptions = Object.freeze({ - excInfo: null, - extra: null, - stackInfo: false, - stackLevel: 1 -}); - -/** - * Instances of the logger class represent a single logging channel. A 'logging - * channel' indicates an area of an application. Exactly how an 'area' is - * defined is up to the application developer. Since an application can have any - * number of areas, logging channels are identified by a unique string. - * Application areas can be nested (e.g. an area of input process might include - * sub-areas "read CSV file", "read XLS files" and "read Gnumeric files"). To - * cater for this natural nesting, channel ames are organized into a namespace - * hierarchy where levels are separated by periods, much like the Java or Python - * package namespace. So in the instance given above, channel names might be - * "input" for the upper level, and "input.csv", "input.xls" and "input.gnu" for - * the sub-levels. - * There is no arbitrary limit to the depth of nesting. - */ -export class Logger extends Filterer { - public readonly scope: string; - public _level: number; - private _manager: Manager|null = null; - public readonly parent: Logger|null = null; - public readonly propagate: boolean = true; - public readonly handlers: Handler[] = []; - public readonly disabled: boolean = false; - private cache: {[key: number]: boolean} = {}; - - /** - * Initialize the logger with a name and an optional level - * - * @param scope - - * @param level - - * @param manager - - */ - constructor( - scope: string, - level?: LogLevel, - ) { - super(); - - this.scope = scope; - this._level = checkLevel(level ?? NOTSET); - } - - public get level() { return this._level } - - public set level(level: LogLevel) { this._level = checkLevel(level) } - - public set manager(manager: Manager) { - if (this.manager) { - throw new ValueError('logger can only be assigned to manager once'); - } - } - - public setLevel(level: LogLevel) { - this.level = checkLevel(level); - - //this.manager.clearCache() - } - - /** - * Get the effective level for this logger. - * - * Loop through this logger and its parents in the logger hierarchy, looking - * for a non-zero logging level. Return the first one found. - */ - public getEffectiveLevel() { - var logger: Logger|null = this; - - while (logger) { - if (logger.level) { return logger.level } - logger = logger.parent; - } - - return NOTSET; - } - - /** - * Is this logger enabled for level 'level'? - */ - public isEnabledFor(level: LogLevel): boolean { - if (this.disabled) { return false } - - if (this.cache[level] === undefined && this.manager && this.manager.disable < level) { - return this.cache[level] = ( - level >= this.getEffectiveLevel() - ); - } - - return this.cache[level] = false; - } - - /** - * Log 'msg % args' with severity 'DEBUG' - * - * To pass exception information, use the keyword argument exc_info with - * a true value, e.g. - * - * ``` - * logger.debug("Houston, we have a thorny problem", { exc_info: true }) - * ``` - */ - public debug(msg: string, options?: LogOptions) { - if (this.isEnabledFor(DEBUG)) { this._log(DEBUG, msg, options) } - } - - /** - * A factory method which can be overriden in subclasses to create - * specialized LogRecords. - * - * - */ - protected makeRecord( - name: string, - level: LogLevel, - msg: string, - options: LogOptions, - ): LogRecord { - - var recordOptions: LogRecordOptions = { - level: level, - msg: msg, - }; - - var rv = logRecordFactory(name, recordOptions); - - if (options.extra !== null) { - Object.entries(options.extra!).forEach((item) => { - - var [k, v] = item; - - if (['message', 'asctime'].includes(k as string) || - (rv as {[key: string]: any}).keys().includes(k as string)) { - throw new KeyError('attempt to overwrite ${k} in LogRecord') - } - - (rv as any)[k] = options.extra![k as string] as any - }) - } - - return rv - } - - /** - * Low-level logging routine which creates a LogRecord and then calls the - * handlers of this logger to handle the record. - */ - protected _log(level: LogLevel, msg: string, options?: LogOptions) { - options = options ?? DEFAULT_LOG_OPTIONS; - options = { ...DEFAULT_LOG_OPTIONS, ...options }; - - var sinfo=null; - - if (options!.excInfo !== null) { - if (options!.excInfo instanceof Error) { - var excInfo: ExecutionInfo = [ - typeof options!.excInfo, - options!.excInfo, - options!.excInfo.stack! - ] - } - else if (!(options!.excInfo instanceof Array)) { - throw new NotImplementedError("would try to get the callee stack from the system. Probably will use stacktrace.js as this needs to be implemented browser-specific."); - } - } - - var record = this.makeRecord(this.scope, level, msg, options) - } - - /** - * Call the handlers for the specified record. - * - * This method is used for unpickled records received from a socket, as well - * as those created locally. Logger-level filtering is applied. - */ - protected handle(scope: string, record: LogRecord) { - if (this.disabled) { return } - var maybeRecord = this.filter(record); - if (!maybeRecord) { return } - if ((maybeRecord as any) instanceof LogRecord) { record = maybeRecord } - this.callHandlers(record) - } - - /** - * Pass a record to all relevant handlers. - * - * Loop through all handlers for this logger and its parents n the logger - * hierarchy. If no handler was found, output a one-off error message to - * sys.stderr. Stop searching up the hierarchy whenever a logger with the - * "propagate" attribute set to zero is found - that will be the last logger - * whose handlers are called. - */ - protected callHandlers(record: LogRecord) { - var c: Logger|null = this; - var found = 0; - - while (c) { - for (var i = 0; i < c.handlers.length; i += 1) { - let hdlr = c.handlers[i]; - - found = found + 1; - - if (record.levelno >= hdlr.level) { hdlr.handle(record) } - } - - if (!c.propagate) { c = null } - else { c = c.parent } - } - - if (found == 0) { - if (lastResort) { - if (record.levelno >= lastResort.level) { - lastResort.handle(record) - } - else if (throwErrors && (this.manager && !this.manager.emittedNoHandlerWarning)) { - console.error( - `No handlers could be found for logger ${this.scope}` - ); - - this.manager.emittedNoHandlerWarning = true; - } - } - } - } - - public clear() { - for (var property in this.cache) delete this.cache[property]; - } - - /** - * Remove the specified handler from this logger. - */ - public addHandler(hdlr: Handler) { - const i = this.handlers.indexOf(hdlr); - if (i === -1) { this.handlers.push(hdlr) } - } - - /** - * Remove the specified handler from this logger. - */ - public removeHandler(hdlr: Handler) { - const i = this.handlers.indexOf(hdlr); - if (i !== -1) { delete this.handlers[i] } - } -} - -/** - * A root logger is not that different to any other logger, except that it must - * have a logging level and there is only one instance of in a manager's - * hierarchy. - */ -class RootLogger extends Logger { - - constructor(level: LogLevel) { - super('root', level); - } -} - -var loggerClass = Logger; - -/** - * root logger (singleton) - */ -const ROOT = new RootLogger(WARNING); - -/** - * log manager (singleton) - */ -const MANAGER = new Manager(ROOT); - -//--------------------------------------------------------------------------- -// Configuration classes and functions -//--------------------------------------------------------------------------- - -/** - * options for basic configuration of logging module - */ -export interface BasicConfigOptions { - /* - * Specifies that a FileHandler be created, using the specified filename, - * rather than a StreamHandler. - */ - filename?: string; - - /** - * Specifies the mode to open the file, if filename is specified (if - * filemode is unspecified, it defaults to 'a') - */ - filemode?: string; - - /** - * Use the specified format string for the handler. - */ - format?: string; - - /** - * Use the specified date/time format. - * - */ - datefmt?: string; - - /** - * If a format string is specified, use this to specify the type of format - * string (possible values '%', '{', '$', for %-formatting, - * :meth:`str.format` and :class:`string.Template`- defaults to '%'). - * - * TODO: switch to enum - */ - style?: string; - - /** - * Set the root logger level to the specified level. - */ - level?: LogLevel; - - /** - * Use the specified stream to initialize the StreamHandler. Note that this - * argument is incompatible with 'filename' - if both are present, 'stream' - * is ignored. - * - * TODO: - */ - stream?: any; - - /** - * If specified, this should be an iterable of already created handlers, - * which will be added to the root logger. Any handler in the list which - * does not have a formatter assigned will be assigned the formatter created - * in this function. - */ - handlers?: Handler[]; - - /** - * If this keyword is specified as true, any existing handlers attached to - * the root logger are removed and closed, before carrying out the - * configuration as specified by the other arguments. - */ - force?: boolean; - - /** - * If specified together with a filename, this encoding is passed to the - * created FileHandler, causing it to be used when the file is opened. - */ - encoding?: string; - - /** - * If specified together with a filename, this value is - * passed to the created FileHandler, causing it to be used - * when the file is opened in text mode. If not specified, - * the default value is `backslashreplace`. - */ - errors?: string|null; -} - -/** - * Do basic configuration for the logging system. - * - * This function does nothing if the root logger already has handlers - * configured, unless the keyword argument *force* is set to ``True``. - * It is a convenience method intended for use by simple scripts - * to do one-shot configuration of the logging package. - * - * The default behaviour is to create a StreamHandler which writes to - * sys.stderr, set a formatter using the BASIC_FORMAT format string, and - * add the handler to the root logger. - * - * A number of optional keyword arguments may be specified, which can alter - * the default behaviour. - * - * Note that you could specify a stream created using open(filename, mode) - * rather than passing the filename and mode in. However, it should be - * remembered that StreamHandler does not close its stream (since it may be - * using sys.stdout or sys.stderr), whereas FileHandler closes its stream - * when the handler is closed. - * - * TODO: refactor logic, there apparently is some redundancy in the original - * code - */ -export function basicConfig(options: BasicConfigOptions) { - const force = options.force ?? false; - var encoding = options.encoding ?? undefined; - var errors: string|undefined = options.errors ?? 'backslashreplace'; - var handlers = options.handlers ?? []; - const filename = options.filename ?? null; - const stream = options.stream ?? null; - const filemode = options.filemode ?? 'a'; - const dateformat = options.filemode ?? null; - const style = options.filemode ?? '%'; - const level = options.level ?? null; - - if (!Object.keys(STYLES).includes(style)) { - throw new ValueError( - `style must be one of: ${Object.keys(STYLES).join(', ')}` - ); - } - - if (force) { - for (var i = 0; i < MANAGER.root.handlers.length; i += 1) { - let h: Handler = MANAGER.root.handlers[i]; - MANAGER.root.removeHandler(h); - h.close(); - } - } - - if (handlers.length == 0) { - if (handlers === null && stream && filename) { - throw new ValueError( - "'stream' and 'filename' should not be specified together" - ); - } - - else if (stream || filename) { - throw new ValueError( - "'stream' or 'filename' should not be specified together" + - "with 'handlers'" - ); - } - - if (handlers === null) { - var h: Handler; - - if (filename) { - if (filemode.match('b')) { errors = undefined } - else { encoding = 'utf-8' } - - h = new FileHandler({ - filename: filename, - filemode: filemode, - 'encoding': encoding, - errors: errors - }); - } - - else { h = new StreamHandler(stream) } - - handlers = [h]; - } - - for (var i = 0; i < handlers.length; i += 1) { - let h = handlers[i]; - - if (h.formatter === null) { - h.formatter = new Formatter({ - fmt: options.format ?? STYLES[style][1], - datefmt: dateformat, - style: style - }); - } - - MANAGER.root.addHandler(h); - } - - if (level !== null) { MANAGER.root.setLevel(level) } - - if (options) { - // runtime interface guard, please let me stay. 🥺 - // the interface does not allow for additional members, but the - // runtime environment has no concept of interfaces. We can stick to - // the original implementation - const keys = Object.keys(options).join(', '); - - throw new ValueError(`Unrecognised argument(s): ${keys}`); - } - } -} diff --git a/src/log-level.ts b/src/log-level.ts new file mode 100644 index 0000000..23014ba --- /dev/null +++ b/src/log-level.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------- + Level related stuff + --------------------------------------------------------------------------- + + Default levels and level names, these can be replaced with any positive set + of values having corresponding names. There is a pseudo-level, NOTSET, which + is only really there as a lower limit for user-defined levels. Handlers and + loggers are initialized with NOTSET so that they will log all messages, even + at user-defined levels. +*/ + +export type LogLevel = number; + +/** + * An indication that something unexpected happened, or that a problem might + * occur in the near future (e.g. ‘disk space low’). The software is still + * working as expected. + */ +export const CRITICAL = 50; +export const FATAL = CRITICAL; + +/** + * Due to a more serious problem, the software has not been able to perform some + * function. + */ +export const ERROR = 40; + +/** + * An indication that something unexpected happened, or that a problem might + * occur in the near future (e.g. ‘disk space low’). The software is still + * working as expected. + */ +export const WARNING = 30; +export const WARN = WARNING; + +/** + * Confirmation that things are working as expected. + */ +export const INFO = 20; + +/** + * Detailed information, typically only of interest to a developer trying to + * diagnose a problem. + */ +export const DEBUG = 10; + +/** + * When set on a logger, indicates that ancestor loggers are to be consulted to + * determine the effective level. If that still resolves to NOTSET, then all + * events are logged. When set on a handler, all events are handled. + */ +export const NOTSET = 0; + +const LEVELTONAME: {[key: number]: string} = { + [CRITICAL]: 'CRITICAL', + [ERROR]: 'ERROR', + [WARNING]: 'WARNING', + [INFO]: 'INFO', + [DEBUG]: 'DEBUG', + [NOTSET]: 'NOTSET' +} + +const NAMETOLEVEL: {[key: string]: number} = { + CRITICAL: CRITICAL, + ERROR: ERROR, + WARNING: WARNING, + INFO: INFO, + DEBUG: DEBUG, + NOTSET: NOTSET, +} + +function getLevelNamesMapping() { + return Object.assign({}, NAMETOLEVEL); +} + +/** + * Return the textual or numeric representation of logging level 'level' + * + * @param level + */ +export function getLevelName(level: string|number): string|number { + var result: string|number = LEVELTONAME[level as number]; + if (result !== undefined) { return result } + result = NAMETOLEVEL[level as string]; + if (result !== undefined) { return result } + return `Level ${level}`; +} + +/** + * Associate 'levelName' with 'level' + * + * @param level + * @param levelName + */ +export function addLevelName(level: number, levelName: string) { + LEVELTONAME[level] = levelName; + NAMETOLEVEL[levelName] = level; +} + +export function checkLevel(level: number|string): number { + var rv: number; + + if (typeof level == 'number') { rv = level } + + else if (typeof level == 'string') { + if (!Object.keys(NAMETOLEVEL).includes(level as string)) { + throw new Error(`Unknown level: ${level}`) + } + + rv = NAMETOLEVEL[level] + } + + else { + throw new Error(`Level not a number or valid string: ${level}`) + } + + return rv +} diff --git a/src/log-record.ts b/src/log-record.ts new file mode 100644 index 0000000..6c6f335 --- /dev/null +++ b/src/log-record.ts @@ -0,0 +1,77 @@ +import { getLevelName, LogLevel } from './log-level'; +import { MillisecondsSinceUnixEpoch } from './helper/datetime'; + +//--------------------------------------------------------------------------- +// The logging record +//--------------------------------------------------------------------------- + +/** + * options for instantiating a new log record + */ +export interface LogRecordOptions { + /** + * The numeric level of the logging event (such as 10 for DEBUG, 20 for + * INFO, etc). Note that this is converted to two attributes of the + * LogRecord: levelno for the numeric value and levelname for the + * corresponding level name. + */ + level: number, + file?: string, + /** + * The line number in the source file where the logging call was made. + */ + lno?: number, + /** + * The event description message, which can be a %-format string with + * placeholders for variable data, or an arbitrary object (see Using + * arbitrary objects as messages). + */ + msg: string, + /** + * Variable data to merge into the msg argument to obtain the event + * description. + */ + args?: any[], +} + +export type LogRecordFactory = { (name: string, options: LogRecordOptions): LogRecord }; + +/** + * LogRecord instances are created every time something is logged. They contain + * all the information pertinent to the event being logged. The main + * information parssed in is msg and args, which are combined using str(msg) % + * args to create the message field of the record. The record also includes + * information such as when the record was created, the source line where the + * logging call was made, and any exception information to be logged. + */ +export class LogRecord { + public readonly levelno: LogLevel; + public readonly levelname: string|LogLevel; + public readonly scope: string; + + public readonly created: MillisecondsSinceUnixEpoch = Date.now(); + + constructor(scope: string, options: LogRecordOptions) { + this.levelno = options.level; + this.levelname = getLevelName(options.level); + this.scope = scope; + } +} + +export var logRecordFactory = (scope: string, options: LogRecordOptions) => { + return new LogRecord(scope, options) +}; + +/** + * Define which class use when instantiating log records. + * + * @param factory - A callable which will be called to instantiate a log record. + * Pass a clojure, if your factory is a class already. + */ +export function setLogRecordFactory(factory: LogRecordFactory) { + logRecordFactory = factory +} + +export function getLogRecordFactory(): LogRecordFactory { + return logRecordFactory +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..8cdff06 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,324 @@ +import { + LogLevel, + DEBUG, + NOTSET, + WARNING, + checkLevel, +} from './log-level'; +import { + LogRecord, + logRecordFactory, + LogRecordOptions, +} from './log-record'; +import { Handler, StderrHandler } from './handler'; +import { + NotImplementedError, + KeyError, + ValueError, + StackTrace, +} from './helper/error'; +import { Manager } from './manager'; +import { Filterer } from './filter'; + +//--------------------------------------------------------------------------- +// Logger classes and functions +//--------------------------------------------------------------------------- + +export type ExecutionInfo = [string, Error, StackTrace]; + +export var throwErrors: boolean = true; + +export const DEFAULT_LAST_RESORT = new StderrHandler(WARNING); + +export var lastResort = DEFAULT_LAST_RESORT; + +export type LoggerClass = { new(): Logger }; + +/** + * context of a logging event/trigger + */ +export interface LogOptions{ + /** + * + */ + excInfo: ExecutionInfo|Error|null, + /** + * + */ + extra: {[key: string]: any}|null, + /** + * + */ + stackInfo: boolean, + /** + * + */ + stackLevel: number +} + +const DEFAULT_LOG_OPTIONS: LogOptions = Object.freeze({ + excInfo: null, + extra: null, + stackInfo: false, + stackLevel: 1 +}); + +/** + * Instances of the logger class represent a single logging channel. A 'logging + * channel' indicates an area of an application. Exactly how an 'area' is + * defined is up to the application developer. Since an application can have any + * number of areas, logging channels are identified by a unique string. + * Application areas can be nested (e.g. an area of input process might include + * sub-areas "read CSV file", "read XLS files" and "read Gnumeric files"). To + * cater for this natural nesting, channel ames are organized into a namespace + * hierarchy where levels are separated by periods, much like the Java or Python + * package namespace. So in the instance given above, channel names might be + * "input" for the upper level, and "input.csv", "input.xls" and "input.gnu" for + * the sub-levels. + * There is no arbitrary limit to the depth of nesting. + */ +export class Logger extends Filterer { + public readonly scope: string; + public _level: number; + private _manager: Manager|null = null; + public readonly parent: Logger|null = null; + public readonly propagate: boolean = true; + public readonly handlers: Handler[] = []; + public readonly disabled: boolean = false; + private cache: {[key: number]: boolean} = {}; + + /** + * Initialize the logger with a name and an optional level + * + * @param scope - + * @param level - + * @param manager - + */ + constructor( + scope: string, + level?: LogLevel, + ) { + super(); + + this.scope = scope; + this._level = checkLevel(level ?? NOTSET); + } + + public get level() { return this._level } + + public set level(level: LogLevel) { this._level = checkLevel(level) } + + public set manager(manager: Manager) { + if (this.manager) { + throw new ValueError('logger can only be assigned to manager once'); + } + } + + public setLevel(level: LogLevel) { + this.level = checkLevel(level); + + //this.manager.clearCache() + } + + /** + * Get the effective level for this logger. + * + * Loop through this logger and its parents in the logger hierarchy, looking + * for a non-zero logging level. Return the first one found. + */ + public getEffectiveLevel() { + var logger: Logger|null = this; + + while (logger) { + if (logger.level) { return logger.level } + logger = logger.parent; + } + + return NOTSET; + } + + /** + * Is this logger enabled for level 'level'? + */ + public isEnabledFor(level: LogLevel): boolean { + if (this.disabled) { return false } + + if (this.cache[level] === undefined && this.manager && this.manager.disable < level) { + return this.cache[level] = ( + level >= this.getEffectiveLevel() + ); + } + + return this.cache[level] = false; + } + + /** + * Log 'msg % args' with severity 'DEBUG' + * + * To pass exception information, use the keyword argument exc_info with + * a true value, e.g. + * + * ``` + * logger.debug("Houston, we have a thorny problem", { exc_info: true }) + * ``` + */ + public debug(msg: string, options?: LogOptions) { + if (this.isEnabledFor(DEBUG)) { this._log(DEBUG, msg, options) } + } + + /** + * A factory method which can be overriden in subclasses to create + * specialized LogRecords. + * + * + */ + protected makeRecord( + name: string, + level: LogLevel, + msg: string, + options: LogOptions, + ): LogRecord { + + var recordOptions: LogRecordOptions = { + level: level, + msg: msg, + }; + + var rv = logRecordFactory(name, recordOptions); + + if (options.extra !== null) { + Object.entries(options.extra!).forEach((item) => { + + var [k, v] = item; + + if (['message', 'asctime'].includes(k as string) || + (rv as {[key: string]: any}).keys().includes(k as string)) { + throw new KeyError('attempt to overwrite ${k} in LogRecord') + } + + (rv as any)[k] = options.extra![k as string] as any + }) + } + + return rv + } + + /** + * Low-level logging routine which creates a LogRecord and then calls the + * handlers of this logger to handle the record. + */ + protected _log(level: LogLevel, msg: string, options?: LogOptions) { + options = options ?? DEFAULT_LOG_OPTIONS; + options = { ...DEFAULT_LOG_OPTIONS, ...options }; + + var sinfo=null; + + if (options!.excInfo !== null) { + if (options!.excInfo instanceof Error) { + var excInfo: ExecutionInfo = [ + typeof options!.excInfo, + options!.excInfo, + options!.excInfo.stack! + ] + } + else if (!(options!.excInfo instanceof Array)) { + throw new NotImplementedError("would try to get the callee stack from the system. Probably will use stacktrace.js as this needs to be implemented browser-specific."); + } + } + + var record = this.makeRecord(this.scope, level, msg, options) + } + + /** + * Call the handlers for the specified record. + * + * This method is used for unpickled records received from a socket, as well + * as those created locally. Logger-level filtering is applied. + */ + protected handle(scope: string, record: LogRecord) { + if (this.disabled) { return } + var maybeRecord = this.filter(record); + if (!maybeRecord) { return } + if ((maybeRecord as any) instanceof LogRecord) { record = maybeRecord } + this.callHandlers(record) + } + + /** + * Pass a record to all relevant handlers. + * + * Loop through all handlers for this logger and its parents n the logger + * hierarchy. If no handler was found, output a one-off error message to + * sys.stderr. Stop searching up the hierarchy whenever a logger with the + * "propagate" attribute set to zero is found - that will be the last logger + * whose handlers are called. + */ + protected callHandlers(record: LogRecord) { + var c: Logger|null = this; + var found = 0; + + while (c) { + for (var i = 0; i < c.handlers.length; i += 1) { + let hdlr = c.handlers[i]; + + found = found + 1; + + if (record.levelno >= hdlr.level) { hdlr.handle(record) } + } + + if (!c.propagate) { c = null } + else { c = c.parent } + } + + if (found == 0) { + if (lastResort) { + if (record.levelno >= lastResort.level) { + lastResort.handle(record) + } + else if (throwErrors && (this.manager && !this.manager.emittedNoHandlerWarning)) { + console.error( + `No handlers could be found for logger ${this.scope}` + ); + + this.manager.emittedNoHandlerWarning = true; + } + } + } + } + + public clear() { + for (var property in this.cache) delete this.cache[property]; + } + + /** + * Remove the specified handler from this logger. + */ + public addHandler(hdlr: Handler) { + const i = this.handlers.indexOf(hdlr); + if (i === -1) { this.handlers.push(hdlr) } + } + + /** + * Remove the specified handler from this logger. + */ + public removeHandler(hdlr: Handler) { + const i = this.handlers.indexOf(hdlr); + if (i !== -1) { delete this.handlers[i] } + } +} + +/** + * A root logger is not that different to any other logger, except that it must + * have a logging level and there is only one instance of in a manager's + * hierarchy. + */ +export class RootLogger extends Logger { + + constructor(level: LogLevel) { + super('root', level); + } +} + +/** + * root logger (singleton) + */ +export const ROOT = new RootLogger(WARNING); diff --git a/src/manager.ts b/src/manager.ts new file mode 100644 index 0000000..c891849 --- /dev/null +++ b/src/manager.ts @@ -0,0 +1,127 @@ +import { + Logger, + LoggerClass, + RootLogger, + ROOT, +} from './logger'; +import { LogRecordFactory } from './log-record'; +import { + LogLevel, + NOTSET, + checkLevel, +} from './log-level' + + +//--------------------------------------------------------------------------- +// Manager classes and functions +//--------------------------------------------------------------------------- + +var loggerClass = Logger; + +/** + * Placeholder instance + */ +class Placeholder { + protected loggers: Logger[] = []; + + /** + * initialize with the specified logger being a child of this placeholder. + */ + constructor(logger: Logger) { this.push(logger) } + + /** + * add the specified logger as a child of this placeholder + */ + public push(logger: Logger) { + if (!this.loggers.includes(logger)) { this.loggers.push(logger) } + } +} + + +/** + * There is [under normal circumstances] just one Manager intance, which holds + * the hierarchy of loggers. + */ +export class Manager { + public readonly root: RootLogger; + protected _disable: number = 0; + public emittedNoHandlerWarning: boolean = false; + protected loggers: {[key: string]: Logger} = {}; + protected _loggerClass: LoggerClass|null = null; + protected _logRecordFactory: LogRecordFactory|null = null; + + public get disable(): number { return this._disable } + + public set disable(level: LogLevel) { this._disable = checkLevel(level) } + + /** + * Initialize the manager with the root node of the logger hierarchy + */ + constructor(root: RootLogger) { + this.root = root; + } + + /** + * Get a logger with the specified name (scope name), creating it, if it + * does not yet exist. This name is a dot-separated hierarchical name, such + * as "a", "a.b", "a.b.c" or similar. + * + * If a PlaceHolder existed for the specified name [i.e. the logger didn't + * exist but a child of it did], replace it with the created logger and fix + * up the parent/child references which pointed to the placeholder to now + * point to the logger. + */ + getLogger(scope: string) { + var rv: null|Logger = null; + + if (typeof scope != 'string') { + + rv = this.loggers[scope]; + + if (rv instanceof Placeholder) { + var ph = rv; + rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET); + } + else { + rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET); + this.loggers[scope] = rv; + } + } + } + + /** + * Set the class to be used when instantiating a logger with this Manager. + */ + set loggerClass(class_: LoggerClass) { + if (class_ !== Logger) { + if (!(class_.prototype instanceof Logger)) { + throw new TypeError("logger not derived from logging.Logger: ") + } + } + + this._loggerClass = class_; + } + + /** + * Set the factory to be used when instantiating a log record with this + * Manager. + */ + set logRecordFactory(factory: LogRecordFactory) { + this._logRecordFactory = factory; + } + + /** + * clear the cache for all loggers in loggerDict + */ + public clear() { + Object.values(this.loggers).forEach((logger) => { + logger.clear() + }); + } +} + + +/** + * log manager (singleton) + */ +export const MANAGER = new Manager(ROOT); diff --git a/tests/test.ts b/tests/test.ts index 208acdc..13ee6f7 100644 --- a/tests/test.ts +++ b/tests/test.ts @@ -2,7 +2,7 @@ import {expect, jest, test} from '@jest/globals'; describe('Logger', () => { it('can be instantiated', () => { - //const logger = new logging.Logger('test', 0); + //const logger = new logging.log_level.Logger('test', 0); }) }); @@ -17,56 +17,56 @@ describe('getLevelName', () => { it('numeric to textual representation of built-ins', () => { expect( - logging.getLevelName(logging.CRITICAL) + logging.log_level.getLevelName(logging.log_level.CRITICAL) ).toBe('CRITICAL'); expect( - logging.getLevelName(logging.FATAL) + logging.log_level.getLevelName(logging.log_level.FATAL) ).toBe('CRITICAL'); expect( - logging.getLevelName(logging.ERROR) + logging.log_level.getLevelName(logging.log_level.ERROR) ).toBe('ERROR'); expect( - logging.getLevelName(logging.WARNING) + logging.log_level.getLevelName(logging.log_level.WARNING) ).toBe('WARNING'); expect( - logging.getLevelName(logging.WARN) + logging.log_level.getLevelName(logging.log_level.WARN) ).toBe('WARNING'); expect( - logging.getLevelName(logging.INFO) + logging.log_level.getLevelName(logging.log_level.INFO) ).toBe('INFO'); expect( - logging.getLevelName(logging.DEBUG) + logging.log_level.getLevelName(logging.log_level.DEBUG) ).toBe('DEBUG'); expect( - logging.getLevelName(logging.NOTSET) + logging.log_level.getLevelName(logging.log_level.NOTSET) ).toBe('NOTSET'); }); it('textual to numeric representation of built-ins', () => { expect( - logging.getLevelName('CRITICAL') - ).toBe(logging.CRITICAL); + logging.log_level.getLevelName('CRITICAL') + ).toBe(logging.log_level.CRITICAL); expect( - logging.getLevelName('FATAL') + logging.log_level.getLevelName('FATAL') ).toBe(`Level FATAL`); expect( - logging.getLevelName('ERROR') - ).toBe(logging.ERROR); + logging.log_level.getLevelName('ERROR') + ).toBe(logging.log_level.ERROR); expect( - logging.getLevelName('WARNING') - ).toBe(logging.WARNING); + logging.log_level.getLevelName('WARNING') + ).toBe(logging.log_level.WARNING); expect( - logging.getLevelName('WARN') + logging.log_level.getLevelName('WARN') ).toBe('Level WARN'); expect( - logging.getLevelName('INFO') - ).toBe(logging.INFO); + logging.log_level.getLevelName('INFO') + ).toBe(logging.log_level.INFO); expect( - logging.getLevelName('DEBUG') - ).toBe(logging.DEBUG); + logging.log_level.getLevelName('DEBUG') + ).toBe(logging.log_level.DEBUG); expect( - logging.getLevelName('NOTSET') - ).toBe(logging.NOTSET); + logging.log_level.getLevelName('NOTSET') + ).toBe(logging.log_level.NOTSET); }); }); @@ -79,8 +79,8 @@ describe('addLevelName', () => { }); it('numeric to textual representation of built-ins', () => { - logging.addLevelName(80, 'FOOBAR'); - expect(logging.getLevelName(80)).toBe('FOOBAR'); - expect(logging.getLevelName('FOOBAR')).toBe(80); + logging.log_level.addLevelName(80, 'FOOBAR'); + expect(logging.log_level.getLevelName(80)).toBe('FOOBAR'); + expect(logging.log_level.getLevelName('FOOBAR')).toBe(80); }) });