← Files MagnosARCHIVED FILE

skills/setup/scripts/scan_folder.py

23 KB · Nov 7, 2026 · 12:59 UTC

↓ Download file

#!/usr/bin/env python3
"""Profile the CSV and XLSX files in the working folder and propose the finance profile.

Finds the entities, currencies and periods in the files. When two trial
balances are there, counts how many accounts moved at least each candidate
materiality between them ("41 of 180 moved this much last month").

Prints Markdown; saves the machine result to .magnos/runs/setup.json, which
write_profile.py reads for the entities.

  python3 scripts/scan_folder.py              every table file in the working folder (two levels deep)
  python3 scripts/scan_folder.py data/ tb.csv  only these folders and files (inside the working folder)
"""
from __future__ import annotations

import argparse
import datetime as _dt
import os
import re
import sys
from collections import Counter
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import magnos_fin as mf  # noqa: E402

TABLE_EXT = (".csv", ".tsv", ".xlsx", ".xlsm")
SKIP_DIRS = {".git", ".magnos", "node_modules", "__pycache__", ".venv", "venv", "media", ".idea", ".vscode"}
MAX_DEPTH = 2
MAX_BYTES = 50 * 1024 * 1024
MAX_SHEETS = 10
CANDIDATES = [(Decimal(25000), Decimal(10)), (Decimal(10000), Decimal(5)), (Decimal(50000), Decimal(15))]
MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September",
          "October", "November", "December"]
SYMBOLS = {"$": "USD", "€": "EUR", "£": "GBP"}
ISO_HINT = re.compile(r"(?<![a-z])(usd|eur|gbp|cad|aud|chf|jpy|sek|nok|dkk|inr|sgd|nzd|zar|mxn|brl|try|pln|hkd|cny)(?![a-z])")


def fail(msg: str) -> None:
    print(msg, file=sys.stderr)
    sys.exit(2)


# ---------------------------------------------------------------- files


def inside(path: str, root: str) -> bool:
    path, root = os.path.realpath(path), os.path.realpath(root)
    return path == root or path.startswith(root + os.sep)


def find_files(targets: List[str], cwd: str) -> Tuple[List[str], List[str]]:
    """Table files to read, and notes about files skipped."""
    found: List[str] = []
    notes: List[str] = []
    for t in targets:
        full = os.path.abspath(os.path.join(cwd, t))
        if not inside(full, cwd):
            fail(f"{t} is outside the working folder; name a file or folder inside {cwd}.")
        if not os.path.exists(full):
            fail(f"{t} does not exist; name a file or folder inside the working folder.")
        if os.path.isfile(full):
            found.append(full)
            continue
        base_depth = full.rstrip(os.sep).count(os.sep)
        for dirpath, dirnames, filenames in os.walk(full):
            dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
            if dirpath.count(os.sep) - base_depth >= MAX_DEPTH:
                dirnames[:] = []
            for f in sorted(filenames):
                if f.lower().endswith(TABLE_EXT) and not f.startswith(("~$", ".")):
                    found.append(os.path.join(dirpath, f))
    out = []
    for f in dict.fromkeys(found):
        if os.path.getsize(f) > MAX_BYTES:
            notes.append(f"{os.path.relpath(f, cwd)} skipped: larger than 50 MB")
        else:
            out.append(f)
    return out, notes


def read_tables(path: str) -> List[mf.Table]:
    if path.lower().endswith((".xlsx", ".xlsm")):
        wb = mf.read_xlsx(path)
        return [mf.read_table(path, s) for s in wb.sheet_names[:MAX_SHEETS]]
    return [mf.read_table(path)]


# ---------------------------------------------------------------- columns


def _norm(s: str) -> str:
    return re.sub(r"[^a-z0-9]", "", str(s).lower())


def pick_column(t: mf.Table, exact: Tuple[str, ...], contains: Tuple[str, ...] = (),
                avoid: Tuple[str, ...] = ()) -> Optional[str]:
    norms = [(c, _norm(c)) for c in t.columns]
    for want in exact:
        for c, n in norms:
            if n == want:
                return c
    for want in contains:
        for c, n in norms:
            if want in n and not any(a in n for a in avoid):
                return c
    return None


