跳转到内容

模組:沙盒/a2569875/PythonScript/Librarys/Structes

维基百科,自由的百科全书
-- Module:PythonScript/Librarys/Structes
-- 綜合結構標準庫:包含 struct, logging, unittest, calendar
local Structes = {}

local PyClass = require("Module:沙盒/a2569875/PythonScript/Class")
local Runtime = require("Module:沙盒/a2569875/PythonScript/Runtime")
local RUtils = require("Module:沙盒/a2569875/PythonScript/Runtime/Utils")
local EvalUtils = require("Module:沙盒/a2569875/PythonScript/Evaluator/Utils")
local Evaluator = require("Module:沙盒/a2569875/PythonScript/Evaluator")
local B = Runtime.builtins

local function fn(name, func) return { __type = "builtin_method", __name = name, __call = func } end

-- 輔助:安全將任意 Python 數值解包為 Lua 原生 number
local function to_num(x)
    if type(x) == "number" then return x end
    if type(x) == "boolean" then return x and 1 or 0 end
    if type(x) == "table" then
        if x.__type == "int" then return x.value or tonumber(RUtils.to_str(x, false)) or 0 end
        if x.__builtin_value ~= nil then return to_num(x.__builtin_value) end
    end
    return tonumber(x) or 0
end

-- 輔助:安全檢查類別繼承關係 (支援單一 class 或 tuple of classes)
local function is_subclass(sub, target)
    if type(sub) ~= "table" or type(target) ~= "table" then return false end
    if target.__type == "tuple" then
        for _, t in ipairs(target.elements) do
            if is_subclass(sub, t) then return true end
        end
        return false
    end
    if sub == target then return true end
    local mro = sub.__mro or sub.__mro__
    if mro then
        for _, base in ipairs(mro) do
            if base == target then return true end
        end
    end
    if sub.__bases then
        for _, base in ipairs(sub.__bases) do
            if is_subclass(base, target) then return true end
        end
    end
    return false
end

-- 輔助:建立模組命名空間
local function make_namespace(name, dict)
    local mod_cls = PyClass.make_class("module", {}, {})
    local inst = PyClass.make_instance(mod_cls)
    inst.__dict = dict
    inst.__dict["__name__"] = name
    for _, v in pairs(dict) do
        if type(v) == "table" and v.__type == "class" then
            v.__dict["__module__"] = name
        end
    end
    return inst
end

