diff --git a/di/log/init.q b/di/log/init.q new file mode 100644 index 0000000..b15514b --- /dev/null +++ b/di/log/init.q @@ -0,0 +1,7 @@ +/ Load core functionality into root module namespace +\l ::log.q + +export:([ + createLog:createLog + ]) + diff --git a/di/log/log.md b/di/log/log.md new file mode 100644 index 0000000..1366f50 --- /dev/null +++ b/di/log/log.md @@ -0,0 +1,410 @@ +# Log module reference + +This module is based off the base kdb-x logging module provided by KX + +The kdb-x logging module is a simple logging framework. + +For the purpose of documenting, the coded examples provided here assume the logging library has been loaded into the namespace `.logger`, and a logging instance has been created under the `.log` namespace like so: + +```q +.logger:use`di.log +.log:.logger.createLog[] +``` + +But remember, under the kdb-x module framework this can be loaded into a project under any name desired. + +## Instance Creation + +The logging module supports multiple independent log instances within a single q process. This is achieved using a factory design pattern: the module exposes a single function, `createLog`, which returns a fully initialized, independent, logger instance. + +```q +.logger.createLog[] +``` + +This returns a dictionary containing all logging APIs, state and accessors so multiple loggers can operate independently. + +This is useful when a single q process has multiple sections, and a particular section needs to be debugged. E.g. + +```q +q).logger:use`di.log; +q)\d .foo +q.foo)logger:.logger.createLog[]; +q.foo)f:{logger.trace "trace from foo"}; +q.foo)\d .bar +q.bar)logger:.logger.createLog[]; +q.bar)f:{logger.trace "trace from bar";logger.warn "warn from bar"}; +q.bar)\d . +q).foo.f[]; // expect no output since default log level is info +q).bar.f[]; // expect output since there is a warn log +2025.10.23D11:09:47.645765308 warn PID[94486] HOST[a-lptp-p2myzzg4] warn from bar +q).foo.logger.setlvl `trace; // change foo log level to trace +q).foo.f[]; // shows trace +2025.10.23D11:10:18.631553544 trace PID[94486] HOST[a-lptp-p2myzzg4] trace from foo +q).bar.f[]; // still only shows warn since log level wasn't chanted for bar +2025.10.23D11:10:34.057420473 warn PID[94486] HOST[a-lptp-p2myzzg4] warn from bar +``` + +## Logging APIs + +To log to a particular level, run with `.log.xxxx`, where `xxxx` is one of: + + + +- `trace` +- `debug` +- `info` +- `warn` +- `error` +- `fatal` + +These can each be called in the following ways +>**Note:** Referencing `info` in the following examples but this extends to each of the above APIs + +### Simple Logging + +```q +.log.info[message] +``` + +Where `message` is either +- A string +- A general list (see [variable logging](#variable-logging)) + +```q +q).log.info "Hello World!" +2025.10.13D12:37:12.911621911 info PID[658720] HOST[a-lptp-p2myzzg4] Hello World! +``` + +### Variable Logging + +Dependent on di.printf module - this has been derived from KX's [`printf`](https://github.com/KxSystems/printf) module. + +Variables can be included in log messages using arguments in junction with format specifiers. + +```q +.log.info (message;arg1;arg2;...) +``` + +Where `arg1`,`arg2`,... are the variables to be formatted in the message. The number of arguments depends on the number of format specifiers are in the message string. + +```q +q).log.info ("myvar: %r";`foo) +2025.10.30D10:22:19.124336219 info PID[1937319] HOST[a-lptp-p2myzzg4] myvar: `foo +q).log.info ("var1: %s, var2: %r";`foo;123) +2025.10.30D10:22:38.850194514 info PID[1937319] HOST[a-lptp-p2myzzg4] var1: foo, var2: 123 +q).log.info ("var1: %r, var2: %s";"\t tab";"\t tab") +2025.10.30D10:22:52.071370289 info PID[1937319] HOST[a-lptp-p2myzzg4] var1: "\t tab", var2: tab +q).log.info ("formatted float: %08.3f";3.1415926536) +2025.10.30D10:23:34.987634986 info PID[1937319] HOST[a-lptp-p2myzzg4] formatted float: 3.142 +``` + +## Log Levels + +Log levels specify at which severity to report. + +### Get Level + +_Get the current log level_ + +```q +.log.getlvl[] +``` + +Returns the current log `level` (symbol) + +```q +q).log.getlvl[] +`info +``` + +### Set Level + +_Set the log level_ + +```q +.log.setlvl[level] +``` + +Where `level` is an [available log level](#log-levels) + +```q +q).log.info "hi" +2025.10.13D11:39:51.014213515 info PID[489737] HOST[a-lptp-p2myzzg4] hi +q).log.setlvl `error +q).log.info "you can't see me" +``` + +## Formats + +Formats dictate how a log message is formatted. + +### Replacement Rules + +The following are the replacement rules for a formatter: + +| Pattern | Replacement | Notes | +|---------|------------------------------|---------------------------------| +| $a | .z.a | IP address | +| $c | .z.c | cores | +| $f | .z.f | file | +| $h | .z.h | host | +| $i | .z.i | PID | +| $K | .z.K | version | +| $k | .z.k | release date | +| $o | .z.o | OS version | +| $u | .z.u | user ID | +| $d | .z.d | UTC date | +| $D | .z.D | local date | +| $n | .z.n | UTC timespan | +| $N | .z.N | local timespan | +| $p | .z.p | UTC timestamp | +| $P | .z.P | local timestamp | +| $t | .z.t | UTC time | +| $T | .z.T | local time | +| $z | .z.z | UTC datetime | +| $Z | .z.Z | local datetime | +| $l | log level of this message | | +| $m | user provided log message | | +| $w | " " sv string system"w" | current memory usage | +| $s | syslog level of this message | converts `info to 6 for example | + +### Provided Formats + +This module provides off the shelf the following formats: + +- __basic__ +```q +"$p $l PID[$i] HOST[$h] $m\r\n" +``` + +- __syslog__ +```q +"<$s> $m\r\n" +``` + +- __raw__ +```q +"$m\r\n" +``` + +>**Tip:** Use the raw format in junction with `.j.j` to log json +> +> ```q +> q).log.setfmt `raw +> q).log.info .j.j `foo`bar!til 2 +> {"foo":0,"bar":1} +> ``` + +### List Formats + +_List registered formats_ + +```q +.log.fmts[]; +``` + +Returns a dictionary, where the keys are the names of the registered format, and the values are the format rules. + +```q +q).log.fmts[] +basic | "$p $l PID[$i] HOST[$h] $m\r\n" +syslog| "<$s> $m\r\n" +raw | "$m\r\n" +``` + +### Set Format + +_Set log format_ + +```q +.log.setfmt[name] +``` + +Where `name` is a registered format, visible in `.log.fmts[]`. + +```q +q).log.info "hi" +2025.10.13D11:16:34.445967945 info PID[408461] HOST[a-lptp-p2myzzg4] hi +q).log.setfmt `syslog +q).log.info "hi" +<6> hi +q).log.setfmt `raw +q).log.info .j.j `foo`bar!til 2 +{"foo":0,"bar":1} +``` +### Get Format + +_Check current log format_ + +```q +.log.getfmt[] +``` + +Returns the current format as a symbol + +```q +q).log.getfmt[] +`basic +``` + +### Add Formats + +_Register custom format_ + +```q +.log.addfmt[name;format] +``` + +Where +- `name` is a name to register the new format as (symbol) +- `format` is a string defining the format of the output (string) + +```q +q).log.addfmt[`foo;"bar: $m\r\n"] +q).log.setfmt`foo +q).log.info "lorem" +bar: lorem +``` + +## Sinks + +Sinks are the destination for logs, can be qipc, file or system handles. + +### List Sinks + +_List the sinks_ + +```q +.log.sinks[] +``` + +Returns a dictionary, with the keys of the available levels, and the values of the handles subscribed to that level. + +```q +q).log.sinks[] +trace| 1 +debug| 1 +info | 1 +warn | 1 +error| 2 +fatal| 2 +``` + +### Remove + +_Remove a handle from a level_ + +```q +.log.remove[handle;level] +``` + +Where + +- `handle` is an integer, +- `level` is a log level + +removes the handle from the level. Returns the handle removed + +```q +q).log.sinks[] +trace| 1 +debug| 1 +info | 1 +warn | 1 +error| 2 +fatal| 2 +q).log.info "hi" +2025.10.13D11:55:51.028205975 info PID[521724] HOST[a-lptp-p2myzzg4] hi +q).log.remove[1;`info] +1 +q).log.info "you can't see me" +``` + +### Add + +The add API has two different ways of being called + +#### File and System Handles + +_Add a file or system sink to a level_ + +```q +.log.add[handle;level] +``` + +Where + +- `handle` is an integer +- `level` is a log level + +adds the handle to the level. Returns the handle added + +```q +q).log.sinks[] +trace| 1 +debug| 1 +info | 1 +warn | 1 +error| 2 +fatal| 2 +q).log.error "hi" // logs to stderr system handle +2025.10.13D12:00:56.977883075 error PID[549311] HOST[a-lptp-p2myzzg4] hi +q).log.add[1;`error] +1 +q).log.error "hi" // logs to both stderr and stdout system handles +2025.10.13D12:01:45.822436756 error PID[549311] HOST[a-lptp-p2myzzg4] hi +2025.10.13D12:01:45.822436756 error PID[549311] HOST[a-lptp-p2myzzg4] hi +``` + +#### qIPC Handles + +qIPC handles require additionally a function to call on the server side. + +_Add a qipc sink to a level_ + +```q +.log.add[(handle;function);level] +``` + +Where + +- `handle` is an integer +- `function` is a dyadic function that accepts arguments of [handle;message], where message is the formatted string representation of a log call +- `level` is a log level + +adds the qIPC handle to the level. Returns the handle added and function. + + +```q +q)\q -p 5000 +q)system "sleep 1" // give the process time to start before connecting +q)h:hopen 5000 +q).log.add[(h;{x@({-1 reverse x};y)});`info] +4i +{x@({-1 reverse x};y)} +q).log.info "hi" +2025.10.13D12:12:20.903165119 info PID[579844] HOST[a-lptp-p2myzzg4] hi + +ih ]4gzzym2p-ptpl-a[TSOH ]448975[DIP ofni 911561309.02:21:21D31.01.5202 +``` + +## Disconnects + +When attempting to log to a non-existent handle, the handle is removed from all levels in `sink` and a warning message is displayed `("lost connection to handle %1, dropping";handle)` + +```q +q).log.add[999i;`info] // handle 999i is not a valid handle +999i +q).log.info "hi" +2025.10.13D12:14:54.784304296 info PID[593137] HOST[a-lptp-p2myzzg4] hi +2025.10.13D12:14:54.784402446 warn PID[593137] HOST[a-lptp-p2myzzg4] lost connection to handle 999, dropping +``` + +```q +q).log.add[(999i;{x@({-1 reverse x};y)});`info] // handle 999i is not a valid qIPC handle +999i +{x@({-1 reverse x};y)} +q).log.info "hi" +2025.10.13D12:19:48.981780007 info PID[607636] HOST[a-lptp-p2myzzg4] hi +2025.10.13D12:19:48.981878933 warn PID[607636] HOST[a-lptp-p2myzzg4] lost connection to handle 999, dropping +``` diff --git a/di/log/log.q b/di/log/log.q new file mode 100644 index 0000000..db768ab --- /dev/null +++ b/di/log/log.q @@ -0,0 +1,57 @@ +// CRLF vs LF +nl:$[.z.o in `w32`w64;"\r\n";"\n"]; +// Patterns +pattern:handler:()!(); +{pattern[x]:{string value ".z.",z}[;;x]} each "acfhiKkoudDnNpPtTzZ"; // Make a pattern for each relevant .z namespace var +pattern["l"]:{[x;y] string x}; // level +pattern["m"]:{[x;y] y}; // message +pattern["w"]:{[x;y] " " sv string system"w"}; // workspace +pattern["s"]:{[x;y] string syslogLvl x}; // syslog level +pattern["~"]:{[x;y] "$"}; // Escape character handling +// Pattern for user substitution +usr:"sr~"!({$[10h~abs tx:type x;x;-11h~tx;string x;'`type]};.Q.s1;"%"); +// Levels +lvls:`trace`debug`info`warn`error`fatal; +// syslog level lookup +syslogLvl:lvls!7 7 6 4 3 2i; + +// Helper functions for splitting on delemiter, replacing and reconstructing +prep:{[del;rep;msg]msg:ssr[msg;del,del;del,"~"];v:rep@first@/:m1:1 _ m:del vs msg;(enlist[first m],1_/:m1;v)}; +construct:{raze first[y],x,'1 _ y}; + +([printf]):use`di.printf; + +// Object Instantiation +i:0; +gn:{` sv (.z.M.log;`$string x;y)}; // get name +createLog:{ + i+:1; + gni:gn[i;]; // get incremented name + gv:{[x;y] get x[y]}[gni;]; // get incremented value + d:([ + handler:()!(); + formats:([basic:"$p $l PID[$i] HOST[$h] $m",nl;syslog:"<$s> $m",nl;raw:"$m",nl]); // Premade list of formats + sink:lvls!(); // Sink Initialisation + getfmt:{[gv;x]gv[`formats]?gv`fmt}[gv;]; // Get and set the current formatter + setfmt:{[gni;gv;x]gni[`fmt] set gv[`formats] x;(gni`m;gni`v) set' prep["$";pattern;gv`fmt];}[gni;gv;]; + addfmt:{[gni;x;y]@[gni[`formats];x;:;y]}[gni;;]; // Give a user the ability to add their own formatter + add:{[gni;x;y]h:x;(x;f):$[1=).{lvls?x} each x,gv`lvl;{[gv;gni;x;y] + @[gv[`handler][x]x;y;{[gni;h;e]gni[`remove][h;] each lvls;gni[`warn] ("lost connection to handle %r, dropping";h)}[gni;x;]]}[gv;gni;;gv[`formatter][x] printf y]@/:gv[`sink][x]]}[gv;gni;;]@/:lvls; + // Add error trapping + d,:lvls!@[;;{x}]@/:fns; + // Set dictionary in the incremented namespace + (gni each key d) set' value d; + // Initialisation + d[`add][1;`trace`debug`info`warn];d[`add][2;`error`fatal]; + // Default format basic + d[`setfmt] `basic; + // Default level info + d[`setlvl] `info; + ([fmts:gni `formats;sinks:gni `sink;getlvl:gni `lvl]),(lvls,`getfmt`setfmt`addfmt`add`remove`setlvl)#d + }; diff --git a/di/log/test.csv b/di/log/test.csv new file mode 100644 index 0000000..4e33b17 --- /dev/null +++ b/di/log/test.csv @@ -0,0 +1,18 @@ +action,ms,bytes,lang,code,repeat,minver,comment +before,0,0,q,.l:use`di.log,1,,Load in log module +before,0,0,q,.log:.l.createLog[],1,,Create the log functions +before,0,0,q,.log.remove[1;`info`warn],1,,Remove stdout logging for testing +before,0,0,q,.log.remove[2;`error],1,,Remove stderr logging for testing +before,0,0,q,n:hsym `$"test_info_log.txt",1,,Set temp log file for stdout +before,0,0,q,m:hsym `$"test_error_log.txt",1,,Set temp log file for stderr +before,0,0,q,hout:hopen n,1,,Open handle to temp log file for stdout +before,0,0,q,herr:hopen m,1,,Open handle to temp log file for stderr +before,0,0,q,.log.add[hout;`info`warn],1,,Add handle to info and warn levels +before,0,0,q,.log.add[herr;`error],1,,Add handle to error level +run,0,0,q,.log.info"Hello World!",1,,Log info level +run,0,0,q,.log.warn"Hello Warning!",1,,Log warn level +run,0,0,q,.log.error"This is an error!",1,,Log error level +true,0,0,q,2~count read0 n,1,,Check count of info+warn logs +true,0,0,q,1~count read0 m,1,,Check count of error logs +after,0,0,q,hdel n,1,,Delete info log +after,0,0,q,hdel m,1,,Delete err log diff --git a/di/printf/init.q b/di/printf/init.q new file mode 100644 index 0000000..7428ddc --- /dev/null +++ b/di/printf/init.q @@ -0,0 +1,7 @@ +/ Load core functionality into root module namespace +\l ::printf.q + +export:([ + printf:printf + ]) + diff --git a/di/printf/printf.md b/di/printf/printf.md new file mode 100644 index 0000000..6bd40cd --- /dev/null +++ b/di/printf/printf.md @@ -0,0 +1,106 @@ + +# Printf module reference + +The DI printf module replicates the core functionality provided by KX's printf module. + +The kdb-x printf module replicates a subset of the C99 printf standard to format strings. + +For the purpose of documenting, the coded examples provided here assume the printf module has been loaded into the root namespace: + +```q +([printf]):use`di.printf +``` + +But remember, under the kdb-x module framework this can be loaded into a project under any name desired. + +## Specification + +The C standard library printf specification can be found [here](https://man7.org/linux/man-pages/man3/printf.3.html). Rather than rehashing the specification, this documentation will note deviations in behaviour from the specification. + +For reference, the overall syntax from the C standard spec is: + +``` +%[argument$][flags][width][.precision][length modifier]conversion +``` + +This module deviates from this in the following ways: + +- The C99 specification which this module mirrors does not include the UNIX Specification of repeated references to the same argument. So the `[argument$]` component of the conversion specification is dropped +- Due to the differing type systems between C and q, the length modifier field doesn't translate well to q. As such, this component of the conversion specification is also dropped + +Hence, the specification for this module is as follows: + +``` +%[flags][width][.precision]conversion +``` + +### Flags + +Of the C99 standard flags available, only the "alternate form" is excluded. + +| Text |Description| +|:---------:|:-----------:| +| - | Left-align the output of this placeholder; default is to right-align the output| +| + | Prepends a plus sign for a positive value; by default a positive value does not have a prefix| +| (space) | Prepends a space character for a positive value; ignored if the + flag exists; by default a positive value does not have a prefix| +| 0 | When the 'width' option is specified, prepends zeros instead of spaces for numeric types; for example, printf("%4X",3) produces " 3", while printf("%04X",3); produces "0003"| + +### Width + +The width field operates the same as the C99 standard, with the exception of the `*` form, where one argument is used to specify the width of the variable being replaced. If width needs to be variable it's recommended to handle this in the user provided string. E.g. + +```q +q)vwidth:8; +q)printf ("Variadic width %",string[vwidth],".2f"; 1.61803) +"Variadic width 1.62" +``` + +### Precision + +The precision field operates the same as the C99 standard printf. + +### Type Field + +Due to the differing type systems between C and q, there are a number of excluded fields, and one added field: + +Differences are: +- `i` is removed, as the only difference with `d` is use in junction with `scanf` +- `u` is removed, as unsigned integers don't exist in q +- `F` is removed, as the only difference with `f` is capitals on nulls or infinities. q's printf will just print 0w, 0n +- `p` is removed, as pointers aren't considered +- `n` is removed +- `a` and `A` are removed +- `r` is added for representations of types that don't exist (`p`, `n` ...) + +The amended table is: + +| Text |Description| +|:----:|:-----------:| +| % | Output a literal % character; does not accept flags, width, precision or length fields| +| d | long | +| f | float | +| x, X | hexadecimal representation | +| o | octal representation | +| s | string or symbol | +| r | string representations of q types that don't exist in C| + +Some notes: + +- The string flag is permissive, it's a no-op conversion on strings, it runs `string` on symbols, and calls .Q.s1 if it's a different type. +- For long and float these are cast with `"j"` and `"f"` respectively, if a string is provided as the argument for the conversion specification in junction with a `%d` or `%j` type field the cast is attempted with `"J"` and `"F"` respectively. +- `%r` simply interprets the argument with .Q.s1, this is useful for q types that don't have a direct C type mapping (timestamps, dictionaries etc.) +- Hexadecimal and octal representation uses the approach noted in [casting](https://code.kx.com/phrases/cast/) +- For floats, IEEE754 precision format is utilised via [`-27!`](https://code.kx.com/q/basics/internal/#-27xy-ieee754-precision-format) + +## Printf + +```q +printf[message] +``` + +Where `message` is either +- A string +- A general list, where + - The first item is a string with conversion specifiers + - The following items are variables to be formatted as specified by the conversion specifiers + diff --git a/di/printf/printf.q b/di/printf/printf.q new file mode 100644 index 0000000..a7e4094 --- /dev/null +++ b/di/printf/printf.q @@ -0,0 +1,94 @@ +validFlags:"-+ 0"; +HEX:"0123456789abcdef"; + +typeConversions:([ + d:{$[10h~type x;"J"$x;"j"$x]}; + f:{$[10h~type x;"F"$x;"f"$x]}; + x:{{$[10h~type x;x;ltrim raze " ",'flip x]} HEX 16 vs "i"$x}; + X:{upper {$[10h~type x;x;ltrim raze " ",'flip x]} HEX 16 vs "i"$x}; + o:{{$[10h~type x;x;ltrim raze " ",'flip x]} HEX 8 vs "i"$x}; + O:{upper {$[10h~type x;x;ltrim raze " ",'flip x]} HEX 8 vs "i"$x}; + s:{$[10h~abs tx:type x;x;-11h~tx;string x;.Q.s1 x]}; + r:.Q.s1]); + +typePrecisions:([ + d:{a:string x;if[y~"";:a];c:count a;if[c>=y;:a]; ((y-c)#"0"),a}; + f:{if[y~"";:{.Q.fmt[x+1+count string floor y;x;y]}[6;x]];-27!(`int$y;x)}; + x:{a:x;if[y~"";:a];c:count a;if[c>=y;:a]; ((y-c)#"0"),a}; + X:{a:x;if[y~"";:a];c:count a;if[c>=y;:a]; ((y-c)#"0"),a}; + o:{a:x;if[y~"";:a];c:count a;if[c>=y;:a]; ((y-c)#"0"),a}; + O:{a:x;if[y~"";:a];c:count a;if[c>=y;:a]; ((y-c)#"0"),a}; + s:{if[y~"";:x];y sublist x}; + r:{[x;y]x}]); + +typeFlags:string key typeConversions; + +fwn:{nxy:not x in y;i:first where nxy;a:i#x;b:i _x;(a;b)}; + +vconst:{[flags;width;precision;ty;variable] + (fneg;fpos;fspace;fzero):validFlags in flags; + fpres:0b; + if[ty~"";:""]; // early exit if nothing + a:typeConversions[`$ty][variable]; + if[not ""~precision;precision:"J"$1 _ precision;fpres:1b]; + res:typePrecisions[`$ty][a;precision]; + if[ty in "fd";if[a>=0;res:$[fpos;"+";fspace;" ";""],res]]; + if[width~"";:res]; + pad:("J"$width)-count res; + if[pad>0; + res:$[fneg; + res,pad#" "; + fzero & (not[fpres] or ty in "f") & not ty in "sr"; + $[first[res] in "+-";first[res],(pad#"0"),1 _ res;(pad#"0"),res]; + (pad#" "),res]]; + res + }; + +vrep:{[str;variable] + fmt:1 _ str; + (flags;fmt):fwn[fmt;validFlags]; + (width;fmt):fwn[fmt;.Q.n]; + (precision;fmt):fwn[fmt;.Q.n,"."]; + ty:$[first[fmt] in typeFlags;first fmt;""]; + fmt:1 _fmt; + parsed:vconst[flags;width;precision;ty;variable]; + (parsed;fmt) + }; + +process:{[d;variable] + // if no % then everything is parsed + if[not any bs:"%"=d`unparsed; + d[`parsed],:d[`unparsed]; + d[`unparsed]:""; + :d + ]; + // make the parse just look from the start + if[not first bs; + d[`parsed],:first[where bs] # d`unparsed; + d[`unparsed]:first[where bs] _ d`unparsed; + :.z.s[d;variable] + ]; + // Check if this is a literal % + if["%%"~2 sublist d[`unparsed]; + d[`parsed],:"%"; + d[`unparsed]:2 _ d`unparsed; + :.z.s[d;variable] + ]; + // Handle variable replacement + (parsed;unparsed):vrep[d`unparsed;variable]; + d[`parsed],:parsed; + d[`unparsed]:unparsed; + d + }; + + +printf:{ + if[10h~type x; // if string then check if escape characters + $[x like "*%%*";x:(x;"");:x] // if it exists, modify x, if not return the string + ]; + if[(not 0h~type x) or not 10h~type (x,()) 0;'`type]; + d:`parsed`unparsed!("";x 0); + d:process/[d;1_x]; + d[`parsed],d[`unparsed] + }; + diff --git a/di/printf/test.csv b/di/printf/test.csv new file mode 100644 index 0000000..eeb9a2f --- /dev/null +++ b/di/printf/test.csv @@ -0,0 +1,2 @@ +action,ms,bytes,lang,code,repeat,minver,comment +before,0,0,q,([printf]):use`di.printf,1,,Load in printf module