def entity_values(t: mf.Table) -> List[str]:
    col = pick_column(t, ("entity", "entitycode", "entityname", "legalentity", "subsidiary", "companycode"),
                      ("entity", "subsidiary"), ("identity",))
    if not col or t.kind(col) not in ("text", "number"):
        return []
    vals = [v.strip() for v in t.col(col) if v.strip()]
    distinct = list(dict.fromkeys(vals))
    if not distinct or len(distinct) > 12 or any(len(v) > 40 for v in distinct):
        return []
    return distinct


def currency_rows(t: mf.Table) -> Counter:
    """Rows per currency: a currency column, else a code in a header, else symbols in the amounts."""
    out: Counter = Counter()
    col = pick_column(t, ("currency", "currencycode", "ccy", "curr", "isocurrency", "transactioncurrency",
                          "reportingcurrency"), ("currency",))
    if col:
        for v in t.col(col):
            v = v.strip()
            if re.fullmatch(r"[A-Za-z]{3}", v):
                out[v.upper()] += 1
            elif v in SYMBOLS:
                out[SYMBOLS[v]] += 1
        if out:
            return out
    for c in t.columns:
        m = ISO_HINT.search(str(c).lower())
        if m and t.kind(c) == "number":
            out[m.group(1).upper()] += len(t)
            return out
    for c in t.columns:
        for v in t.col(c)[:5000]:
            s = v.strip()
            for sym, code in SYMBOLS.items():
                if s and (s.lstrip("(-−").startswith(sym) or s.endswith(sym)) and mf.to_decimal(s) is not None:
                    out[code] += 1
    return out


_MONTH_HEADER = re.compile(r"^([A-Za-z]{3,9})\.?[\s\-'/_]*(\d{2}|\d{4})$")


def header_month(h: str) -> Optional[_dt.date]:
    s = str(h).strip()
    d = mf.parse_date(s)
    if d is not None and mf.to_decimal(s) is None:
        return d.replace(day=1)
    m = _MONTH_HEADER.match(s)
    if m:
        key = m.group(1).lower()
        months = {n.lower(): i + 1 for i, n in enumerate(MONTHS)}
        months.update({n[:3].lower(): i + 1 for i, n in enumerate(MONTHS)})
        months["sept"] = 9
        if key in months:
            y = int(m.group(2))
            return _dt.date(y + 2000 if y < 100 else y, months[key], 1)
    return None


PREFERRED_DATE_NAMES = ("period", "postingdate", "date", "month", "transactiondate", "invoicedate", "gldate",
                        "accountingdate", "postdate", "entrydate", "closedate", "documentdate")


def date_span(t: mf.Table) -> Tuple[Optional[_dt.date], Optional[_dt.date]]:
    """First and last date in the table's period or date columns."""
    cols: List[Tuple[str, bool]] = []  # (column, values are Excel serials)
    for c in t.columns:
        n = _norm(c)
        kind = t.kind(c)
        if kind == "date":
            cols.append((c, False))
        elif kind == "number" and any(k in n for k in ("date", "period", "month")):
            sample = [mf.to_decimal(v) for v in t.col(c)[:200] if v.strip()]
            if sample and all(x is not None and 20000 < x < 80000 for x in sample):
                cols.append((c, True))
    preferred = [x for x in cols if _norm(x[0]) in PREFERRED_DATE_NAMES]
    use = preferred or cols
    first = last = None
    for c, serial in use:
        for v in t.col(c):
            if not v.strip():
                continue
            d = mf.parse_date(str(int(float(v))) if serial else v)
            if d is None:
                continue
            first = d if first is None or d < first else first
            last = d if last is None or d > last else last
    months = [m for m in (header_month(h) for h in t.columns) if m]
    if len(months) >= 3:
        lo, hi = min(months), mf.month_end(max(months))
        first = lo if first is None or lo < first else first
        last = hi if last is None or hi > last else last
    return first, last