-- ==========================================
-- 1. STRUCT 模組 (二進位資料打包與解包,支援 32/64-bit 浮點數)
-- ==========================================
function Structes.init_struct()
    local struct_dict = {}
    local StructError = PyClass.make_class("error", {B.Exception}, {})
    struct_dict.error = StructError

    local function raise_struct_err(msg)
        local exc = PyClass.make_instance(StructError)
        PyClass.set_attr(exc, "args", { __type = "tuple", elements = {msg} })
        Runtime.raise(exc)
    end

    local function get_fmt_info(fmt)
        if type(fmt) == "table" and fmt._data then fmt = fmt._data end
        local endian = "<"
        local pos = 1
        local first_char = fmt:sub(1, 1)
        if first_char == "@" or first_char == "=" or first_char == "<" or first_char == ">" or first_char == "!" then
            endian = (first_char == ">" or first_char == "!") and ">" or "<"
            pos = 2
        end
        local codes = {}
        for count_str, code in fmt:sub(pos):gmatch("(%d*)([a-zA-Z?])") do
            local count = tonumber(count_str) or 1
            if code == "s" or code == "p" then
                table.insert(codes, {code = code, count = count})
            else
                for _ = 1, count do table.insert(codes, {code = code, count = 1}) end
            end
        end
        return endian, codes
    end

    local type_sizes = {x=1, c=1, b=1, B=1, ["?"]=1, h=2, H=2, i=4, I=4, l=4, L=4, q=8, Q=8, f=4, d=8}

    local function calcsize(fmt)
        local _, codes = get_fmt_info(fmt)
        local size = 0
        for _, c in ipairs(codes) do
            if c.code == "s" or c.code == "p" then size = size + c.count
            elseif type_sizes[c.code] then size = size + type_sizes[c.code]
            else raise_struct_err("bad char in struct format") end
        end
        return size
    end

    local function pack_int(val, size, is_signed, is_big)
        val = math.floor(to_num(val))
        local bytes = {}
        local u = val
        if is_signed and u < 0 then u = u + (256 ^ size) end
        for _ = 1, size do
            local r = u % 256
            table.insert(bytes, string.char(r))
            u = math.floor(u / 256)
        end
        local res = table.concat(bytes)
        return is_big and res:reverse() or res
    end

    local function unpack_int(s, pos, size, is_signed, is_big)
        local chunk = s:sub(pos, pos + size - 1)
        if is_big then chunk = chunk:reverse() end
        local val, mult = 0, 1
        for i = 1, size do
            val = val + string.byte(chunk, i) * mult
            mult = mult * 256
        end
        if is_signed then
            local half = 2 ^ (size * 8 - 1)
            if val >= half then val = val - 2 * half end
        end
        return val
    end

    local function pack_float32(x, is_big)
        x = to_num(x)
        if x == 0 then return pack_int(0, 4, false, is_big) end
        local sign = 0; if x < 0 then sign = 128; x = -x end
        local mant, exp = math.frexp(x); exp = exp + 126
        if exp <= 0 then mant = math.floor(mant * 2 ^ (24 + exp) + 0.5); exp = 0
        else mant = math.floor((mant * 2 - 1) * 2 ^ 23 + 0.5); if mant >= 2 ^ 23 then mant = 0; exp = exp + 1 end end
        local b1 = mant % 256; mant = math.floor(mant / 256)
        local b2 = mant % 256; local b3 = math.floor(mant / 256) + (exp % 2) * 128
        local b4 = sign + math.floor(exp / 2)
        local res = string.char(b1, b2, b3, b4)
        return is_big and res:reverse() or res
    end

    local function unpack_float32(s, pos, is_big)
        local chunk = s:sub(pos, pos + 3)
        if is_big then chunk = chunk:reverse() end
        local b1, b2, b3, b4 = string.byte(chunk, 1, 4)
        local sign = (b4 >= 128) and -1 or 1; local exp = (b4 % 128) * 2 + math.floor(b3 / 128)
        local mant = ((b3 % 128) * 256 + b2) * 256 + b1
        if exp == 0 and mant == 0 then return 0.0 * sign
        elseif exp == 255 then return (mant == 0) and (math.huge * sign) or (0/0)
        elseif exp == 0 then return sign * math.ldexp(mant, -149)
        else return sign * math.ldexp(mant + 2 ^ 23, exp - 150) end
    end

    local function pack_float64(x, is_big)
        x = to_num(x)
        if x == 0 then return pack_int(0, 8, false, is_big) end
        local sign = 0; if x < 0 then sign = 128; x = -x end
        if x == math.huge then
            local res = string.char(0, 0, 0, 0, 0, 0, 240, sign + 127)
            return is_big and res:reverse() or res
        elseif x ~= x then
            local res = string.char(0, 0, 0, 0, 0, 0, 248, sign + 127)
            return is_big and res:reverse() or res
        end
        local mant, exp = math.frexp(x); exp = exp + 1022
        if exp <= 0 then mant = math.floor(mant * 2 ^ (53 + exp) + 0.5); exp = 0
        else mant = math.floor((mant * 2 - 1) * 2 ^ 52 + 0.5); if mant >= 2 ^ 52 then mant = 0; exp = exp + 1 end end
        local bytes = {}
        for _ = 1, 6 do table.insert(bytes, string.char(mant % 256)); mant = math.floor(mant / 256) end
        local b7 = mant + (exp % 16) * 16
        local b8 = sign + math.floor(exp / 16)
        table.insert(bytes, string.char(b7, b8))
        local res = table.concat(bytes)
        return is_big and res:reverse() or res
    end

    local function unpack_float64(s, pos, is_big)
        local chunk = s:sub(pos, pos + 7)
        if is_big then chunk = chunk:reverse() end
        local b1, b2, b3, b4, b5, b6, b7, b8 = string.byte(chunk, 1, 8)
        local sign = (b8 >= 128) and -1 or 1
        local exp = (b8 % 128) * 16 + math.floor(b7 / 16)
        local mant = (b7 % 16)
        for _, b in ipairs({b6, b5, b4, b3, b2, b1}) do mant = mant * 256 + b end
        if exp == 0 and mant == 0 then return 0.0 * sign
        elseif exp == 2047 then return (mant == 0) and (math.huge * sign) or (0/0)
        elseif exp == 0 then return sign * math.ldexp(mant, -1074)
        else return sign * math.ldexp(mant + 2 ^ 52, exp - 1075) end
    end

    local function raw_pack(fmt, args, start_idx)
        local endian, codes = get_fmt_info(fmt)
        local is_big = (endian == ">")
        local res = {}
        local arg_idx = start_idx or 2
        for _, c in ipairs(codes) do
            local code = c.code
            if code == "x" then
                table.insert(res, "\0")
            elseif code == "s" or code == "p" then
                local val = args[arg_idx]; arg_idx = arg_idx + 1
                if type(val) == "table" and val._data then val = val._data end
                val = tostring(val or "")
                if #val < c.count then val = val .. string.rep("\0", c.count - #val)
                elseif #val > c.count then val = val:sub(1, c.count) end
                table.insert(res, val)
            else
                local val = args[arg_idx]; arg_idx = arg_idx + 1
                if code == "?" then table.insert(res, pack_int(EvalUtils.is_truthy(val) and 1 or 0, 1, false, is_big))
                elseif code == "b" then table.insert(res, pack_int(val, 1, true, is_big))
                elseif code == "B" or code == "c" then
                    if type(val) == "string" then val = string.byte(val)
                    elseif type(val) == "table" and val._data then val = string.byte(val._data) end
                    table.insert(res, pack_int(val, 1, false, is_big))
                elseif code == "h" then table.insert(res, pack_int(val, 2, true, is_big))
                elseif code == "H" then table.insert(res, pack_int(val, 2, false, is_big))
                elseif code == "i" or code == "l" then table.insert(res, pack_int(val, 4, true, is_big))
                elseif code == "I" or code == "L" then table.insert(res, pack_int(val, 4, false, is_big))
                elseif code == "q" then table.insert(res, pack_int(val, 8, true, is_big))
                elseif code == "Q" then table.insert(res, pack_int(val, 8, false, is_big))
                elseif code == "f" then table.insert(res, pack_float32(val, is_big))
                elseif code == "d" then table.insert(res, pack_float64(val, is_big))
                else raise_struct_err("bad char in struct format") end
            end
        end
        return { __type = "bytes", _data = table.concat(res) }
    end

    local function raw_unpack(fmt, buffer, offset)
        if type(buffer) == "table" and buffer._data then buffer = buffer._data end
        local expected_size = calcsize(fmt)
        local pos = (offset or 0) + 1
        if #buffer - pos + 1 < expected_size then
            raise_struct_err(string.format("unpack requires a buffer of %d bytes", expected_size))
        end
        local endian, codes = get_fmt_info(fmt)
        local is_big = (endian == ">")
        local elts = {}
        for _, c in ipairs(codes) do
            local code = c.code
            if code == "x" then
                pos = pos + 1
            elseif code == "s" or code == "p" then
                table.insert(elts, { __type = "bytes", _data = buffer:sub(pos, pos + c.count - 1) })
                pos = pos + c.count
            else
                local val = 0
                if code == "?" then val = (unpack_int(buffer, pos, 1, false, is_big) ~= 0)
                elseif code == "b" then val = unpack_int(buffer, pos, 1, true, is_big)
                elseif code == "B" then val = unpack_int(buffer, pos, 1, false, is_big)
                elseif code == "c" then val = { __type = "bytes", _data = string.char(unpack_int(buffer, pos, 1, false, is_big)) }
                elseif code == "h" then val = unpack_int(buffer, pos, 2, true, is_big)
                elseif code == "H" then val = unpack_int(buffer, pos, 2, false, is_big)
                elseif code == "i" or code == "l" then val = unpack_int(buffer, pos, 4, true, is_big)
                elseif code == "I" or code == "L" then val = unpack_int(buffer, pos, 4, false, is_big)
                elseif code == "q" then val = unpack_int(buffer, pos, 8, true, is_big)
                elseif code == "Q" then val = unpack_int(buffer, pos, 8, false, is_big)
                elseif code == "f" then val = unpack_float32(buffer, pos, is_big)
                elseif code == "d" then val = unpack_float64(buffer, pos, is_big) end
                table.insert(elts, val)
                pos = pos + (type_sizes[code] or 0)
            end
        end
        return { __type = "tuple", elements = elts }
    end

    struct_dict.calcsize = fn("calcsize", function(a) return calcsize(a[1]) end)
    struct_dict.pack = fn("pack", function(a) return raw_pack(a[1], a, 2) end)
    struct_dict.unpack = fn("unpack", function(a)
        local buf = a[2]
        if type(buf) == "table" and buf._data then buf = buf._data end
        local sz = calcsize(a[1])
        if #buf ~= sz then raise_struct_err(string.format("unpack requires a buffer of %d bytes", sz)) end
        return raw_unpack(a[1], buf, 0)
    end)
    struct_dict.unpack_from = fn("unpack_from", function(a, kw)
        local fmt, buf = a[1], a[2]
        local offset = to_num(a[3] or (kw and kw.offset) or 0)
        return raw_unpack(fmt, buf, offset)
    end)

    local StructClass = PyClass.make_class("Struct", {B.object}, {
        __init__ = fn("__init__", function(a)
            local fmt = a[2]
            if type(fmt) == "table" and fmt._data then fmt = fmt._data end
            a[1].__dict.format = fmt
            a[1].__dict.size = calcsize(fmt)
            return B.None
        end),
        pack = fn("pack", function(a) return raw_pack(a[1].__dict.format, a, 2) end),
        unpack = fn("unpack", function(a) return struct_dict.unpack.__call({a[1].__dict.format, a[2]}) end),
        unpack_from = fn("unpack_from", function(a, kw) return struct_dict.unpack_from.__call({a[1].__dict.format, a[2], a[3]}, kw) end)
    })
    struct_dict.Struct = StructClass
    return make_namespace("struct", struct_dict)
