From 7a70ed28c35e996b61e56c0614fbded497e0271a Mon Sep 17 00:00:00 2001 From: "Rodney, Tiara" Date: Fri, 25 Apr 2025 15:56:31 +0200 Subject: [PATCH] feat: initialize tests, docs and refactor --- .gitignore | 1 + README.md | 4 + doc/README.md | 1 + doc/logging-cookbook.md | 37 + jest.config.js | 7 +- package-lock.json | 54 ++ package.json | 1 + src/datetime.ts | 2 + src/index.ts | 5 +- src/logging/README.md | 1 + src/logging/index.ts | 1413 ++++++++++++++++++++++++++------------- src/logging/logger.ts | 951 ++++++++++++++++++++++++++ src/stream.ts | 2 + tests/logging.test.ts | 79 ++- tsconfig.json | 2 +- typedoc.json | 4 + 16 files changed, 2096 insertions(+), 468 deletions(-) create mode 100644 README.md create mode 100644 doc/README.md create mode 100644 doc/logging-cookbook.md create mode 100644 src/datetime.ts create mode 100644 src/logging/README.md create mode 100644 src/logging/logger.ts create mode 100644 src/stream.ts create mode 100644 typedoc.json diff --git a/.gitignore b/.gitignore index 3836d03..6c9d7af 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ config.log config.status autom4te.cache /dist +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbe213e --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ + + +* [Logging Cookbook](doc/logging-cookbook.md) + diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..776f875 --- /dev/null +++ b/doc/README.md @@ -0,0 +1 @@ +The doc/README.md \ No newline at end of file diff --git a/doc/logging-cookbook.md b/doc/logging-cookbook.md new file mode 100644 index 0000000..a0e5d9c --- /dev/null +++ b/doc/logging-cookbook.md @@ -0,0 +1,37 @@ +# Using logging in multiple modules + +Multiple calls to `logging.getLogger('someLogger')` return a reference to the +same logger object. This is true not only within the same module, but also +across modules as long as it is in the same Python interpreter process. It is +true for references to the same object; additionally, application code can +define and configure a parent logger in one module and create (but not +configure) a child logger in a separate module, and all logger calls to the +child will pass up to the parent. Here is a main module: + +``javascript +import * as logging from 'eslib/logging'; +import * as my_module from './my_module'; + +// create logger with 'spam_application' +var logger = logging.getLogger('spam_application'); +logger.setLevel(logging.DEBUG); + +// create file handler which logs even debug messages +var fh = logging.FileHandler('spam.log') +fh.setLevel(logging.DEBUG); + +// create console handler with a higher log level +var ch = logging.StreamHandler(); +ch.setLevel(logging.ERROR); + +// create formatter and add it to the handlers +var formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'); +fh.setFormatter(formatter); +ch.setFormatter(formatter); + +// add the handlers to the logger +logger.addHandler(fh); +logger.addHandler(ch); + +logger.info('creating an instance of auxiliary_module.Auxiliary') +``` diff --git a/jest.config.js b/jest.config.js index ad6863d..fc73172 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,12 @@ module.exports = { testEnvironment: "node", transform: { - "^.+.tsx?$": ["ts-jest", {}] + "^.+.tsx?$": [ + "ts-jest", + { + tsconfig: 'tsconfig.debug.json' + } + ] }, roots: ['./tests'] } diff --git a/package-lock.json b/package-lock.json index 33eebb0..215731c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "jest": "^29.7.0", "ts-jest": "^29.2.5", "typedoc": "^0.27.7", + "typedoc-plugin-pages": "^1.1.0", "typescript": "^5.7.3", "yargs": "^17.7.2" } @@ -1539,6 +1540,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "dev": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3724,6 +3731,28 @@ "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x" } }, + "node_modules/typedoc-default-themes": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.10.2.tgz", + "integrity": "sha512-zo09yRj+xwLFE3hyhJeVHWRSPuKEIAsFK5r2u47KL/HBKqpwdUSanoaz5L34IKiSATFrjG5ywmIu98hPVMfxZg==", + "dev": true, + "dependencies": { + "lunr": "^2.3.8" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/typedoc-plugin-pages": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-pages/-/typedoc-plugin-pages-1.1.0.tgz", + "integrity": "sha512-pmCCp3G2aCeEWb829dcVe9Pl+pI5OsaqfyrkEutcAHZi6IMVfQ5G5NdrkIkFCGhJU/DY04rGrVdynWqnaO5/jg==", + "dev": true, + "dependencies": { + "compare-versions": "^3.6.0", + "typedoc-default-themes": "^0.10.1" + } + }, "node_modules/typedoc/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -5156,6 +5185,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6767,6 +6802,25 @@ } } }, + "typedoc-default-themes": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.10.2.tgz", + "integrity": "sha512-zo09yRj+xwLFE3hyhJeVHWRSPuKEIAsFK5r2u47KL/HBKqpwdUSanoaz5L34IKiSATFrjG5ywmIu98hPVMfxZg==", + "dev": true, + "requires": { + "lunr": "^2.3.8" + } + }, + "typedoc-plugin-pages": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-pages/-/typedoc-plugin-pages-1.1.0.tgz", + "integrity": "sha512-pmCCp3G2aCeEWb829dcVe9Pl+pI5OsaqfyrkEutcAHZi6IMVfQ5G5NdrkIkFCGhJU/DY04rGrVdynWqnaO5/jg==", + "dev": true, + "requires": { + "compare-versions": "^3.6.0", + "typedoc-default-themes": "^0.10.1" + } + }, "typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", diff --git a/package.json b/package.json index 8712267..7e23dde 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "jest": "^29.7.0", "ts-jest": "^29.2.5", "typedoc": "^0.27.7", + "typedoc-plugin-pages": "^1.1.0", "typescript": "^5.7.3", "yargs": "^17.7.2" }, diff --git a/src/datetime.ts b/src/datetime.ts new file mode 100644 index 0000000..297e20c --- /dev/null +++ b/src/datetime.ts @@ -0,0 +1,2 @@ + +export type MillisecondsSinceUnixEpoch = number; diff --git a/src/index.ts b/src/index.ts index 37a0222..ea95dc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ - -export * as logging from './logging' +export * as logging from './logging'; +export * as datetime from './datetime'; +export * as error from './error'; diff --git a/src/logging/README.md b/src/logging/README.md new file mode 100644 index 0000000..494c133 --- /dev/null +++ b/src/logging/README.md @@ -0,0 +1 @@ +sds diff --git a/src/logging/index.ts b/src/logging/index.ts index 43a050d..3b3a110 100644 --- a/src/logging/index.ts +++ b/src/logging/index.ts @@ -1,58 +1,160 @@ /** - * Logging module + * This module defines functions and classes which implement a flexible, + * idiomatic event logging system for ECMAScript applications and libraries. It + * is a (quasi) vanilla port of the CPython 3.13 standard library logging + * module. * - * This is a partial port of the logging package of the Python standard - * library. Why port this? Well, I've been working with a lot of different - * standard logging modules throughout my endeavours and the standard Python - * logging is the one that comes closest to what I would expect from a logging - * module when collaboratively developing. + * The key benefit of having the logging API provided by a standard library + * module is that all Python modules can participate in logging, so your + * application log can include your own messages integrated with messages from + * third-party modules. * - * There is no point in porting the approach of parallelism from the Python - * module as we don't have that option with browser-side JavaScript. I'm still - * thinking of how this could be properly abstracted for asynchronicty, but at - * this point in time there aren't that many blocking routines in the code, so - * performance gain would be marginal. - * - * Why do we need our own logging library? - * - * Logging is essential for collaboration. It serves as a tool for debugging, - * communicates operational excellence, and is generally required for - * security conformance. Log, log, log and stop worrying so much about - * performance impact. If logging is a bottle-neck for you, you're simply using - * a shitty logging library. Mark my words: If this library becomes a - * bottle-neck, it's time to refactor! Let's start from scratch and get it right - * the first time! - * - * Why are channels now named scopes and why is the scope not a property of a - * log record anymore? * - * First, the documentation of the logging library is inconsistent with the - * naming of what the nesting of loggers is actually called. In addition, - * considering them as a property of something makes us run into naming - * conflicts with built-in words, like with the "name" property. This conflicts - * with ECMA script. Therefore, we rename "channels" to "scopes" and consider - * them independent from the object they're associated with. + * Here’s a simple example of idiomatic usage: * - * Important features: + * ```javascript + * // myapp.ts + * import * as logging from 'eslib/logging'; + * import * as mylib from './mylib'; + * const logger = logging.getLogger(__name__); + * + * function main() { + * logging.basicConfig({filename: 'myapp.log', level: logging.INFO}); + * logger.info('Started'); + * mylib.doSomething(); + * logger.info('Finished'); + * } + * + * main(); + * ``` * - * * third-party integration - * * OCSF-compliant logging - * * formatting - * * flexible log levels - * * filtering + * ```javascript + * // mylib.ts + * import * as logging from 'eslib/logging'; + * const logger = logging.getLogger(__name__); + * + * function do_something() { + * logger.info('Doing something') + * } + * ``` * - * If an unhandled error occurs, there should be a (pop-up) alert on browsers, - * in addition to a forceful flush of any logs destined to (abstract) stderr. + * If you run `myapp.ts`, you should see this in myapp.log: + * + * ``` + * INFO:__main__:Started + * INFO:mylib:Doing something + * INFO:__main__:Finished + * ``` + * + * The key feature of this idiomatic usage is that the majority of code is + * simply creating a module level logger with `getLogger(__name__)`, and using + * that logger to do any needed logging. This is concise, while allowing + * downstream code fine-grained control if needed. Logged messages to the + * module-level logger get forwarded to handlers of loggers in higher-level + * modules, all the way up to the highest-level logger known as the root logger; + * this approach is known as hierarchical logging. + * + * For logging to be useful, it needs to be configured: setting the levels and + * destinations for each logger, potentially changing how specific modules log, + * often based on command-line arguments or application configuration. In most + * cases, like the one above, only the root logger needs to be so configured, + * since all the lower level loggers at module level eventually forward their + * messages to its handlers. basicConfig() provides a quick way to configure the + * root logger that handles many use cases. + * + * The module provides a lot of functionality and flexibility. If you are + * unfamiliar with logging, the best way to get to grips with it is to view the + * tutorials (see the links above and on the right). + * + * The basic classes defined by the module, together with their attributes and + * methods, are listed in the sections below. + * + * * Loggers expose the interface that application code directly uses. + * * Handlers send the log records (created by loggers) to the appropriate + * destination. + * * Filters provide a finer grained facility for determining which log records + * to output. + * * Formatters specify the layout of log records in the final output. + * + * TODO: reintroduce multi-threading support + * + * > "Enums aren't real, they can hurt you though.". There are a couple of + * situations where it might make sense to translate a group of integer + * constants to an enum, but enum isn't a real type and allows for dynamically + * modifying it's behavior dynamically, since the underlying type is an + * object, which is mutable. So we would loose the immutability of the + * constants. Therefore we're stickng to the "old-school" convention. + * + * @module logging */ -import { NotImplementedError, MyError, ValueError, KeyError, StackTrace } from '../error'; +import { + NotImplementedError, + MyError, + ValueError, + KeyError, + StackTrace +} from '../error'; +import { MillisecondsSinceUnixEpoch } from '../datetime'; +import * as stream from 'stream'; +if (typeof window === 'undefined') { + const stream = require('stream'); +} +else { + const stream = require('../stream'); +} + +/*--------------------------------------------------------------------------- + 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} = { @@ -73,33 +175,6 @@ const NAMETOLEVEL: {[key: string]: number} = { NOTSET: NOTSET, } -export type LogLevel = number; - -export type ExecutionInfo = [string, Error, StackTrace]; - -/** - * context of a logging event/trigger - */ -export interface LogOptions{ - excInfo: ExecutionInfo|Error|null, - extra: {[key: string]: any}|null, - stackInfo: boolean, - stackLevel: number -} - -/** - * options for instantiating a new log record - */ -export interface LogRecordOptions { - level: number, - file?: string, - lno?: number, - msg: string, - args?: any[], -} - -export type LogRecordFactory = { (name: string, options: LogRecordOptions): LogRecord }; - function getLevelNamesMapping() { return Object.assign({}, NAMETOLEVEL); } @@ -109,10 +184,10 @@ function getLevelNamesMapping() { * * @param level */ -function getLevelName(level: number): string|number { - var result: string|number = LEVELTONAME[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]; + result = NAMETOLEVEL[level as string]; if (result !== undefined) { return result } return `Level ${level}`; } @@ -123,12 +198,67 @@ function getLevelName(level: number): string|number { * @param level * @param levelName */ -function addLevelName(level: number, levelName: string) { +export function addLevelName(level: number, levelName: string) { LEVELTONAME[level] = levelName; - NAMETOLEVEL[level] = level; + NAMETOLEVEL[levelName] = level; } -export type MillisecondsSinceUnixEpoch = number; +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 @@ -156,6 +286,226 @@ 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. * @@ -197,8 +547,6 @@ export class Filter { } } -export type FilterCallable = (record: LogRecord) => boolean; - export class Filterer { filters: Filter[] = []; @@ -264,50 +612,336 @@ export class Filterer { } } -function checkLevel(level: number|string): number { - var rv: number; +var throwErrors: boolean = true; - if (typeof level == 'number') { rv = level } +//--------------------------------------------------------------------------- +// Handler classes and functions +//---------------------------------------------------------------------------- - else if (typeof level == 'string') { - if (!Object.keys(NAMETOLEVEL).includes(level as string)) { - throw new Error(`Unknown level: ${level}`) - } +type Handlers = {[key: string]: Handler}; - rv = NAMETOLEVEL[level] - } +/** + * map of handler names to handlers + */ +const HANDLERS: Handlers = {}; - else { - throw new Error(`Level not a number or valid string: ${level}`) - } +/** + * added to allow handlers to be removed in reverse order of initialization + */ +const HANDLER_LIST: WeakRef[] = []; - return rv +/** + * Add a handler to the internal cleanup list using a weak reference. + * + * @param handler - + */ +function addHandlerRef(handler: Handler) { + HANDLER_LIST.push(new WeakRef(handler)); } /** - * 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. + * Get a handler with the specified *name*, or None if there isn't one with + * that name. */ -export function setLogRecordFactory(factory: LogRecordFactory) { - logRecordFactory = factory +export function getHandlerByName(name: string): Handler|null { + return HANDLERS[name] ?? null } -export function getLogRecordFactory(): LogRecordFactory { - return logRecordFactory +/** + * 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 } } -const DEFAULTLOGOPTIONS: LogOptions = { +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 -} - -export type LoggerClass = { new(): Logger }; - -var throwErrors: boolean = true; +}); /** * Instances of the logger class represent a single logging channel. A 'logging @@ -326,10 +960,10 @@ var throwErrors: boolean = true; export class Logger extends Filterer { public readonly scope: string; public _level: number; - public readonly manager: Manager|null = null; - public readonly parent_: Logger|null = null; + private _manager: Manager|null = null; + public readonly parent: Logger|null = null; public readonly propagate: boolean = true; - public readonly handlers: any[] = []; + public readonly handlers: Handler[] = []; public readonly disabled: boolean = false; private cache: {[key: number]: boolean} = {}; @@ -340,20 +974,26 @@ export class Logger extends Filterer { * @param level - * @param manager - */ - constructor(scope: string, level?: LogLevel, manager?: Manager) { + constructor( + scope: string, + level?: LogLevel, + ) { super(); this.scope = scope; - this._level = checkLevel(level ?? NOTSET); - - this.manager = manager ?? null; } 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); @@ -371,7 +1011,7 @@ export class Logger extends Filterer { while (logger) { if (logger.level) { return logger.level } - logger = logger.parent_ + logger = logger.parent; } return NOTSET; @@ -448,8 +1088,8 @@ export class Logger extends Filterer { * handlers of this logger to handle the record. */ protected _log(level: LogLevel, msg: string, options?: LogOptions) { - options = options ?? DEFAULTLOGOPTIONS; - options = { ...DEFAULTLOGOPTIONS, ...options }; + options = options ?? DEFAULT_LOG_OPTIONS; + options = { ...DEFAULT_LOG_OPTIONS, ...options }; var sinfo=null; @@ -506,7 +1146,7 @@ export class Logger extends Filterer { } if (!c.propagate) { c = null } - else { c = c.parent_ } + else { c = c.parent } } if (found == 0) { @@ -528,6 +1168,22 @@ export class Logger extends Filterer { 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] } + } } /** @@ -537,384 +1193,215 @@ export class Logger extends Filterer { */ class RootLogger extends Logger { - constructor(level: LogLevel, manager?: Manager) { - super('root', level, manager); + constructor(level: LogLevel) { + super('root', level); } } var loggerClass = Logger; /** - * Placeholder instance + * root logger (singleton) */ -export class Placeholder { - protected loggers: Logger[] = []; +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; /** - * initialize with the specified logger being a child of this placeholder. + * Specifies the mode to open the file, if filename is specified (if + * filemode is unspecified, it defaults to 'a') */ - constructor(logger: Logger) { - this.push(logger); - } + filemode?: string; /** - * add the specified logger as a child of this placeholder + * Use the specified format string for the handler. */ - public push(logger: Logger) { - if (!this.loggers.includes(logger)) { this.loggers.push(logger) } - } + 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; } /** - * There is [under normal circumstances] just one Manager intance, which holds - * the hierarchy of loggers. + * 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 class Manager { - public readonly rootLogger: RootLogger; - protected _disable: number = 0; - public emittedNoHandlerWarning: boolean = false; - protected loggers: {[key: string]: Logger} = {}; - protected _loggerClass: LoggerClass|null = null; - protected _logRecordFactory: LogRecordFactory|null = null; +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; - 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(logger: RootLogger) { - this.rootLogger = logger; + if (!Object.keys(STYLES).includes(style)) { + throw new ValueError( + `style must be one of: ${Object.keys(STYLES).join(', ')}` + ); } - /** - * 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, this) - - } - else { - rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET, this) - this.loggers[scope] = rv - } + 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(); } } - /** - * 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() - }); - } -} - -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 (!this.fmt.search(PercentFormatterStyle.validationPattern)) { + if (handlers.length == 0) { + if (handlers === null && stream && filename) { throw new ValueError( - `Invalid format '${this.fmt}' for ` + - `'${PercentFormatterStyle.defaultFormat[0]}'` - ) + "'stream' and 'filename' should not be specified together" + ); } - } - 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) + else if (stream || filename) { + throw new ValueError( + "'stream' or 'filename' should not be specified together" + + "with 'handlers'" + ); } - catch (e) { - throw new ValueError(`formatting field not found in record: ${e}`) + + 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}`); } } } - -const basicFormat = '%(level)s:%(name)s:%(message)s'; - -const styles: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = { - '%': [PercentFormatterStyle, basicFormat], -} - -export interface FormatterOptions { - fmt?: string - datefmt?: any - style?: string - validate?: boolean - defaults?: {[key: string]: any} -} - -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) { - 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 handlers: {[key: string]: Handler} = {}; // map of handler names to - // handlers -const handlerList: WeakRef[] = []; // added to allow handlers to be removed in reverse - // order of initialization - -function addHandlerRef(handler: Handler) { - handlerList.push(new WeakRef(handler)); -} - -const defaultFormatter = new Formatter({}); - -export class Handler extends Filterer { - - protected _scope: string|null = null; - protected formatter: Formatter|null = null; - protected _level: number; - protected _closed: boolean = false; - - constructor(level: LogLevel) { - super(); - this._level = checkLevel(level); - // Add the handler to the global handlerList (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 = defaultFormatter } - } - - /** - * 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...' - ) - } -} - -export class StderrHandler extends Handler { - - constructor(level: LogLevel) { - super(level); - } -} - -const defaultLastResort = new StderrHandler(WARNING); -export var lastResort = defaultLastResort; diff --git a/src/logging/logger.ts b/src/logging/logger.ts new file mode 100644 index 0000000..572e360 --- /dev/null +++ b/src/logging/logger.ts @@ -0,0 +1,951 @@ +/** + * This module defines functions and classes which implement a flexible event + * logging system for applications and libraries. + * + * The key benefit of having the logging API provided by a standard library + * module is that all Python modules can participate in logging, so your + * application log can include your own messages integrated with messages from + * third-party modules. + * + * Here’s a simple example of idiomatic usage: + * + * ```javascript + * // myapp.ts + * import * as logging from 'eslib/logging'; + * import * as mylib from './mylib'; + * const logger = logging.getLogger(__name__); + * + * function main() { + * logging.basicConfig({filename: 'myapp.log', level: logging.INFO}); + * logger.info('Started'); + * mylib.doSomething(); + * logger.info('Finished'); + * } + * + * main(); + * ``` + * + * ```javascript + * // mylib.ts + * import * as logging from 'eslib/logging'; + * const logger = logging.getLogger(__name__); + * + * function do_something() { + * logger.info('Doing something') + * } + * ``` + * + * If you run `myapp.ts`, you should see this in myapp.log: + * + * ``` + * INFO:__main__:Started + * INFO:mylib:Doing something + * INFO:__main__:Finished + * ``` + * + * The key feature of this idiomatic usage is that the majority of code is + * simply creating a module level logger with `getLogger(__name__)`, and using + * that logger to do any needed logging. This is concise, while allowing + * downstream code fine-grained control if needed. Logged messages to the + * module-level logger get forwarded to handlers of loggers in higher-level + * modules, all the way up to the highest-level logger known as the root logger; + * this approach is known as hierarchical logging. + * + * For logging to be useful, it needs to be configured: setting the levels and + * destinations for each logger, potentially changing how specific modules log, + * often based on command-line arguments or application configuration. In most + * cases, like the one above, only the root logger needs to be so configured, + * since all the lower level loggers at module level eventually forward their + * messages to its handlers. basicConfig() provides a quick way to configure the + * root logger that handles many use cases. + * + * The module provides a lot of functionality and flexibility. If you are + * unfamiliar with logging, the best way to get to grips with it is to view the + * tutorials (see the links above and on the right). + * + * The basic classes defined by the module, together with their attributes and + * methods, are listed in the sections below. + * + * * Loggers expose the interface that application code directly uses. + * * Handlers send the log records (created by loggers) to the appropriate + * destination. + * * Filters provide a finer grained facility for determining which log records + * to output. + * * Formatters specify the layout of log records in the final output. + * @module logging + */ +import { NotImplementedError, MyError, ValueError, KeyError, StackTrace } from '../error'; + +export const CRITICAL = 50; +export const FATAL = CRITICAL; +export const ERROR = 40; +export const WARNING = 30; +export const WARN = WARNING; +export const INFO = 20; +export const DEBUG = 10; +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, +} + +export type LogLevel = number; + +export type ExecutionInfo = [string, Error, StackTrace]; + +/** + * context of a logging event/trigger + */ +export interface LogOptions{ + excInfo: ExecutionInfo|Error|null, + extra: {[key: string]: any}|null, + stackInfo: boolean, + stackLevel: number +} + +/** + * options for instantiating a new log record + */ +export interface LogRecordOptions { + level: number, + file?: string, + lno?: number, + msg: string, + args?: any[], +} + +export type LogRecordFactory = { (name: string, options: LogRecordOptions): LogRecord }; + +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 type MillisecondsSinceUnixEpoch = number; + +/** + * 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) +}; + +/** + * 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 type FilterCallable = (record: LogRecord) => boolean; + +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 + } +} + +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 +} + +/** + * 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 +} + +const DEFAULTLOGOPTIONS: LogOptions = { + excInfo: null, + extra: null, + stackInfo: false, + stackLevel: 1 +} + +export type LoggerClass = { new(): Logger }; + +var throwErrors: boolean = true; + +/** + * 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; + public readonly manager: Manager|null = null; + public readonly parent_: Logger|null = null; + public readonly propagate: boolean = true; + public readonly handlers: any[] = []; + 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, manager?: Manager) { + super(); + + this.scope = scope; + + this._level = checkLevel(level ?? NOTSET); + + this.manager = manager ?? null; + } + + public get level() { return this._level } + + public set level(level: LogLevel) { this._level = checkLevel(level) } + + 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 ?? DEFAULTLOGOPTIONS; + options = { ...DEFAULTLOGOPTIONS, ...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]; + } +} + +/** + * 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, manager?: Manager) { + super('root', level, manager); + } +} + +var loggerClass = Logger; + +/** + * 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 rootLogger: 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(logger: RootLogger) { + this.rootLogger = logger; + } + + /** + * 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, this) + + } + else { + rv = new (this._loggerClass ?? loggerClass)(scope, NOTSET, this) + 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() + }); + } +} + +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 basicFormat = '%(level)s:%(name)s:%(message)s'; + +const styles: {[key: string]: [{ new(options: PercentFormatterStyleOptions): PercentFormatterStyle}, string]} = { + '%': [PercentFormatterStyle, basicFormat], +} + +export interface FormatterOptions { + fmt?: string + datefmt?: any + style?: string + validate?: boolean + defaults?: {[key: string]: any} +} + +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 handlers: {[key: string]: Handler} = {}; // map of handler names to + // handlers +const handlerList: WeakRef[] = []; // added to allow handlers to be removed in reverse + // order of initialization + +function addHandlerRef(handler: Handler) { + handlerList.push(new WeakRef(handler)); +} + +const defaultFormatter = new Formatter(); + +export class Handler extends Filterer { + + protected _scope: string|null = null; + protected formatter: Formatter|null = null; + protected _level: number; + protected _closed: boolean = false; + + constructor(level: LogLevel) { + super(); + this._level = checkLevel(level); + // Add the handler to the global handlerList (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 = defaultFormatter } + } + + /** + * 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...' + ) + } +} + +export class StderrHandler extends Handler { + constructor(level: LogLevel) { + super(level); + } +} + +const defaultLastResort = new StderrHandler(WARNING); +export var lastResort = defaultLastResort; diff --git a/src/stream.ts b/src/stream.ts new file mode 100644 index 0000000..297e20c --- /dev/null +++ b/src/stream.ts @@ -0,0 +1,2 @@ + +export type MillisecondsSinceUnixEpoch = number; diff --git a/tests/logging.test.ts b/tests/logging.test.ts index 0deaef3..a8ad3b7 100644 --- a/tests/logging.test.ts +++ b/tests/logging.test.ts @@ -1,7 +1,84 @@ -import * as logging from '../src/logging'; +import {expect, jest, test} from '@jest/globals'; describe('Logger', () => { it('can be instantiated', () => { //const logger = new logging.Logger('test', 0); }) }); + +describe('getLevelName', () => { + var logging: any; + + beforeEach(() => { + logging = require('../src/logging'); + }); + + it('numeric to textual representation of built-ins', () => { + expect( + logging.getLevelName(logging.CRITICAL) + ).toBe('CRITICAL'); + expect( + logging.getLevelName(logging.FATAL) + ).toBe('CRITICAL'); + expect( + logging.getLevelName(logging.ERROR) + ).toBe('ERROR'); + expect( + logging.getLevelName(logging.WARNING) + ).toBe('WARNING'); + expect( + logging.getLevelName(logging.WARN) + ).toBe('WARNING'); + expect( + logging.getLevelName(logging.INFO) + ).toBe('INFO'); + expect( + logging.getLevelName(logging.DEBUG) + ).toBe('DEBUG'); + expect( + logging.getLevelName(logging.NOTSET) + ).toBe('NOTSET'); + }); + + it('textual to numeric representation of built-ins', () => { + expect( + logging.getLevelName('CRITICAL') + ).toBe(logging.CRITICAL); + expect( + logging.getLevelName('FATAL') + ).toBe(`Level FATAL`); + expect( + logging.getLevelName('ERROR') + ).toBe(logging.ERROR); + expect( + logging.getLevelName('WARNING') + ).toBe(logging.WARNING); + expect( + logging.getLevelName('WARN') + ).toBe('Level WARN'); + expect( + logging.getLevelName('INFO') + ).toBe(logging.INFO); + expect( + logging.getLevelName('DEBUG') + ).toBe(logging.DEBUG); + expect( + logging.getLevelName('NOTSET') + ).toBe(logging.NOTSET); + }); +}); + + +describe('addLevelName', () => { + var logging: any; + + beforeEach(() => { + logging = require('../src/logging'); + }); + + it('numeric to textual representation of built-ins', () => { + logging.addLevelName(80, 'FOOBAR'); + expect(logging.getLevelName(80)).toBe('FOOBAR'); + expect(logging.getLevelName('FOOBAR')).toBe(80); + }) +}); diff --git a/tsconfig.json b/tsconfig.json index d921ac3..273eaef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "outDir": "build/production", "baseUrl": "./", "paths": { "*": ["node/modules/*"] }, - "lib": ["esnext.weakref"] + "lib": ["esnext.weakref", "dom"] }, "include": [ "src/**/*.ts" diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..e0eebe9 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,4 @@ +{ + "out": "build/doc", + "readme": "README.md" +}