def fiscal_hint(t: mf.Table) -> Optional[int]:
    """The fiscal year's first month, from 12 month headers in a row or a fiscal period column."""
    months = [header_month(h) for h in t.columns]
    run: List[_dt.date] = []
    for m in months:
        if m is None:
            if len(run) >= 12:
                break
            run = []
            continue
        if run and (m.year * 12 + m.month) != (run[-1].year * 12 + run[-1].month) + 1:
            if len(run) >= 12:
                break
            run = []
        run.append(m)
    if len(run) == 12:
        return run[0].month
    fp = pick_column(t, ("fiscalperiod", "periodnumber", "fiscalmonth", "fp", "periodno"))
    if fp:
        dcol = next((c for c in t.columns if t.kind(c) == "date"), None)
        if dcol:
            firsts = Counter()
            for row in t.rows:
                if str(row.get(fp, "")).strip() in ("1", "01", "P1", "p1", "P01"):
                    d = mf.parse_date(row.get(dcol, ""))
                    if d:
                        firsts[d.month] += 1
            if firsts:
                return firsts.most_common(1)[0][0]
    return None


# ---------------------------------------------------------------- trial balances


_MON_TOKEN = re.compile(
    r"(?<![a-z])(january|february|march|april|may|june|july|august|september|october|november|december"
    r"|jan|feb|mar|apr|jun|jul|aug|sept|sep|oct|nov|dec)(?![a-z])(?:[\s_\-]*(20\d\d|\d\d)(?!\d))?")
_YM_TOKEN = re.compile(r"(?<!\d)(20\d\d)[\s_\-]?(0[1-9]|1[0-2])(?!\d)")


def period_from_name(path: str) -> Optional[Tuple[Optional[int], int]]:
    name = os.path.splitext(os.path.basename(path))[0].lower()
    m = _YM_TOKEN.search(name)
    if m:
        return int(m.group(1)), int(m.group(2))
    m = _MON_TOKEN.search(name)
    if m:
        mon = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
        tok = "sep" if m.group(1) == "sept" else m.group(1)[:3]
        y = m.group(2)
        year = None if not y else (int(y) + 2000 if len(y) == 2 else int(y))
        return year, mon.index(tok) + 1
    return None


def trial_balance(t: mf.Table) -> Optional[Dict[str, Any]]:
    """Balances by (entity, account) when the table looks like a trial balance."""
    acct = pick_column(t, ("account", "accountnumber", "accountno", "acct", "acctno", "glaccount", "accountcode",
                           "accountid", "glcode"), ("account", "acct"),
                       ("name", "desc", "type", "categ", "group", "class", "title"))
    if not acct:
        return None
    bal = pick_column(t, ("balance", "endingbalance", "closingbalance", "netbalance", "net", "amount", "ytdbalance",
                          "periodend", "value"), ("balance",), ("opening", "beginning", "begin"))
    debit = pick_column(t, ("debit", "debits", "dr"), ("debit",))
    credit = pick_column(t, ("credit", "credits", "cr"), ("credit",))
    use_dc = bool(debit and credit and t.kind(debit) == "number" and t.kind(credit) == "number")
    if not use_dc and (not bal or t.kind(bal) != "number"):
        return None
    ent_col = pick_column(t, ("entity", "entitycode", "entityname", "legalentity", "subsidiary", "companycode"),
                          ("entity", "subsidiary"), ("identity",))
    balances: Dict[Tuple[str, str], Decimal] = {}
    rows = 0
    for row in t.rows:
        a = str(row.get(acct, "")).strip()
        if not a or a.lower().startswith("total"):
            continue
        if use_dc:
            v = (mf.to_decimal(row.get(debit)) or mf.D0) - (mf.to_decimal(row.get(credit)) or mf.D0)
        else:
            v = mf.to_decimal(row.get(bal))
            if v is None:
                continue
        key = (str(row.get(ent_col, "")).strip() if ent_col else "", a)
        balances[key] = balances.get(key, mf.D0) + v
        rows += 1
    if rows < 5 or len(balances) < 0.9 * rows:
        return None  # many rows per account: a ledger, not a trial balance
    looks = (re.search(r"(?<![a-z])(tb|trial)", os.path.basename(t.path).lower())
             or (bal and "balance" in _norm(bal)) or use_dc)
    if not looks:
        return None
    return {"balances": balances, "entity_column": ent_col or ""}