end

-- ==========================================
-- 2. LOGGING 模組 (支援 stdout 與 mw.log,徹底移除雙重 self 錯誤)
-- ==========================================
function Structes.init_logging()
    local log_dict = {
        DEBUG = 10, INFO = 20, WARNING = 30, WARN = 30, ERROR = 40, CRITICAL = 50, FATAL = 50, NOTSET = 0
    }
    local level_names = { [10]="DEBUG", [20]="INFO", [30]="WARNING", [40]="ERROR", [50]="CRITICAL", [0]="NOTSET" }

    local LogRecord = PyClass.make_class("LogRecord", {B.object}, {
        __init__ = fn("__init__", function(a)
            local self, name, lvl, msg = a[1], a[2], to_num(a[3]), a[4]
            self.__dict.name = tostring(name or "root")
            self.__dict.levelno = lvl
            self.__dict.levelname = level_names[lvl] or (" LEVEL " .. tostring(lvl))
            self.__dict.msg = RUtils.to_str(msg, false)
            return B.None
        end),
        getMessage = fn("getMessage", function(a) return a[1].__dict.msg end)
    })

    local Formatter = PyClass.make_class("Formatter", {B.object}, {
        __init__ = fn("__init__", function(a, kw)
            a[1].__dict.fmt = (a[2] ~= nil and a[2] ~= B.None) and a[2] or (kw and kw.fmt) or "%(levelname)s:%(name)s:%(message)s"
            return B.None
        end),
        format = fn("format", function(a)
            local self, record = a[1], a[2]
            local s = self.__dict.fmt or "%(levelname)s:%(name)s:%(message)s"
            local d = record.__dict
            s = s:gsub("%%%(levelname%)s", tostring(d.levelname or "INFO"))
            s = s:gsub("%%%(levelno%)s", tostring(d.levelno or 20))
            s = s:gsub("%%%(name%)s", tostring(d.name or "root"))
            s = s:gsub("%%%(message%)s", tostring(d.msg or ""))
            return s
        end)
    })

    local function make_formatter(fmt_str)
        local inst = PyClass.make_instance(Formatter)
        inst.__dict.fmt = fmt_str or "%(levelname)s:%(name)s:%(message)s"
        return inst
    end

    local function format_record(handler, record)
        local fmt_obj = handler.__dict.formatter or make_formatter()
        local d = record.__dict
        local s = fmt_obj.__dict.fmt or "%(levelname)s:%(name)s:%(message)s"
        s = s:gsub("%%%(levelname%)s", tostring(d.levelname or "INFO"))
        s = s:gsub("%%%(levelno%)s", tostring(d.levelno or 20))
        s = s:gsub("%%%(name%)s", tostring(d.name or "root"))
        s = s:gsub("%%%(message%)s", tostring(d.msg or ""))
        return s
    end

    local Handler = PyClass.make_class("Handler", {B.object}, {
        __init__ = fn("__init__", function(a)
            a[1].__dict.level = to_num(a[2] or 0)
            a[1].__dict.formatter = make_formatter()
            return B.None
        end),
        setFormatter = fn("setFormatter", function(a) a[1].__dict.formatter = a[2]; return B.None end),
        setLevel = fn("setLevel", function(a) a[1].__dict.level = to_num(a[2]); return B.None end),
        format = fn("format", function(a) return format_record(a[1], a[2]) end),
        handle = fn("handle", function(a)
            local self, record = a[1], a[2]
            if to_num(record.__dict.levelno) >= to_num(self.__dict.level) then
                local msg = format_record(self, record)
                Evaluator.invoke(PyClass.get_attr(self, "emit"), {record, msg}, {})
            end
            return B.None
        end)
    })

    local StreamHandler = PyClass.make_class("StreamHandler", {Handler}, {
        emit = fn("emit", function(a)
            local msg = a[3] or format_record(a[1], a[2])
            table.insert(Runtime.stdout, tostring(msg) .. "\n")
            return B.None
        end)
    })

    local MwLogHandler = PyClass.make_class("MwLogHandler", {Handler}, {
        emit = fn("emit", function(a)
            local msg = a[3] or format_record(a[1], a[2])
            mw.log(tostring(msg))
            return B.None
        end)
    })

    local root_logger -- 前向宣告

    -- 內部直接發送日誌的核心輔助函式 (完全避開 bound_method 二次綁定問題)
    local function emit_log(logger_inst, num_level, raw_msg, extra_args)
        local eff_level = to_num(logger_inst.__dict.level)
        if eff_level == 0 and root_logger and logger_inst ~= root_logger then
            eff_level = to_num(root_logger.__dict.level)
        end
        if num_level < eff_level then return B.None end

        local formatted_msg = RUtils.to_str(raw_msg, false)
        if extra_args and #extra_args > 0 then
            local ok_mod, mod_res = pcall(function()
                local str_mod = PyClass.get_attr(formatted_msg, "__mod__")
                if #extra_args == 1 then return str_mod.__call({extra_args[1]})
                else return str_mod.__call({{ __type = "tuple", elements = extra_args }}) end
            end)
            if ok_mod and type(mod_res) == "string" then formatted_msg = mod_res end
        end

        local rec = PyClass.make_instance(LogRecord)
        rec.__dict.name = tostring(logger_inst.__dict.name or "root")
        rec.__dict.levelno = num_level
        rec.__dict.levelname = level_names[num_level] or ("LVL" .. tostring(num_level))
        rec.__dict.msg = formatted_msg

        local cur = logger_inst
        while cur do
            for _, h in ipairs(cur.__dict.handlers or {}) do
                if num_level >= to_num(h.__dict.level) then
                    local out_str = format_record(h, rec)
                    if h.__class == MwLogHandler then
                        mw.log(out_str)
                    elseif h.__class == StreamHandler then
                        table.insert(Runtime.stdout, out_str .. "\n")
                    else
                        Evaluator.invoke(PyClass.get_attr(h, "emit"), {rec, out_str}, {})
                    end
                end
            end
            if cur.__dict.propagate and cur ~= root_logger then
                cur = root_logger
            else
                break
            end
        end
        return B.None
    end

    local function collect_extra(a, start_i)
        local ex = {}
        for i = start_i, #a do table.insert(ex, a[i]) end
        return ex
    end

    local Logger = PyClass.make_class("Logger", {B.object}, {
        __init__ = fn("__init__", function(a)
            a[1].__dict.name = tostring(a[2] or "root")
            a[1].__dict.level = to_num(a[3] or 0)
            a[1].__dict.handlers = {}
            a[1].__dict.propagate = true
            return B.None
        end),
        setLevel = fn("setLevel", function(a) a[1].__dict.level = to_num(a[2]); return B.None end),
        getEffectiveLevel = fn("getEffectiveLevel", function(a)
            local lvl = to_num(a[1].__dict.level)
            if lvl == 0 and root_logger then return to_num(root_logger.__dict.level) end
            return lvl
        end),
        addHandler = fn("addHandler", function(a) table.insert(a[1].__dict.handlers, a[2]); return B.None end),
        log = fn("log", function(a) return emit_log(a[1], to_num(a[2]), a[3], collect_extra(a, 4)) end),
        debug = fn("debug", function(a) return emit_log(a[1], 10, a[2], collect_extra(a, 3)) end),
        info = fn("info", function(a) return emit_log(a[1], 20, a[2], collect_extra(a, 3)) end),
        warning = fn("warning", function(a) return emit_log(a[1], 30, a[2], collect_extra(a, 3)) end),
        warn = fn("warn", function(a) return emit_log(a[1], 30, a[2], collect_extra(a, 3)) end),
        error = fn("error", function(a) return emit_log(a[1], 40, a[2], collect_extra(a, 3)) end),
        critical = fn("critical", function(a) return emit_log(a[1], 50, a[2], collect_extra(a, 3)) end),
        fatal = fn("fatal", function(a) return emit_log(a[1], 50, a[2], collect_extra(a, 3)) end),
        exception = fn("exception", function(a) return emit_log(a[1], 40, a[2], collect_extra(a, 3)) end)
    })

    local function new_logger(name, level, propagate)
        local l = PyClass.make_instance(Logger)
        l.__dict.name = name
        l.__dict.level = level or 0
        l.__dict.handlers = {}
        l.__dict.propagate = (propagate ~= false)
        return l
    end

    local default_handler = PyClass.make_instance(StreamHandler)
    default_handler.__dict.level = 0
    default_handler.__dict.formatter = make_formatter()

    root_logger = new_logger("root", 30, false)
    table.insert(root_logger.__dict.handlers, default_handler)

    -- 特殊預設 mw logger:直接輸出至維基百科底層 mw.log,且不向 stdout 傳播
    local mw_handler = PyClass.make_instance(MwLogHandler)
    mw_handler.__dict.level = 0
    mw_handler.__dict.formatter = make_formatter()
    local mw_logger = new_logger("mw", 10, false)
    table.insert(mw_logger.__dict.handlers, mw_handler)

    local loggers_registry = { ["root"] = root_logger, ["mw"] = mw_logger }

    log_dict.getLogger = fn("getLogger", function(a)
        local name = a[1]
        if not name or name == B.None or name == "" then return root_logger end
        name = tostring(name)
        if not loggers_registry[name] then
            loggers_registry[name] = new_logger(name, 0, true)
        end
        return loggers_registry[name]
    end)

    log_dict.basicConfig = fn("basicConfig", function(a, kw)
        kw = kw or {}
        if kw.level ~= nil then root_logger.__dict.level = to_num(kw.level) end
        if kw.format ~= nil then
            root_logger.__dict.handlers[1].__dict.formatter = make_formatter(kw.format)
        end
        return B.None
    end)

    -- 模組層級快速日誌函式
    log_dict.debug = fn("debug", function(a) return emit_log(root_logger, 10, a[1], collect_extra(a, 2)) end)
    log_dict.info = fn("info", function(a) return emit_log(root_logger, 20, a[1], collect_extra(a, 2)) end)
    log_dict.warning = fn("warning", function(a) return emit_log(root_logger, 30, a[1], collect_extra(a, 2)) end)
    log_dict.warn = log_dict.warning
    log_dict.error = fn("error", function(a) return emit_log(root_logger, 40, a[1], collect_extra(a, 2)) end)
    log_dict.critical = fn("critical", function(a) return emit_log(root_logger, 50, a[1], collect_extra(a, 2)) end)
    log_dict.log = fn("log", function(a) return emit_log(root_logger, to_num(a[1]), a[2], collect_extra(a, 3)) end)

    log_dict.Logger = Logger; log_dict.Handler = Handler; log_dict.LogRecord = LogRecord
    log_dict.StreamHandler = StreamHandler; log_dict.MwLogHandler = MwLogHandler; log_dict.Formatter = Formatter

    return make_namespace("logging", log_dict)
end

-- ==========================================
-- 3. UNITTEST 模組 (完整測試框架與斷言庫)
-- ==========================================
function Structes.init_unittest()
    local unit_dict = {}
    local AssertionError = B.AssertionError or PyClass.make_class("AssertionError", {B.Exception}, {})
    local SkipTest = PyClass.make_class("SkipTest", {B.Exception}, {})

    local function fail(msg)
        local exc = PyClass.make_instance(AssertionError)
        local str_msg = msg and RUtils.to_str(msg, false) or "AssertionError"
        exc.__dict._unit_msg = str_msg
        PyClass.set_attr(exc, "args", { __type = "tuple", elements = {str_msg} })
        Runtime.raise(exc)
    end

    local function py_contains(member, container)
        if type(container) == "table" and container.__type == "instance" then
            local ok, attr = pcall(PyClass.get_attr, container, "__contains__")
            if ok and attr then
                return EvalUtils.is_truthy(Evaluator.invoke(attr, {member}, {}))
            end
            if container.__builtin_value ~= nil then
                container = container.__builtin_value
            end
        end
        if type(member) == "table" and member.__type == "instance" and member.__builtin_value ~= nil then
            member = member.__builtin_value
        end
        if type(container) == "string" then
            if type(member) ~= "string" then
                Runtime.raise("TypeError", "'in <string>' requires string as left operand")
            end
            return string.find(container, member, 1, true) ~= nil
        elseif type(container) == "table" then
            if container.__type == "list" or container.__type == "tuple" or container.__type == "set" or container.__type == "frozenset" then
                for _, v in ipairs(container.elements) do
                    if EvalUtils.py_eq(v, member) then return true end
                end
                return false
            elseif container.__type == "dict" then
                for _, p in ipairs(container.pairs) do
                    if EvalUtils.py_eq(p.key, member) then return true end
                end
                return false
            elseif container.__type == "bytes" or container.__type == "bytearray" or container.__type == "memoryview" then
                if type(member) == "number" and math.floor(member) == member then
                    return string.find(container._data, string.char(member), 1, true) ~= nil
                elseif type(member) == "table" and (member.__type == "bytes" or member.__type == "bytearray" or member.__type == "memoryview") then
                    return string.find(container._data, member._data, 1, true) ~= nil
                end
                return false
            elseif container.__type == "range" then
                local n = to_num(member)
                if container.step > 0 then return n >= container.start and n < container.stop and (n - container.start) % container.step == 0
                else return n <= container.start and n > container.stop and (n - container.start) % container.step == 0 end
            end
        end
        local ok_iter, iter_state = pcall(EvalUtils.make_iter_state, container)
        if not ok_iter then
            Runtime.raise("TypeError", "argument is not iterable")
        end
        while true do
            local has_next, v = EvalUtils.get_next_item(iter_state)
            if not has_next then break end
            if EvalUtils.py_eq(v, member) then return true end
        end
        return false
    end

    local AssertRaisesContext = PyClass.make_class("_AssertRaisesContext", {B.object}, {
        __enter__ = fn("__enter__", function(a) return a[1] end),
        __exit__ = fn("__exit__", function(a)
            local self, exc_type, exc_val = a[1], a[2], a[3]
            local expected = self.__dict.expected
            local exp_name = expected.__name or "Exception"
            if exc_type == nil or exc_type == B.None then
                fail(exp_name .. " not raised")
            end
            if is_subclass(exc_type, expected) then
                self.__dict.exception = exc_val
                return true
            end
            return false
        end)
    })

    local SubTestContext = PyClass.make_class("_SubTest", {B.object}, {
        __enter__ = fn("__enter__", function(a) return a[1] end),
        __exit__ = fn("__exit__", function() return false end)
    })

    local TestCase = PyClass.make_class("TestCase", {B.object}, {
        __init__ = fn("__init__", function(a) a[1].__dict._testMethodName = a[2] or "runTest"; return B.None end),
        setUp = fn("setUp", function() return B.None end),
        tearDown = fn("tearDown", function() return B.None end),
        fail = fn("fail", function(a) fail(a[2] or "Test failed explicitly") end),
        skipTest = fn("skipTest", function(a)
            local exc = PyClass.make_instance(SkipTest)
            exc.__dict._skip_reason = RUtils.to_str(a[2] or "skipped", false)
            Runtime.raise(exc)
        end),
        subTest = fn("subTest", function() return PyClass.make_instance(SubTestContext) end),

        assertEqual = fn("assertEqual", function(a)
            if not EvalUtils.py_eq(a[2], a[3]) then
                fail(a[4] or (RUtils.to_str(a[2], true) .. " != " .. RUtils.to_str(a[3], true)))
            end
            return B.None
        end),
        assertNotEqual = fn("assertNotEqual", function(a)
            if EvalUtils.py_eq(a[2], a[3]) then
                fail(a[4] or (RUtils.to_str(a[2], true) .. " == " .. RUtils.to_str(a[3], true)))
            end
            return B.None
        end),
        assertTrue = fn("assertTrue", function(a)
            if not EvalUtils.is_truthy(a[2]) then
                fail(a[3] or (RUtils.to_str(a[2], true) .. " is not true"))
            end
            return B.None
        end),
        assertFalse = fn("assertFalse", function(a)
            if EvalUtils.is_truthy(a[2]) then
                fail(a[3] or (RUtils.to_str(a[2], true) .. " is not false"))
            end
            return B.None
        end),
        assertIs = fn("assertIs", function(a)
            if not EvalUtils.py_is(a[2], a[3]) then
                fail(a[4] or (RUtils.to_str(a[2], true) .. " is not " .. RUtils.to_str(a[3], true)))
            end
            return B.None
        end),
        assertIsNot = fn("assertIsNot", function(a)
            if EvalUtils.py_is(a[2], a[3]) then
                fail(a[4] or (RUtils.to_str(a[2], true) .. " is " .. RUtils.to_str(a[3], true)))
            end
            return B.None
        end),
        assertIsNone = fn("assertIsNone", function(a)
            if a[2] ~= B.None then
                fail(a[3] or (RUtils.to_str(a[2], true) .. " is not None"))
            end
            return B.None
        end),
        assertIsNotNone = fn("assertIsNotNone", function(a)
            if a[2] == B.None then
                fail(a[3] or "unexpectedly None")
            end
            return B.None
        end),
        assertIn = fn("assertIn", function(a)
            if not py_contains(a[2], a[3]) then
                fail(a[4] or (RUtils.to_str(a[2], true) .. " not found in " .. RUtils.to_str(a[3], true)))
            end
            return B.None
        end),
        assertNotIn = fn("assertNotIn", function(a)
            if py_contains(a[2], a[3]) then
                fail(a[4] or (RUtils.to_str(a[2], true) .. " unexpectedly found in " .. RUtils.to_str(a[3], true)))
            end
            return B.None
        end),
        assertIsInstance = fn("assertIsInstance", function(a)
            if not B.isinstance({a[2], a[3]}) then
                fail(a[4] or (RUtils.to_str(a[2], true) .. " is not an instance of " .. RUtils.to_str(a[3], true)))
            end
            return B.None
        end),
        assertNotIsInstance = fn("assertNotIsInstance", function(a)
            if B.isinstance({a[2], a[3]}) then
                fail(a[4] or (RUtils.to_str(a[2], true) .. " is an instance of " .. RUtils.to_str(a[3], true)))
            end
            return B.None
        end),
        assertAlmostEqual = fn("assertAlmostEqual", function(a, kw)
            local x, y = to_num(a[2]), to_num(a[3])
            local places = to_num(a[4] or (kw and kw.places) or 7)
            local msg = a[5] or (kw and kw.msg)
            local delta = kw and kw.delta and to_num(kw.delta) or (10 ^ (-places))
            if math.abs(x - y) > delta then
                fail(msg or string.format("%s != %s within %s", tostring(x), tostring(y), tostring(delta)))
            end
            return B.None
        end),
        assertNotAlmostEqual = fn("assertNotAlmostEqual", function(a, kw)
            local x, y = to_num(a[2]), to_num(a[3])
            local places = to_num(a[4] or (kw and kw.places) or 7)
            local msg = a[5] or (kw and kw.msg)
            local delta = kw and kw.delta and to_num(kw.delta) or (10 ^ (-places))
            if math.abs(x - y) <= delta then
                fail(msg or string.format("%s == %s within %s", tostring(x), tostring(y), tostring(delta)))
            end
            return B.None
        end),
        assertGreater = fn("assertGreater", function(a)
            if not (EvalUtils.py_cmp(a[2], a[3]) > 0) then fail(a[4] or (RUtils.to_str(a[2], true) .. " not greater than " .. RUtils.to_str(a[3], true))) end
            return B.None
        end),
        assertGreaterEqual = fn("assertGreaterEqual", function(a)
            if not (EvalUtils.py_cmp(a[2], a[3]) >= 0) then fail(a[4] or (RUtils.to_str(a[2], true) .. " not greater than or equal to " .. RUtils.to_str(a[3], true))) end
            return B.None
        end),
        assertLess = fn("assertLess", function(a)
            if not (EvalUtils.py_cmp(a[2], a[3]) < 0) then fail(a[4] or (RUtils.to_str(a[2], true) .. " not less than " .. RUtils.to_str(a[3], true))) end
            return B.None
        end),
        assertLessEqual = fn("assertLessEqual", function(a)
            if not (EvalUtils.py_cmp(a[2], a[3]) <= 0) then fail(a[4] or (RUtils.to_str(a[2], true) .. " not less than or equal to " .. RUtils.to_str(a[3], true))) end
            return B.None
        end),
        assertRaises = fn("assertRaises", function(a, kw)
            local exc_cls = a[2]
            if #a == 2 and (not kw or kw.callable == nil) then
                local ctx = PyClass.make_instance(AssertRaisesContext)
                ctx.__dict.expected = exc_cls
                return ctx
            end
            local callable = a[3] or (kw and kw.callable)
            local call_args = {}
            for i = 4, #a do table.insert(call_args, a[i]) end
            local ok, res = pcall(Evaluator.invoke, callable, call_args, kw or {})
            if ok then fail((exc_cls.__name or "Exception") .. " not raised") end
            if type(res) == "table" and res.__class then
                if not is_subclass(res.__class, exc_cls) then
                    fail("Expected " .. tostring(exc_cls.__name) .. " but got " .. tostring(res.__class.__name))
                end
            end
            return B.None
        end)
    })

    unit_dict.TestCase = TestCase
    unit_dict.SkipTest = SkipTest
    unit_dict.skip = fn("skip", function(a)
        local reason = a[1] or "skipped"
        return fn("skip_deco", function(da)
            local target = da[1]
            if type(target) == "table" then target.__unittest_skip__ = reason end
            return target
        end)
    end)
    unit_dict.skipIf = fn("skipIf", function(a)
        if EvalUtils.is_truthy(a[1]) then return unit_dict.skip.__call({a[2]}) end
        return fn("noop_deco", function(da) return da[1] end)
    end)
    unit_dict.skipUnless = fn("skipUnless", function(a)
        if not EvalUtils.is_truthy(a[1]) then return unit_dict.skip.__call({a[2]}) end
        return fn("noop_deco", function(da) return da[1] end)
    end)

    unit_dict.main = fn("main", function()
        local scope = Runtime.Traceback.current_scope
        while scope and scope.parent do scope = scope.parent end

        local test_classes = {}
        for k, v in pairs(scope and scope.vars or {}) do
            if type(v) == "table" and v.__type == "class" and v ~= TestCase and is_subclass(v, TestCase) then
                table.insert(test_classes, {name = k, cls = v})
            end
        end
        table.sort(test_classes, function(x, y) return x.name < y.name end)

        local total_run, total_fail, total_err, total_skip = 0, 0, 0, 0
        local report = {}
        local progress = {}
        local start_clock = os.clock() or 0

        for _, tc in ipairs(test_classes) do
            local test_names = {}
            for k, v in pairs(tc.cls.__dict) do
                if type(k) == "string" and k:match("^test") and type(v) == "table" and (v.__type == "function" or v.__type == "builtin_method") then
                    table.insert(test_names, k)
                end
            end
            table.sort(test_names)

            for _, t_name in ipairs(test_names) do
                total_run = total_run + 1
                local raw_fn = tc.cls.__dict[t_name]
                if tc.cls.__unittest_skip__ or ( raw_fn and raw_fn.__unittest_skip__ ) then
                    total_skip = total_skip + 1
                    table.insert(progress, "s")
                else
                    local inst = PyClass.make_instance(tc.cls)
                    inst.__dict._testMethodName = t_name

                    local ok_setup, res_setup = pcall(Evaluator.invoke, PyClass.get_attr(inst, "setUp"), {}, {})
                    if not ok_setup then
                        total_err = total_err + 1
                        table.insert(progress, "E")
                        table.insert(report, string.format("ERROR: %s (%s)\n%s", t_name, tc.name, RUtils.to_str(res_setup, false)))
                    else
                        local method = PyClass.get_attr(inst, t_name)
                        local ok_test, res_test = pcall(Evaluator.invoke, method, {}, {})

                        if ok_test then
                            table.insert(progress, ".")
                        else
                            if type(res_test) == "table" and res_test.__class == SkipTest then
                                total_skip = total_skip + 1
                                table.insert(progress, "s")
                            elseif type(res_test) == "table" and (res_test.__class == AssertionError or (res_test.__class and res_test.__class.__name == "AssertionError")) then
                                total_fail = total_fail + 1
                                table.insert(progress, "F")
                                local f_msg = (res_test.__dict and res_test.__dict._unit_msg)
                                    or (res_test.__dict and res_test.__dict.args and res_test.__dict.args.elements and res_test.__dict.args.elements[1])
                                    or RUtils.to_str(res_test, false)
                                table.insert(report, string.format("FAIL: %s (%s)\nAssertionError: %s", t_name, tc.name, tostring(f_msg)))
                            else
                                total_err = total_err + 1
                                table.insert(progress, "E")
                                local e_name = (type(res_test) == "table" and res_test.__class and res_test.__class.__name) or "Error"
                                table.insert(report, string.format("ERROR: %s (%s)\n%s: %s", t_name, tc.name, e_name, RUtils.to_str(res_test, false)))
                            end
                        end
                        pcall(Evaluator.invoke, PyClass.get_attr(inst, "tearDown"), {}, {})
                    end
                end
            end
        end

        local elapsed = math.max(0, (os.clock() or 0) - start_clock)
        if #progress > 0 then
            table.insert(Runtime.stdout, table.concat(progress) .. "\n")
        end
        if #report > 0 then
            for _, r in ipairs(report) do
                table.insert(Runtime.stdout, string.rep("=", 40) .. "\n" .. r .. "\n")
            end
        end
        table.insert(Runtime.stdout, string.rep("-", 40) .. "\n")
        table.insert(Runtime.stdout, string.format("Ran %d test%s in %.3fs\n\n", total_run, total_run == 1 and "" or "s", elapsed))
        if total_fail == 0 and total_err == 0 then
            local extra = total_skip > 0 and string.format(" (skipped=%d)", total_skip) or ""
            table.insert(Runtime.stdout, "OK" .. extra .. "\n")
        else
            table.insert(Runtime.stdout, string.format("FAILED (failures=%d, errors=%d, skipped=%d)\n", total_fail, total_err, total_skip))
        end
        return B.None
    end)

    return make_namespace("unittest", unit_dict)