def tb_period(path: str, t: mf.Table) -> Optional[Tuple[Optional[int], int]]:
    pcol = pick_column(t, ("period", "periodend", "asof", "asofdate", "date", "month"))
    if pcol:
        dates = [d for d in (mf.parse_date(v) for v in t.col(pcol)) if d]
        if dates:
            d = max(dates)
            return d.year, d.month
    return period_from_name(path)


def order_pair(a: Tuple[Optional[int], int], b: Tuple[Optional[int], int]) -> bool:
    """True when period a is earlier than period b."""
    ya, ma = a
    yb, mb = b
    if ya is not None and yb is not None:
        return (ya, ma) < (yb, mb)
    if abs(ma - mb) > 6:  # Dec then Jan: the year turned
        return ma > mb
    return ma < mb


def period_label(p: Tuple[Optional[int], int]) -> str:
    y, m = p
    return f"{MONTHS[m - 1][:3]} {y}" if y else MONTHS[m - 1][:3]


def moves(prior: Dict[Tuple[str, str], Decimal], current: Dict[Tuple[str, str], Decimal],
          candidates: List[Tuple[Decimal, Decimal]]) -> Tuple[int, List[Dict[str, Any]]]:
    keys = set(prior) | set(current)
    out = []
    for amt, pct in candidates:
        n = 0
        for k in keys:
            p, c = prior.get(k, mf.D0), current.get(k, mf.D0)
            diff = abs(c - p)
            if diff < amt:
                continue
            if p == 0 or diff / abs(p) * 100 >= pct:
                n += 1
        out.append({"amount": amt, "pct": pct, "accounts_moved": n})
    return len(keys), out


# ---------------------------------------------------------------- main


def symbol(code: str) -> str:
    return {"USD": "$", "EUR": "€", "GBP": "£"}.get(code, code + " ")


def option_label(amt: Decimal, pct: Decimal, code: str) -> str:
    return f"{mf.money(amt, currency=symbol(code))} and {mf.num(pct, 2 if pct != pct.to_integral() else 0)}%"