end

-- ==========================================
-- 4. CALENDAR 模組 (支援 i18n 語系、自訂繼承 Hook 與 Wiki 表格)
-- ==========================================
function Structes.init_calendar()
    local cal_dict = {
        MONDAY = 0, TUESDAY = 1, WEDNESDAY = 2, THURSDAY = 3, FRIDAY = 4, SATURDAY = 5, SUNDAY = 6
    }

    local LOCALES = {
        en = {
            months = {"January","February","March","April","May","June","July","August","September","October","November","December"},
            days   = {"Mo","Tu","We","Th","Fr","Sa","Su"},
            title  = "{month} {year}"
        },
        zh = {
            months = {"一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"},
            days   = {"一","二","三","四","五","六","日"},
            title  = "{year}年 {month}"
        },
        zh_full = {
            months = {"一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"},
            days   = {"星期一","星期二","星期三","星期四","星期五","星期六","星期日"},
            title  = "{year}年 {month}"
        },
        ja = {
            months = {"1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"},
            days   = {"月","火","水","木","金","土","日"},
            title  = "{year}年 {month}"
        }
    }

    local m_names = LOCALES.en.months
    local m_abbrs = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}
    local d_names = {"Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"}
    local d_abbrs = {"Mon","Tue","Wed","Thu","Fri","Sat","Sun"}

    cal_dict.month_name = { __type = "list", elements = {"", unpack(m_names)} }
    cal_dict.month_abbr = { __type = "list", elements = {"", unpack(m_abbrs)} }
    cal_dict.day_name   = { __type = "list", elements = d_names }
    cal_dict.day_abbr   = { __type = "list", elements = d_abbrs }

    local function isleap(year)
        year = to_num(year)
        return year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0)
    end

    local function leapdays(y1, y2)
        y1, y2 = to_num(y1), to_num(y2)
        local function leaps_before(y) y = y - 1; return math.floor(y/4) - math.floor(y/100) + math.floor(y/400) end
        return leaps_before(y2) - leaps_before(y1)
    end

    local function weekday(year, month, day)
        year, month, day = to_num(year), to_num(month), to_num(day)
        local days = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30}
        local y = year - 1
        local d = y * 365 + math.floor(y/4) - math.floor(y/100) + math.floor(y/400)
        for i = 1, month - 1 do
            d = d + days[i + 1]
            if i == 2 and isleap(year) then d = d + 1 end
        end
        d = d + day
        return (d + 6) % 7
    end

    local function raw_monthrange(year, month)
        year, month = to_num(year), to_num(month)
        if month < 1 or month > 12 then Runtime.raise("ValueError", "bad month number; must be 1-12") end
        local days = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
        local d = days[month]
        if month == 2 and isleap(year) then d = 29 end
        return weekday(year, month, 1), d
    end

    local function raw_monthcalendar(year, month, firstweekday)
        firstweekday = to_num(firstweekday or 0) % 7
        local first_wd, n_days = raw_monthrange(year, month)
        local lead_empty = (first_wd - firstweekday) % 7
        local weeks, cur_week = {}, {}
        for _ = 1, lead_empty do table.insert(cur_week, 0) end
        for d = 1, n_days do
            table.insert(cur_week, d)
            if #cur_week == 7 then table.insert(weeks, cur_week); cur_week = {} end
        end
        if #cur_week > 0 then
            while #cur_week < 7 do table.insert(cur_week, 0) end
            table.insert(weeks, cur_week)
        end
        return weeks
    end

    -- 💡 i18n 解析引擎:整合實例屬性、繼承類別屬性、Hook 方法與 kwargs
    local function resolve_i18n(self_obj, kw)
        kw = kw or {}
        local function get_opt(k1, k2)
            if kw[k1] ~= nil and kw[k1] ~= B.None then return kw[k1] end
            if k2 and kw[k2] ~= nil and kw[k2] ~= B.None then return kw[k2] end
            if self_obj then
                local ok1, v1 = pcall(PyClass.get_attr, self_obj, k1)
                if ok1 and v1 ~= nil and v1 ~= B.None then return v1 end
                if k2 then
                    local ok2, v2 = pcall(PyClass.get_attr, self_obj, k2)
                    if ok2 and v2 ~= nil and v2 ~= B.None then return v2 end
                end
            end
            return nil
        end

        local loc_name = get_opt("locale", "__locale__")
        loc_name = loc_name and RUtils.to_str(loc_name, false):lower() or "en"
        local preset = LOCALES[loc_name] or LOCALES.en

        local custom_months = get_opt("months", "month_names") or get_opt("__month_names__")
        local custom_days   = get_opt("days", "day_names") or get_opt("weekdays", "__day_names__")
        local title_fmt     = get_opt("title_fmt", "__title_fmt__")
        title_fmt = title_fmt and RUtils.to_str(title_fmt, false) or preset.title

        local function extract_list(src, expected_len)
            if not src then return nil end
            if type(src) == "table" and (src.__type == "list" or src.__type == "tuple") then
                local elts = src.elements
                local offset = (#elts == expected_len + 1 and RUtils.to_str(elts[1], false) == "") and 1 or 0
                local res = {}
                for i = 1, expected_len do
                    res[i] = elts[i + offset] and RUtils.to_str(elts[i + offset], false) or ""
                end
                return res
            elseif type(src) == "table" and src.__type == "dict" then
                local res = {}
                for _, p in ipairs(src.pairs) do res[to_num(p.key)] = RUtils.to_str(p.value, false) end
                return res
            end
            return nil
        end

        local m_list = extract_list(custom_months, 12) or preset.months
        local d_list = extract_list(custom_days, 7) or preset.days

        return {
            get_title = function(y, m)
                if self_obj then
                    local ok, fn_m = pcall(PyClass.get_attr, self_obj, "formatmonthname")
                    if ok and fn_m then
                        return RUtils.to_str(Evaluator.invoke(fn_m, {y, m}, {}), false)
                    end
                end
                local m_str = m_list[m] or tostring(m)
                return (title_fmt:gsub("{year}", tostring(y)):gsub("{month}", m_str):gsub("{month_num}", tostring(m)))
            end,
            get_weekday = function(wd)
                if self_obj then
                    local ok, fn_w = pcall(PyClass.get_attr, self_obj, "formatweekday")
                    if ok and fn_w then
                        return RUtils.to_str(Evaluator.invoke(fn_w, {wd}, {}), false)
                    end
                end
                return d_list[wd + 1] or d_names[wd + 1]
            end
        }
    end

    local function format_text_month(year, month, firstweekday, self_obj, kw)
        year, month = to_num(year), to_num(month)
        firstweekday = to_num(firstweekday or (kw and kw.firstweekday) or 0) % 7
        local i18n = resolve_i18n(self_obj, kw)
        local header = i18n.get_title(year, month)
        local pad = math.floor((20 - mw.ustring.len(header)) / 2)
        local wk_hdr = {}
        for i = 0, 6 do table.insert(wk_hdr, i18n.get_weekday((firstweekday + i) % 7)) end
        local lines = {
            string.rep(" ", math.max(0, pad)) .. header,
            table.concat(wk_hdr, " ")
        }
        local weeks = raw_monthcalendar(year, month, firstweekday)
        for _, wk in ipairs(weeks) do
            local row = {}
            for _, d in ipairs(wk) do
                table.insert(row, d == 0 and "  " or string.format("%2d", d))
            end
            table.insert(lines, table.concat(row, " "))
        end
        return table.concat(lines, "\n") .. "\n"
    end

    local function format_wiki_month(year, month, firstweekday, events_dict, self_obj, kw)
        year, month = to_num(year), to_num(month)
        firstweekday = to_num(firstweekday or (kw and kw.firstweekday) or 0) % 7
        local i18n = resolve_i18n(self_obj, kw)

        local events = {}
        local ev_src = events_dict or (kw and kw.events)
        if type(ev_src) == "table" and ev_src.__type == "dict" then
            for _, p in ipairs(ev_src.pairs) do
                events[to_num(p.key)] = RUtils.to_str(p.value, false)
            end
        end

        local out = {
            '{| class="wikitable" style="text-align:center; min-width:280px; border-collapse:collapse;"',
            '|-',
            string.format('! colspan="7" style="background:#eaecf0; font-size:110%%; padding:6px;" | %s', i18n.get_title(year, month)),
            '|-'
        }

        local hdr_cells = {}
        for i = 0, 6 do
            local wd = (firstweekday + i) % 7
            local label = i18n.get_weekday(wd)
            if wd == 6 then
                table.insert(hdr_cells, 'style="background:#f8f9fa; color:#d33;" | ' .. label)
            elseif wd == 5 then
                table.insert(hdr_cells, 'style="background:#f8f9fa; color:#06c;" | ' .. label)
            else
                table.insert(hdr_cells, 'style="background:#f8f9fa;" | ' .. label)
            end
        end
        table.insert(out, '! ' .. table.concat(hdr_cells, ' !! '))

        local weeks = raw_monthcalendar(year, month, firstweekday)
        for _, wk in ipairs(weeks) do
            table.insert(out, '|-')
            local row_cells = {}
            local i = 1
            while i <= 7 do
                local d = wk[i]
                if d == 0 then
                    local span = 1
                    while (i + span <= 7) and wk[i + span] == 0 do span = span + 1 end
                    if span > 1 then
                        table.insert(row_cells, string.format('colspan="%d" style="background:#f8f9fa;" | &nbsp;', span))
                    else
                        table.insert(row_cells, 'style="background:#f8f9fa;" | &nbsp;')
                    end
                    i = i + span
                else
                    local wd = (firstweekday + i - 1) % 7
                    local ev = events[d]
                    local styles = {}
                    if ev then
                        table.insert(styles, "background:#fff8dc; font-weight:bold;")
                    elseif wd == 6 then
                        table.insert(styles, "color:#d33;")
                    elseif wd == 5 then
                        table.insert(styles, "color:#06c;")
                    end
                    local cell_text = ev and string.format("%d<br /><small>%s</small>", d, ev) or tostring(d)
                    if #styles > 0 then
                        table.insert(row_cells, string.format('style="%s" | %s', table.concat(styles, " "), cell_text))
                    else
                        table.insert(row_cells, cell_text)
                    end
                    i = i + 1
                end
            end
            table.insert(out, '| ' .. table.concat(row_cells, ' || '))
        end

        table.insert(out, '|}')
        return table.concat(out, '\n')
    end

    local function format_wiki_year(year, cols, firstweekday, self_obj, kw)
        year = to_num(year)
        cols = math.max(1, to_num(cols or (kw and kw.cols) or 3))
        firstweekday = to_num(firstweekday or (kw and kw.firstweekday) or 0) % 7
        local out = {
            '{| class="wikitable" style="text-align:center; background:#ffffff;"',
            '|-',
            string.format('! colspan="%d" style="background:#d8e2ef; font-size:130%%; padding:8px;" | %d', cols, year)
        }
        for m = 1, 12 do
            if (m - 1) % cols == 0 then table.insert(out, '|- style="vertical-align:top;"') end
            table.insert(out, '| style="padding:6px;" |\n' .. format_wiki_month(year, m, firstweekday, nil, self_obj, kw))
        end
        table.insert(out, '|}')
        return table.concat(out, '\n')
    end

    cal_dict.isleap = fn("isleap", function(a) return isleap(a[1]) end)
    cal_dict.leapdays = fn("leapdays", function(a) return leapdays(a[1], a[2]) end)
    cal_dict.weekday = fn("weekday", function(a) return weekday(a[1], a[2], a[3]) end)
    cal_dict.monthrange = fn("monthrange", function(a)
        local fw, nd = raw_monthrange(a[1], a[2])
        return { __type = "tuple", elements = {fw, nd} }
    end)
    cal_dict.monthcalendar = fn("monthcalendar", function(a)
        local weeks = raw_monthcalendar(a[1], a[2], 0)
        local py_weeks = {}
        for _, wk in ipairs(weeks) do table.insert(py_weeks, { __type = "list", elements = wk }) end
        return { __type = "list", elements = py_weeks }
    end)

    local TextCalendar = PyClass.make_class("TextCalendar", {B.object}, {
        __init__ = fn("__init__", function(a, kw)
            a[1].__dict.firstweekday = to_num(a[2] or (kw and kw.firstweekday) or 0) % 7
            if kw then for k, v in pairs(kw) do a[1].__dict[k] = v end end
            return B.None
        end),
        formatmonth = fn("formatmonth", function(a, kw)
            return format_text_month(a[2], a[3], a[1].__dict.firstweekday, a[1], kw)
        end),
        prmonth = fn("prmonth", function(a, kw)
            table.insert(Runtime.stdout, format_text_month(a[2], a[3], a[1].__dict.firstweekday, a[1], kw))
            return B.None
        end)
    })

    local WikiCalendar = PyClass.make_class("WikiCalendar", {B.object}, {
        __init__ = fn("__init__", function(a, kw)
            a[1].__dict.firstweekday = to_num(a[2] or (kw and kw.firstweekday) or 0) % 7
            if kw then for k, v in pairs(kw) do a[1].__dict[k] = v end end
            return B.None
        end),
        formatmonth = fn("formatmonth", function(a, kw)
            local ev = a[4] or (kw and kw.events)
            return format_wiki_month(a[2], a[3], a[1].__dict.firstweekday, ev, a[1], kw)
        end),
        prmonth = fn("prmonth", function(a, kw)
            local ev = a[4] or (kw and kw.events)
            table.insert(Runtime.stdout, format_wiki_month(a[2], a[3], a[1].__dict.firstweekday, ev, a[1], kw) .. "\n")
            return B.None
        end),
        formatyear = fn("formatyear", function(a, kw)
            local cols = a[3] or (kw and kw.cols) or 3
            return format_wiki_year(a[2], cols, a[1].__dict.firstweekday, a[1], kw)
        end)
    })

    cal_dict.TextCalendar = TextCalendar
    cal_dict.WikiCalendar = WikiCalendar
    cal_dict.month = fn("month", function(a, kw) return format_text_month(a[1], a[2], 0, nil, kw) end)
    cal_dict.prmonth = fn("prmonth", function(a, kw) table.insert(Runtime.stdout, format_text_month(a[1], a[2], 0, nil, kw)); return B.None end)
    cal_dict.wikimonth = fn("wikimonth", function(a, kw)
        local fw = a[3] or (kw and kw.firstweekday) or 0
        local ev = a[4] or (kw and kw.events)
        return format_wiki_month(a[1], a[2], fw, ev, nil, kw)
    end)
    cal_dict.wikiyear = fn("wikiyear", function(a, kw)
        local cols = a[2] or (kw and kw.cols) or 3
        local fw = a[3] or (kw and kw.firstweekday) or 0
        return format_wiki_year(a[1], cols, fw, nil, kw)
    end)

    return make_namespace("calendar", cal_dict)
end

return Structes