def main(argv: Optional[List[str]] = None) -> int:
    p = argparse.ArgumentParser(description="Profile the table files in the working folder and propose the finance profile.")
    p.add_argument("paths", nargs="*", default=["."], help="folders or files inside the working folder (default: the working folder)")
    p.add_argument("--json", default=os.path.join(".magnos", "runs", "setup.json"), help="where to save the machine result")
    a = p.parse_args(argv)
    cwd = os.getcwd()
    files, notes = find_files(a.paths, cwd)
    profile = mf.load_profile(cwd)
    has_profile = profile.get("_source") != "defaults"

    per_file: List[Dict[str, Any]] = []
    entities: List[str] = []
    currencies: Counter = Counter()
    currency_files: Counter = Counter()
    first = last = None
    fiscal_hints: Counter = Counter()
    tbs: List[Dict[str, Any]] = []
    for f in files:
        rel = os.path.relpath(f, cwd)
        try:
            tables = read_tables(f)
        except Exception as e:  # a broken or locked file should not stop the scan
            notes.append(f"{rel} skipped: could not be read ({type(e).__name__})")
            continue
        f_rows = 0
        f_ents: List[str] = []
        f_cur: Counter = Counter()
        f_first = f_last = None
        for t in tables:
            if not t.columns:
                continue
            f_rows += len(t)
            for e in entity_values(t):
                if e not in f_ents:
                    f_ents.append(e)
            f_cur.update(currency_rows(t))
            lo, hi = date_span(t)
            if lo and (f_first is None or lo < f_first):
                f_first = lo
            if hi and (f_last is None or hi > f_last):
                f_last = hi
            h = fiscal_hint(t)
            if h:
                fiscal_hints[h] += 1
            tb = trial_balance(t)
            if tb:
                tb.update({"file": rel, "period": tb_period(f, t), "sheet": t.sheet})
                tbs.append(tb)
        for e in f_ents:
            if e not in entities:
                entities.append(e)
        currencies.update(f_cur)
        for code in f_cur:
            currency_files[code] += 1
        if f_first and (first is None or f_first < first):
            first = f_first
        if f_last and (last is None or f_last > last):
            last = f_last
        per_file.append({
            "file": rel, "rows": f_rows, "entities": f_ents,
            "currencies": [c for c, _ in f_cur.most_common()],
            "first": f_first.isoformat() if f_first else None, "last": f_last.isoformat() if f_last else None,
            "trial_balance": any(tb["file"] == rel for tb in tbs),
        })

    # the proposals: current answers first when a profile exists
    found_currency = currencies.most_common(1)[0][0] if currencies else None
    hint = fiscal_hints.most_common(1)[0][0] if fiscal_hints else None
    if has_profile:
        fy = str(profile["fiscal_year_start"])
        fy_why = "your current profile"
    elif hint:
        fy = MONTHS[hint - 1]
        fy_why = "a 12-month budget or a fiscal period column starts in this month"
    else:
        fy = "January"
        fy_why = "no fiscal calendar in the files; calendar year"
    if has_profile:
        cur, cur_why = str(profile["currency"]), "your current profile"
    elif found_currency:
        cur = found_currency
        others = [c for c in currencies if c != cur]
        cur_why = f"{currency_files[cur]} of {len(per_file)} files" + (f"; also found {', '.join(others)}" if others else "")
    else:
        cur, cur_why = "USD", "no currency in the files; the default"
    amt = profile["materiality_amount"] if has_profile else Decimal(25000)
    pct = profile["materiality_pct"] if has_profile else Decimal(10)
    sign = str(profile["variance_sign"]) if has_profile else "favourable-positive"

    candidates = list(CANDIDATES)
    if (amt, pct) not in candidates:
        candidates.insert(0, (amt, pct))
    comparison = None
    dated = [tb for tb in tbs if tb["period"]]
    pair: Optional[Tuple[Dict[str, Any], Dict[str, Any]]] = None
    order_note = ""
    if len(dated) >= 2:
        ordered = list(dated)
        for i in range(len(ordered)):  # a small sort with the year-turn rule
            for j in range(len(ordered) - 1 - i):
                if order_pair(ordered[j + 1]["period"], ordered[j]["period"]):
                    ordered[j], ordered[j + 1] = ordered[j + 1], ordered[j]
        pair = (ordered[-2], ordered[-1])
    elif len(tbs) == 2:
        pair = tuple(sorted(tbs, key=lambda x: x["file"]))  # type: ignore[assignment]
        order_note = "the order comes from the file names"
    if pair:
        prior, current = pair
        n_accounts, counts = moves(prior["balances"], current["balances"], candidates)
        comparison = {
            "prior": prior["file"], "current": current["file"],
            "prior_period": period_label(prior["period"]) if prior["period"] else None,
            "current_period": period_label(current["period"]) if current["period"] else None,
            "accounts": n_accounts, "options": counts, "note": order_note,
        }

    # ---------------------------------------------------------------- the report
    lines: List[str] = []
    if not per_file:
        lines.append("No CSV or XLSX files found in this folder. The profile can still be set: answer the four questions.")
    else:
        ent = (f"{len(entities)} entit{'y' if len(entities) == 1 else 'ies'} ({', '.join(entities)})"
               if entities else "no entity column")
        if currencies:
            codes = [c for c, _ in currencies.most_common()]
            curp = f"amounts in {codes[0]}" if len(codes) == 1 else f"amounts in {', '.join(codes)} (more than one currency)"
        else:
            curp = "no currency stated"
        span = f"periods {mf.month_label(first)} – {mf.month_label(last)}" if first and last else "no dates found"
        lines.append(f"Found {len(per_file)} file{'s' if len(per_file) != 1 else ''}: {ent}, {curp}, {span}.")
        lines.append("")
        rows = []
        for pf in per_file:
            per = "—"
            if pf["first"] and pf["last"]:
                per = f"{mf.month_label(_dt.date.fromisoformat(pf['first']))} – {mf.month_label(_dt.date.fromisoformat(pf['last']))}"
            rows.append([pf["file"] + (" (trial balance)" if pf["trial_balance"] else ""), mf.num(pf["rows"]),
                         ", ".join(pf["entities"]) or "—", ", ".join(pf["currencies"]) or "—", per])
        lines.append(mf.md_table(["File", "Rows", "Entities", "Currency", "Periods"], rows, ["l", "r", "l", "l", "l"]))
    if has_profile:
        lines += ["", f"Current profile: {mf.profile_line(profile)} (.magnos/finance.md). Its answers are the defaults."]
    mat_why = "the Magnos default"
    if comparison:
        first_opt = next(o for o in comparison["options"] if (o["amount"], o["pct"]) == (amt, pct))
        span_txt = (f"from {comparison['prior_period']} to {comparison['current_period']}"
                    if comparison["prior_period"] else f"from {comparison['prior']} to {comparison['current']}")
        mat_why = f"{first_opt['accounts_moved']} of {comparison['accounts']} accounts moved this much {span_txt}"
    if has_profile:
        mat_why = "your current profile" + ("; " + mat_why if comparison else "")
    sign_label = {"favourable-positive": "Favourable positive", "actual-minus-budget": "Actual minus budget"}.get(sign, sign)
    lines += ["", "Proposed answers:", "", mf.md_table(["Question", "Proposed", "Why"], [
        ["Fiscal year starts in", fy, fy_why],
        ["Reporting currency", cur, cur_why],
        ["Materiality for close reviews", option_label(amt, pct, cur), mat_why],
        ["Variances are signed", sign_label, "your current profile" if has_profile else "the Magnos default"],
    ], ["l", "l", "l"])]
    if comparison:
        head = (f"Materiality options, on {comparison['prior']} → {comparison['current']} "
                f"({mf.num(comparison['accounts'])} accounts{'; ' + comparison['note'] if comparison['note'] else ''}):")
        lines += ["", head, "", mf.md_table(["Option", "Accounts that moved this much"], [
            [option_label(o["amount"], o["pct"], cur), f"{mf.num(o['accounts_moved'])} of {mf.num(comparison['accounts'])}"]
            for o in comparison["options"]])]
    elif len(tbs) < 2:
        lines += ["", "No two trial balances to compare, so no account counts for the materiality options."]
    if notes:
        lines += ["", "Skipped: " + "; ".join(notes) + "."]

    data = {
        "folder": cwd,
        "files": per_file,
        "entities": entities,
        "currencies": dict(currencies),
        "first": first.isoformat() if first else None,
        "last": last.isoformat() if last else None,
        "fiscal_year_start_hint": MONTHS[hint - 1] if hint else None,
        "trial_balances": comparison,
        "current_profile": {k: (str(v) if isinstance(v, Decimal) else v) for k, v in profile.items()} if has_profile else None,
        "proposals": {
            "fiscal_year_start": fy, "currency": cur, "materiality_amount": str(amt), "materiality_pct": str(pct),
            "variance_sign": sign, "entities": ", ".join(entities) if entities else (str(profile.get("entities", "")) if has_profile else ""),
        },
        "notes": notes,
    }
    mf.emit("\n".join(lines), data, os.path.join(cwd, a.json))
    return 0


if __name__ == "__main__":
    sys.exit(main())

SHA-256: b8324dea73a51ead3d2408598b11e1b96365d5674170cc717eeff0708cf1ebef