From c8de4e3692a51e1ce0e8a0cd355593162f89ff69 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Fri, 12 Jun 2026 17:32:08 +0800 Subject: [PATCH] feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create Synced from sheet-skill-spec canonical (cli:table_put schema + references). +table-put/+workbook-create accept the new shape via a tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal type/format mapping). +table-get emits the same shape so the writer's df_to_sheet and the reader's sheet_to_df round-trip cleanly. isoDateToSerial now accepts the full ISO datetime form (2024-01-15T00:00:00.000, including timezone suffixes) emitted by df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified by the spec repo's contracts/python_helper_roundtrip script against a real Lark spreadsheet on pandas 2.2 and 3.0. --- shortcuts/sheets/data/flag-defs.json | 4 +- shortcuts/sheets/data/flag-schemas.json | 108 +++---- shortcuts/sheets/flag_defs_gen.go | 4 +- shortcuts/sheets/lark_sheet_table_io.go | 259 +++++++++++++++-- shortcuts/sheets/lark_sheet_table_io_test.go | 274 +++++++++++++++--- ...ark-sheets-financial-modeling-standards.md | 36 ++- .../references/lark-sheets-read-data.md | 50 +++- .../references/lark-sheets-workbook.md | 31 +- .../references/lark-sheets-write-cells.md | 68 +++-- 9 files changed, 636 insertions(+), 198 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index a6e5fcf19..a83acfb9d 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -526,7 +526,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", + "desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", "input": [ "file", "stdin" @@ -2065,7 +2065,7 @@ "kind": "own", "type": "string", "required": "required", - "desc": "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool", + "desc": "Typed table payload (pandas-DataFrame-shaped) as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", "input": [ "file", "stdin" diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json index c5f74def3..be5b23292 100644 --- a/shortcuts/sheets/data/flag-schemas.json +++ b/shortcuts/sheets/data/flag-schemas.json @@ -6248,13 +6248,13 @@ "sheets": { "type": "array", "minItems": 1, - "description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成(NaN→null、Timestamp→ISO、numpy 标量→原生)。", + "description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。", "items": { "type": "object", "required": [ "name", "columns", - "rows" + "data" ], "properties": { "name": { @@ -6277,7 +6277,7 @@ }, "header": { "type": "boolean", - "description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。" + "description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。" }, "allow_overwrite": { "type": "boolean", @@ -6287,38 +6287,14 @@ "columns": { "type": "array", "minItems": 1, - "description": "列定义,顺序与 rows 中每行的取值一一对应。", + "description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。", "items": { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "type": "string", - "description": "列名(写入表头行的文本)。" - }, - "type": { - "type": "string", - "enum": [ - "string", - "number", - "date", - "bool" - ], - "description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字)。string 列由 +table-put 自动套文本格式(number_format `@`),数字样字符串(含前导零,如「00123」)读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串,CLI 转成 Excel 序列号 + 日期 number_format(真日期,可排序 / 透视 / 筛选)。" - }, - "format": { - "type": "string", - "description": "可选的单元格 number_format,如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责(0.0469 配 \"0.00%\",helper 不自动乘 100)。" - } - } + "type": "string" } }, - "rows": { + "data": { "type": "array", - "description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值,null 表示空单元格。", + "description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。", "items": { "type": "array", "items": { @@ -6328,9 +6304,23 @@ "boolean", "null" ], - "description": "单元格值,按所在列的 type 取值(string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔);null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。" + "description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。" } } + }, + "dtypes": { + "type": "object", + "description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。", + "additionalProperties": { + "type": "string" + } + }, + "formats": { + "type": "object", + "description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。", + "additionalProperties": { + "type": "string" + } } } } @@ -6340,13 +6330,13 @@ "sheets": { "type": "array", "minItems": 1, - "description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成(NaN→null、Timestamp→ISO、numpy 标量→原生)。", + "description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。", "items": { "type": "object", "required": [ "name", "columns", - "rows" + "data" ], "properties": { "name": { @@ -6369,7 +6359,7 @@ }, "header": { "type": "boolean", - "description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。" + "description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。" }, "allow_overwrite": { "type": "boolean", @@ -6379,38 +6369,14 @@ "columns": { "type": "array", "minItems": 1, - "description": "列定义,顺序与 rows 中每行的取值一一对应。", + "description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。", "items": { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "type": "string", - "description": "列名(写入表头行的文本)。" - }, - "type": { - "type": "string", - "enum": [ - "string", - "number", - "date", - "bool" - ], - "description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字)。string 列由 +table-put 自动套文本格式(number_format `@`),数字样字符串(含前导零,如「00123」)读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串,CLI 转成 Excel 序列号 + 日期 number_format(真日期,可排序 / 透视 / 筛选)。" - }, - "format": { - "type": "string", - "description": "可选的单元格 number_format,如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责(0.0469 配 \"0.00%\",helper 不自动乘 100)。" - } - } + "type": "string" } }, - "rows": { + "data": { "type": "array", - "description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值,null 表示空单元格。", + "description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。", "items": { "type": "array", "items": { @@ -6420,9 +6386,23 @@ "boolean", "null" ], - "description": "单元格值,按所在列的 type 取值(string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔);null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。" + "description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。" } } + }, + "dtypes": { + "type": "object", + "description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。", + "additionalProperties": { + "type": "string" + } + }, + "formats": { + "type": "object", + "description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。", + "additionalProperties": { + "type": "string" + } } } } diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index 2442d0ab1..41358ca65 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -931,7 +931,7 @@ var flagDefs = map[string]commandDef{ Flags: []flagDef{ {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"}, - {Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool", Input: []string{"file", "stdin"}}, + {Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, @@ -941,7 +941,7 @@ var flagDefs = map[string]commandDef{ {Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"}, {Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"}, {Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}}, - {Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}}, + {Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}}, {Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, diff --git a/shortcuts/sheets/lark_sheet_table_io.go b/shortcuts/sheets/lark_sheet_table_io.go index 72c09a018..9d0e14fa6 100644 --- a/shortcuts/sheets/lark_sheet_table_io.go +++ b/shortcuts/sheets/lark_sheet_table_io.go @@ -81,34 +81,127 @@ type tablePayload struct { Sheets []tableSheetSpec `json:"sheets"` } +// tableSheetSpec is the *internal* representation a sheet is normalized into +// after parsing the wire protocol. It carries everything buildSheetMatrix and +// friends need (typed columns + format + 2D row matrix) and is what the rest of +// this file works against. The wire shape — string columns + dtypes/formats +// maps + `data` — lives in tableSheetIn and is collapsed into this struct by +// (*tableSheetIn).normalize. type tableSheetSpec struct { - Name string `json:"name"` - StartCell string `json:"start_cell"` + Name string + StartCell string // Mode controls write placement: "overwrite" (default) writes a header+data // block from start_cell; "append" writes data below the sheet's existing // data (start_cell's row is ignored, its column is honored). - Mode string `json:"mode"` + Mode string // Header is whether to write a header row of column names. nil defaults by // mode: true for overwrite, false for append (so appended rows don't repeat // the header). Set explicitly to override. - Header *bool `json:"header"` + Header *bool // AllowOverwrite, when explicitly false, makes the write fail if it would // land on a non-empty cell. nil defaults to true (overwrite). - AllowOverwrite *bool `json:"allow_overwrite"` - Columns []tableColumnSpec `json:"columns"` - Rows [][]interface{} `json:"rows"` + AllowOverwrite *bool + Columns []tableColumnSpec + Rows [][]interface{} } type tableColumnSpec struct { - Name string `json:"name"` - Type string `json:"type"` - Format string `json:"format"` + Name string + Type string + Format string +} + +// tableSheetIn is the wire-level shape of one sheet in --sheets. It is +// pandas-DataFrame-shaped on purpose: `columns` is a plain string list, `data` +// is a 2D array, and the per-column type / display format are *separate* +// dtypes/formats maps keyed by column name. That gives agents a one-liner +// (`{**json.loads(df.to_json(orient="split")), "dtypes": +// df.dtypes.astype(str).to_dict()}`) and lets handwritten payloads stay flat +// rather than nest a {name, type, format} object per column. +type tableSheetIn struct { + Name string `json:"name"` + StartCell string `json:"start_cell"` + Mode string `json:"mode"` + Header *bool `json:"header"` + AllowOverwrite *bool `json:"allow_overwrite"` + Columns []string `json:"columns"` + Data [][]interface{} `json:"data"` + Dtypes map[string]string `json:"dtypes"` + Formats map[string]string `json:"formats"` +} + +// dtypeToTypeFormat maps a pandas-style dtype string to the internal column +// (type, default format) pair. The mapping is deliberately permissive: a missing +// or unknown dtype falls through to string + text format (`@`) so a +// `to_json(orient="split")` payload that omits `dtypes` writes correctly as an +// all-string table. Recognized families: +// - int*/uint* (lowercase numpy + capitalized nullable pandas) → number +// - float* / Float* / complex* → number +// - bool / boolean (nullable) → bool +// - datetime* (incl. tz-aware datetime64[ns, UTC]) → date, "yyyy-mm-dd" +// - everything else (object, string, category, empty, unknown) → string, "@" +// +// Explicit `formats[col]` is layered on top of this default by normalize, so a +// user-supplied `#,##0.00` on a float64 column still wins. +func dtypeToTypeFormat(dtype string) (typ, format string) { + d := strings.TrimSpace(dtype) + if d == "" { + return "string", "@" + } + lower := strings.ToLower(d) + switch { + case strings.HasPrefix(lower, "datetime"): + return "date", "yyyy-mm-dd" + case lower == "bool" || lower == "boolean": + return "bool", "" + case isNumericDtype(lower): + return "number", "" + default: + return "string", "@" + } +} + +// isNumericDtype recognizes pandas/numpy numeric dtype strings (lowercased). +// Covers numpy ints (`int8`/`int64`/...), unsigned ints (`uint*`), floats +// (`float32`/`float64`), complex, and pandas' nullable variants +// (`int64`/`uint64`/`float64` lowercased from `Int64`/`UInt64`/`Float64`). +func isNumericDtype(lower string) bool { + for _, p := range []string{"int", "uint", "float", "complex"} { + if strings.HasPrefix(lower, p) { + return true + } + } + return false +} + +// typeToDtype is the inverse used by +table-get to label each output column. +// Choices are picked to be safe under a single `df.astype(dtypes)` round-trip: +// - string → object (pandas default, no-op astype) +// - number → float64 (works for all numeric cells, including ones with NaN) +// - date → datetime64[ns] (matches the ISO strings we emit) +// - bool → bool (inferColumnType only picks bool when every cell is bool) +// +// Anything else (defensive default) maps to object. +func typeToDtype(typ string) string { + switch typ { + case "number": + return "float64" + case "date": + return "datetime64[ns]" + case "bool": + return "bool" + default: + return "object" + } } // parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a // validated payload. UseNumber keeps numeric cells as json.Number so large // integers (order IDs, etc.) survive without precision loss or scientific -// notation. Network-free: safe from Validate and DryRun. +// notation. The wire shape (tableSheetIn: string columns + dtypes/formats maps +// + `data`) is normalized into the internal tableSheetSpec so the rest of the +// file (buildSheetMatrix, sheetCreateDims, …) is unaware of it. Network-free: +// safe from Validate and DryRun. func parseTablePutPayload(runtime flagView) (*tablePayload, error) { raw := strings.TrimSpace(runtime.Str("sheets")) if raw == "" { @@ -116,14 +209,74 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) { } dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() - var p tablePayload - if err := dec.Decode(&p); err != nil { + var wire struct { + Sheets []tableSheetIn `json:"sheets"` + } + if err := dec.Decode(&wire); err != nil { return nil, common.FlagErrorf("--sheets: invalid JSON: %v", err) } + p := &tablePayload{Sheets: make([]tableSheetSpec, 0, len(wire.Sheets))} + for i := range wire.Sheets { + spec, err := wire.Sheets[i].normalize(i) + if err != nil { + return nil, err + } + p.Sheets = append(p.Sheets, spec) + } if err := p.validate(); err != nil { return nil, err } - return &p, nil + return p, nil +} + +// normalize collapses the wire-level pandas-shaped tableSheetIn into the +// internal tableSheetSpec used by the writer. It pairs each column name with +// its dtype-derived (type, format) — with `formats[name]` overriding the +// default — and renames `data` back to the writer's `Rows`. Per-column +// validation that needs the resolved type lives in tablePayload.validate (so +// errors carry the sheet-index/name context the writer already prints). +func (in *tableSheetIn) normalize(idx int) (tableSheetSpec, error) { + spec := tableSheetSpec{ + Name: in.Name, + StartCell: in.StartCell, + Mode: in.Mode, + Header: in.Header, + AllowOverwrite: in.AllowOverwrite, + Rows: in.Data, + } + seenCol := make(map[string]bool, len(in.Columns)) + spec.Columns = make([]tableColumnSpec, len(in.Columns)) + for j, name := range in.Columns { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return tableSheetSpec{}, common.FlagErrorf("--sheets[%d] %q: columns[%d] name is required", idx, in.Name, j) + } + if seenCol[name] { + return tableSheetSpec{}, common.FlagErrorf("--sheets[%d] %q: duplicate column name %q", idx, in.Name, name) + } + seenCol[name] = true + typ, format := dtypeToTypeFormat(in.Dtypes[name]) + if f, ok := in.Formats[name]; ok { + format = strings.TrimSpace(f) + } + spec.Columns[j] = tableColumnSpec{Name: name, Type: typ, Format: format} + } + // Surface dtypes/formats entries that reference a column the sheet doesn't + // have — almost always a typo (`"foramt"`, `"营 收"` with stray spaces) and + // silently ignoring them would let the writer succeed with the wrong + // formatting. The check runs after the column list is built so we can + // compare against the canonical set. + for k := range in.Dtypes { + if !seenCol[k] { + return tableSheetSpec{}, common.FlagErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k) + } + } + for k := range in.Formats { + if !seenCol[k] { + return tableSheetSpec{}, common.FlagErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k) + } + } + return spec, nil } func (p *tablePayload) validate() error { @@ -145,9 +298,10 @@ func (p *tablePayload) validate() error { } for j := range s.Columns { c := &s.Columns[j] - if strings.TrimSpace(c.Name) == "" { - return common.FlagErrorf("--sheets[%d] %q: columns[%d].name is required", i, s.Name, j) - } + // validColumnType still guards the internal Type so a future + // dtype-mapping change (or a direct test-time construction of a + // tableSheetSpec) can't silently route an unknown type into + // buildTypedCell's default branch. if !validColumnType(c.Type) { return common.FlagErrorf("--sheets[%d] %q: columns[%d] %q has invalid type %q (want string/number/date/bool)", i, s.Name, j, c.Name, c.Type) @@ -345,8 +499,20 @@ var excelEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC) // number. The result is written as a numeric cell value with a date // number_format, which is the only combination that yields a real (sortable, // pivotable, ISNUMBER=TRUE) date in Lark Sheets. +// +// Accepts both bare dates (`2024-01-15`) and full ISO datetime strings with a +// `T` separator (`2024-01-15T00:00:00.000`, `2024-01-15T08:30:00+08:00`). The +// `T...` suffix is dropped before parsing so the pandas `df_to_sheet` helper +// — which uses `df.to_json(orient="split", date_format="iso")` and therefore +// always emits the full ISO form — round-trips without an extra string clean +// step on the agent side. A leading `T` (no date prefix) is left alone so the +// parser still rejects it cleanly. func isoDateToSerial(s string) (int, error) { - t, err := time.Parse("2006-01-02", strings.TrimSpace(s)) + s = strings.TrimSpace(s) + if i := strings.Index(s, "T"); i > 0 { + s = s[:i] + } + t, err := time.Parse("2006-01-02", s) if err != nil { return 0, fmt.Errorf("date %q must be ISO yyyy-mm-dd: %v", s, err) } @@ -881,9 +1047,26 @@ func tableGetTargets(ctx context.Context, runtime *common.RuntimeContext, token } // readSheetAsSpec reads one sheet's region and rebuilds it as a typed-protocol -// sheet (name + typed columns + JSON-safe rows), the inverse of the put path. +// sheet — the inverse of the put path and the same wire shape +table-put +// accepts: a string `columns` list, a 2D `data` matrix, and `dtypes` / `formats` +// maps keyed by column name. That symmetry lets callers round-trip via the +// pandas-native idiom +// +// pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"]) +// +// without a custom helper. `dtypes` is always emitted (one entry per column, so +// a single `astype()` call covers every column); `formats` is emitted only for +// columns whose source cells carry a non-empty number_format, since `astype` +// ignores it and we'd rather not pollute the output. func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool) (map[string]interface{}, error) { - spec := map[string]interface{}{"name": t.name, "columns": []interface{}{}, "rows": []interface{}{}} + emptySpec := func() map[string]interface{} { + return map[string]interface{}{ + "name": t.name, + "columns": []interface{}{}, + "data": []interface{}{}, + "dtypes": map[string]interface{}{}, + } + } region := userRange if region == "" { r, err := sheetCurrentRegion(ctx, runtime, token, t.id, t.name) @@ -893,7 +1076,7 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token region = r } if region == "" { - return spec, nil // empty sheet + return emptySpec(), nil // empty sheet } input := map[string]interface{}{ "excel_id": token, @@ -909,7 +1092,7 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token } grid := extractCellGrid(out) if len(grid) == 0 { - return spec, nil + return emptySpec(), nil } var headerRow []map[string]interface{} @@ -925,28 +1108,42 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token } } - columns := make([]interface{}, ncols) + columnNames := make([]interface{}, ncols) colTypes := make([]string, ncols) + dtypes := make(map[string]interface{}, ncols) + formats := map[string]interface{}{} for c := 0; c < ncols; c++ { typ, format := inferColumnType(dataRows, c) colTypes[c] = typ - col := map[string]interface{}{"name": tableGetColumnName(headerRow, c, noHeader), "type": typ} - if format != "" { - col["format"] = format + name := tableGetColumnName(headerRow, c, noHeader) + columnNames[c] = name + dtypes[name] = typeToDtype(typ) + // Only emit a format when the column actually has one and it's not the + // implicit text-format we paint on string columns (the `@` is a writer + // convention, not user intent — surfacing it would round-trip back as + // an explicit format the user never set). + if format != "" && !isTextNumberFormat(format) { + formats[name] = format } - columns[c] = col } - rows := make([][]interface{}, 0, len(dataRows)) + data := make([][]interface{}, 0, len(dataRows)) for _, r := range dataRows { row := make([]interface{}, ncols) for c := 0; c < ncols; c++ { row[c] = cellToTyped(cellAt(r, c), colTypes[c]) } - rows = append(rows, row) + data = append(data, row) + } + spec := map[string]interface{}{ + "name": t.name, + "columns": columnNames, + "data": data, + "dtypes": dtypes, + } + if len(formats) > 0 { + spec["formats"] = formats } - spec["columns"] = columns - spec["rows"] = rows return spec, nil } diff --git a/shortcuts/sheets/lark_sheet_table_io_test.go b/shortcuts/sheets/lark_sheet_table_io_test.go index 30a211876..f5b965a7d 100644 --- a/shortcuts/sheets/lark_sheet_table_io_test.go +++ b/shortcuts/sheets/lark_sheet_table_io_test.go @@ -5,6 +5,7 @@ package sheets import ( "encoding/json" + "fmt" "strings" "testing" @@ -24,8 +25,16 @@ func TestTablePut_IsoDateToSerial(t *testing.T) { {"2024-01-01", 45292, true}, {"2024-02-29", 45351, true}, // 2024 is a leap year {"1899-12-31", 1, true}, // one day after the epoch + // pandas `df.to_json(orient="split", date_format="iso")` emits full ISO + // datetimes (`yyyy-mm-ddTHH:MM:SS.fff[±HH:MM]`); df_to_sheet hands those + // straight to --sheets, so the writer must round-trip them without + // asking agents to strip the time suffix themselves. + {"2024-01-15T00:00:00.000", 45306, true}, + {"2024-01-15T08:30:00+08:00", 45306, true}, {"not-a-date", 0, false}, - {"2024/01/15", 0, false}, // wrong separator + {"2024/01/15", 0, false}, // wrong separator + {"T2024-01-15", 0, false}, // a leading T isn't a valid prefix to strip + {"2024-15-01", 0, false}, // invalid month/day still rejected after T-strip } for _, tt := range cases { got, err := isoDateToSerial(tt.in) @@ -140,6 +149,102 @@ func TestTablePut_BuildTypedCell(t *testing.T) { }) } +// TestDtypeToTypeFormat pins down the pandas-dtype → internal (type, format) +// mapping that drives the writer. Pandas dtype strings come in three flavors — +// lowercase numpy (`int64`), capitalized nullable pandas (`Int64`), and the +// stringified output of tz-aware datetimes (`datetime64[ns, UTC]`) — the table +// below exercises one of each per family so a future pandas release that adds +// (say) `float128` still maps to "number" via the prefix check rather than +// silently falling through to string. +func TestDtypeToTypeFormat(t *testing.T) { + t.Parallel() + cases := []struct { + dtype, wantType, wantFmt string + }{ + // numeric: all variants → number (no default format; formats[] decides display) + {"int8", "number", ""}, {"int16", "number", ""}, {"int32", "number", ""}, {"int64", "number", ""}, + {"uint8", "number", ""}, {"uint16", "number", ""}, {"uint32", "number", ""}, {"uint64", "number", ""}, + {"float32", "number", ""}, {"float64", "number", ""}, + {"Int8", "number", ""}, {"Int64", "number", ""}, {"UInt32", "number", ""}, {"Float64", "number", ""}, // nullable + {"complex64", "number", ""}, {"complex128", "number", ""}, + // booleans: bool (numpy) + boolean (nullable pandas) + {"bool", "bool", ""}, {"boolean", "bool", ""}, + // dates: every datetime* variant, incl. tz-aware + {"datetime64[ns]", "date", "yyyy-mm-dd"}, + {"datetime64[ns, UTC]", "date", "yyyy-mm-dd"}, + {"datetime64[ns, Asia/Shanghai]", "date", "yyyy-mm-dd"}, + {"datetime64", "date", "yyyy-mm-dd"}, + // strings / unknown: object, string, category, empty, gibberish → string + @ + {"object", "string", "@"}, {"string", "string", "@"}, {"category", "string", "@"}, + {"", "string", "@"}, {"timestamp", "string", "@"}, {"bigint", "string", "@"}, + } + for _, tc := range cases { + t.Run(tc.dtype, func(t *testing.T) { + t.Parallel() + gotType, gotFmt := dtypeToTypeFormat(tc.dtype) + if gotType != tc.wantType || gotFmt != tc.wantFmt { + t.Errorf("dtypeToTypeFormat(%q) = (%q, %q), want (%q, %q)", + tc.dtype, gotType, gotFmt, tc.wantType, tc.wantFmt) + } + }) + } +} + +// TestTypeToDtype pins down the inverse mapping used by +table-get. The dtype +// string each internal type maps to must be one `df.astype(dtypes)` can +// consume without a per-column branch — that's the round-trip contract. +func TestTypeToDtype(t *testing.T) { + t.Parallel() + cases := []struct{ typ, want string }{ + {"string", "object"}, // pandas default, astype("object") is a no-op + {"number", "float64"}, // works for ints, floats, and NaN-containing series + {"date", "datetime64[ns]"}, // matches ISO yyyy-mm-dd strings we emit + {"bool", "bool"}, // inferColumnType only picks bool when every cell is bool + {"", "object"}, // defensive default + {"surprise", "object"}, // ditto + } + for _, tc := range cases { + if got := typeToDtype(tc.typ); got != tc.want { + t.Errorf("typeToDtype(%q) = %q, want %q", tc.typ, got, tc.want) + } + } +} + +// TestNormalize_DefaultsAndFormatOverride covers the wire-shape ergonomics that +// matter for one-line pandas writes: +// - omitting `dtypes` makes every column a text-format string (so a bare +// `df.to_json(orient="split")` payload is valid: leading-zero ids survive, +// digits don't sneak in as numbers); +// - `formats[col]` overrides the dtype-derived default (so a `float64` column +// gets `#,##0.00` instead of no format); +// - explicit `dtypes[col]` wins over the default-when-missing path. +func TestNormalize_DefaultsAndFormatOverride(t *testing.T) { + t.Parallel() + in := &tableSheetIn{ + Name: "S", + Columns: []string{"id", "amt", "d", "raw"}, + Dtypes: map[string]string{"amt": "float64", "d": "datetime64[ns]"}, // id, raw left unspecified + Formats: map[string]string{"amt": "#,##0.00"}, // override float default ("") + Data: [][]interface{}{}, + } + spec, err := in.normalize(0) + if err != nil { + t.Fatalf("normalize: %v", err) + } + want := []tableColumnSpec{ + {Name: "id", Type: "string", Format: "@"}, // unspecified dtype → string + text format + {Name: "amt", Type: "number", Format: "#,##0.00"}, // float64 + formats override + {Name: "d", Type: "date", Format: "yyyy-mm-dd"}, // datetime → date + default date format + {Name: "raw", Type: "string", Format: "@"}, // unspecified → string + text format + } + for i, w := range want { + got := spec.Columns[i] + if got != w { + t.Errorf("columns[%d] = %+v, want %+v", i, got, w) + } + } +} + // numberFormatOf digs the number_format out of a built cell's cell_styles, or // "" when absent. func numberFormatOf(cell map[string]interface{}) string { @@ -161,15 +266,17 @@ func TestTablePut_PayloadValidation(t *testing.T) { want string }{ {"empty sheets", `{"sheets":[]}`, "at least one sheet"}, - {"missing name", `{"sheets":[{"columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "name is required"}, - {"duplicate name", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]},{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "duplicate sheet name"}, - {"no columns", `{"sheets":[{"name":"S","columns":[],"rows":[]}]}`, "columns must be non-empty"}, - {"bad column type", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"timestamp"}],"rows":[]}]}`, "invalid type"}, - {"column missing name", `{"sheets":[{"name":"S","columns":[{"type":"string"}],"rows":[]}]}`, "columns[0].name is required"}, - {"row width mismatch", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["x"]]}]}`, "column count"}, - {"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "start_cell"}, - {"bad date value", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2025/03/31"]]}]}`, "must be ISO"}, - {"number expects numeric", `{"sheets":[{"name":"S","columns":[{"name":"n","type":"number"}],"rows":[["abc"]]}]}`, "number expects"}, + {"missing name", `{"sheets":[{"columns":["a"],"data":[]}]}`, "name is required"}, + {"duplicate name", `{"sheets":[{"name":"S","columns":["a"],"data":[]},{"name":"S","columns":["a"],"data":[]}]}`, "duplicate sheet name"}, + {"no columns", `{"sheets":[{"name":"S","columns":[],"data":[]}]}`, "columns must be non-empty"}, + {"column missing name", `{"sheets":[{"name":"S","columns":[""],"data":[]}]}`, "columns[0] name is required"}, + {"duplicate column", `{"sheets":[{"name":"S","columns":["a","a"],"data":[]}]}`, "duplicate column name"}, + {"dtypes refs unknown column", `{"sheets":[{"name":"S","columns":["a"],"data":[],"dtypes":{"b":"int64"}}]}`, "dtypes references unknown column"}, + {"formats refs unknown column", `{"sheets":[{"name":"S","columns":["a"],"data":[],"formats":{"b":"0.0"}}]}`, "formats references unknown column"}, + {"row width mismatch", `{"sheets":[{"name":"S","columns":["a","b"],"data":[["x"]]}]}`, "column count"}, + {"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":["a"],"data":[]}]}`, "start_cell"}, + {"bad date value", `{"sheets":[{"name":"S","columns":["d"],"dtypes":{"d":"datetime64[ns]"},"data":[["2025/03/31"]]}]}`, "must be ISO"}, + {"number expects numeric", `{"sheets":[{"name":"S","columns":["n"],"dtypes":{"n":"int64"},"data":[["abc"]]}]}`, "number expects"}, {"invalid json", `{not json`, "invalid JSON"}, } for _, tt := range cases { @@ -198,11 +305,11 @@ func (s stubFlagView) Command() string { return "+table-put" } // ─── dry-run: create + write rendering ──────────────────────────────── -const tablePutSheetsJSON = `{"sheets":[{"name":"月度","columns":[` + - `{"name":"门店","type":"string"},` + - `{"name":"月份","type":"date","format":"yyyy-mm"},` + - `{"name":"销售额","type":"number","format":"#,##0"}` + - `],"rows":[["北京","2024-01-15",259874]]}]}` +const tablePutSheetsJSON = `{"sheets":[{"name":"月度",` + + `"columns":["门店","月份","销售额"],` + + `"dtypes":{"门店":"object","月份":"datetime64[ns]","销售额":"int64"},` + + `"formats":{"月份":"yyyy-mm","销售额":"#,##0"},` + + `"data":[["北京","2024-01-15",259874]]}]}` func TestTablePut_DryRunWrite(t *testing.T) { t.Parallel() @@ -271,13 +378,13 @@ func TestTablePut_Validation(t *testing.T) { want: "mutually exclusive", }, { - name: "bad column type rejected", - args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"foo"}],"rows":[]}]}`}, - want: "invalid type", + name: "duplicate column name rejected", + args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":["a","a"],"data":[]}]}`}, + want: "duplicate column name", }, { name: "row width mismatch rejected", - args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["only-one"]]}]}`}, + args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":["a","b"],"data":[["only-one"]]}]}`}, want: "column count", }, } @@ -305,7 +412,7 @@ func TestTablePut_ExecuteWrite(t *testing.T) { write := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`) out, err := runShortcutWithStubs(t, TablePut, []string{"--url", testURL, "--sheets", - `{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"},{"name":"b","type":"number"}],"rows":[["x",1]]}]}`}, + `{"sheets":[{"name":"数据","columns":["a","b"],"dtypes":{"b":"int64"},"data":[["x",1]]}]}`}, structure, write) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -337,7 +444,7 @@ func TestTablePut_ExecuteWriteCreatesMissingSheet(t *testing.T) { write.Reusable = true // modify_workbook_structure create + set_cell_range out, err := runShortcutWithStubs(t, TablePut, []string{"--url", testURL, "--sheets", - `{"sheets":[{"name":"新表","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`}, + `{"sheets":[{"name":"新表","columns":["a"],"data":[["x"]]}]}`}, structBefore, structAfter, write) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -395,9 +502,15 @@ func TestTablePut_ExecuteCreatesWideSheetWithDims(t *testing.T) { structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"宽表","index":1}]}`) writeStub := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range const n = 25 - cols := strings.TrimRight(strings.Repeat(`{"name":"c","type":"string"},`, n), ",") + // Distinct names per column — the new wire shape rejects duplicates at + // normalize-time, so a repeated "c" would never reach the create call. + colNames := make([]string, n) + for i := 0; i < n; i++ { + colNames[i] = fmt.Sprintf(`"c%d"`, i) + } + cols := strings.Join(colNames, ",") vals := strings.TrimRight(strings.Repeat(`"x",`, n), ",") - payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"rows":[[` + vals + `]]}]}` + payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"data":[[` + vals + `]]}]}` out, err := runShortcutWithStubs(t, TablePut, []string{"--url", testURL, "--sheets", payload}, structBefore, createStub, structAfter, writeStub) @@ -437,7 +550,7 @@ func TestTablePut_ExecuteTotalFailure(t *testing.T) { } out, err := runShortcutWithStubs(t, TablePut, []string{"--url", testURL, "--sheets", - `{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`}, + `{"sheets":[{"name":"数据","columns":["a"],"data":[["x"]]}]}`}, structure, writeErr) if err == nil { t.Fatalf("expected failure; got nil. out=%s", out) @@ -464,7 +577,7 @@ func TestTablePut_ExecutePartialFailure(t *testing.T) { } out, err := runShortcutWithStubs(t, TablePut, []string{"--url", testURL, "--sheets", - `{"sheets":[{"name":"汇总","columns":[{"name":"a","type":"string"}],"rows":[["x"]]},{"name":"明细","columns":[{"name":"a","type":"string"}],"rows":[["y"]]}]}`}, + `{"sheets":[{"name":"汇总","columns":["a"],"data":[["x"]]},{"name":"明细","columns":["a"],"data":[["y"]]}]}`}, structure, writeOK, writeErr) if err == nil { t.Fatalf("expected partial-success error; got nil. out=%s", out) @@ -485,7 +598,7 @@ func TestTablePut_ExecutePartialFailure(t *testing.T) { // --sheets entry can't be combined with the untyped --headers/--values. func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) { t.Parallel() - typed := `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}` + typed := `{"sheets":[{"name":"S","columns":["a"],"data":[["x"]]}]}` for _, tc := range []struct { name string args []string @@ -553,7 +666,7 @@ func TestWorkbookCreate_TypedAdoptsDefaultSheet(t *testing.T) { } out, err := runShortcutWithStubs(t, WorkbookCreate, []string{ "--title", "Demo", - "--sheets", `{"sheets":[{"name":"Sales","columns":[{"name":"d","type":"date"},{"name":"amt","type":"number"}],"rows":[["2024-01-15",1234.5]]}]}`, + "--sheets", `{"sheets":[{"name":"Sales","columns":["d","amt"],"dtypes":{"d":"datetime64[ns]","amt":"float64"},"data":[["2024-01-15",1234.5]]}]}`, }, create, structure, rename, write) if err != nil { t.Fatalf("typed create failed: %v\nout=%s", err, out) @@ -585,7 +698,7 @@ func TestWorkbookCreate_TypedDryRun(t *testing.T) { t.Parallel() calls := parseDryRunAPI(t, WorkbookCreate, []string{ "--title", "Demo", - "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2024-01-15"]]}]}`, + "--sheets", `{"sheets":[{"name":"S","columns":["d"],"dtypes":{"d":"datetime64[ns]"},"data":[["2024-01-15"]]}]}`, }) if len(calls) != 2 { t.Fatalf("want 2 dry-run calls (create + typed write), got %d", len(calls)) @@ -600,7 +713,7 @@ func TestWorkbookCreate_TypedDryRun_MultiSheetStyles(t *testing.T) { t.Parallel() calls := parseDryRunAPI(t, WorkbookCreate, []string{ "--title", "Demo", - "--sheets", `{"sheets":[{"name":"S1","columns":[{"name":"name","type":"string"}],"rows":[["alice"]]},{"name":"S2","columns":[{"name":"amount","type":"number","format":"0"}],"rows":[[12]]}]}`, + "--sheets", `{"sheets":[{"name":"S1","columns":["name"],"data":[["alice"]]},{"name":"S2","columns":["amount"],"dtypes":{"amount":"int64"},"formats":{"amount":"0"},"data":[[12]]}]}`, "--styles", `{"styles":[{"name":"S1","cell_styles":[{"range":"A1:A2","background_color":"#f5f5f5"}],"cell_merges":[{"range":"A1:A2"}]},{"name":"S2","cell_styles":[{"range":"A1","font_weight":"bold"},{"range":"A2","font_color":"#0f7b0f"}],"col_sizes":[{"range":"A:A","type":"pixel","size":120}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}]}`, }) if len(calls) != 6 { @@ -698,7 +811,7 @@ func TestTablePut_HeaderAndMode(t *testing.T) { func TestTablePut_BadModeRejected(t *testing.T) { t.Parallel() - _, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":[{"name":"a","type":"string"}],"rows":[]}]}`}) + _, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":["a"],"data":[]}]}`}) if err == nil || !strings.Contains(err.Error(), "invalid") { t.Errorf("mode \"upsert\" should be rejected, got %v", err) } @@ -714,7 +827,7 @@ func TestTablePut_AppendEmptySheetWritesHeader(t *testing.T) { write := toolOutputStub(testToken, "write", `{"ok":true}`) out, err := runShortcutWithStubs(t, TablePut, []string{"--url", testURL, "--sheets", - `{"sheets":[{"name":"新","mode":"append","columns":[{"name":"列A","type":"string"}],"rows":[["x"],["y"]]}]}`}, + `{"sheets":[{"name":"新","mode":"append","columns":["列A"],"data":[["x"],["y"]]}]}`}, structure, region, write) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -751,7 +864,7 @@ func TestTablePut_ExecuteAppend(t *testing.T) { write := toolOutputStub(testToken, "write", `{"ok":true}`) out, err := runShortcutWithStubs(t, TablePut, []string{"--url", testURL, "--sheets", - `{"sheets":[{"name":"日志","mode":"append","columns":[{"name":"时间","type":"string"},{"name":"值","type":"number"}],"rows":[["t1",1],["t2",2]]}]}`}, + `{"sheets":[{"name":"日志","mode":"append","columns":["时间","值"],"dtypes":{"值":"int64"},"data":[["t1",1],["t2",2]]}]}`}, structure, region, write) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) @@ -782,7 +895,7 @@ func TestTablePut_ExecuteAppend(t *testing.T) { func TestTablePut_HeaderFalseAndAllowOverwrite(t *testing.T) { t.Parallel() calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", - `{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":[{"name":"a","type":"string"}],"rows":[["x"],["y"]]}]}`}) + `{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":["a"],"data":[["x"],["y"]]}]}`}) body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{}) input := decodeToolInput(t, body, "set_cell_range") if input["allow_overwrite"] != false { @@ -919,10 +1032,20 @@ func TestTableGet_DigitStringRoundTrip(t *testing.T) { sheets, _ := data["sheets"].([]interface{}) s0, _ := sheets[0].(map[string]interface{}) cols, _ := s0["columns"].([]interface{}) - if c0, _ := cols[0].(map[string]interface{}); c0["type"] != "string" { - t.Errorf("@-format col 邮编 → type %v, want string", c0["type"]) + if cols[0] != "邮编" { + t.Errorf("columns[0] = %v, want 邮编", cols[0]) } - rows, _ := s0["rows"].([]interface{}) + dtypes, _ := s0["dtypes"].(map[string]interface{}) + if dtypes["邮编"] != "object" { + t.Errorf("dtypes[邮编] = %v, want object (text-format column round-trips as string)", dtypes["邮编"]) + } + // The writer paints `@` on string columns so digit-like text survives; + // surfacing that back as a user-set format would round-trip noisily, so the + // reader strips it. Hence: no "formats" key at all on an all-string sheet. + if _, has := s0["formats"]; has { + t.Errorf("@ is a writer convention, must NOT surface in formats: %#v", s0["formats"]) + } + rows, _ := s0["data"].([]interface{}) if r0, _ := rows[0].([]interface{}); r0[0] != "00123" { t.Errorf("value = %v, want \"00123\" (leading zero preserved)", r0[0]) } @@ -956,15 +1079,27 @@ func TestTableGet_ExecuteRoundTrip(t *testing.T) { if len(cols) != 3 { t.Fatalf("want 3 columns, got %d", len(cols)) } - c1, _ := cols[1].(map[string]interface{}) - if c1["name"] != "月份" || c1["type"] != "date" || c1["format"] != "yyyy-mm" { - t.Errorf("col 月份 = %#v, want name=月份 date yyyy-mm", c1) + if cols[0] != "门店" || cols[1] != "月份" || cols[2] != "销售额" { + t.Errorf("columns = %#v, want [门店 月份 销售额]", cols) } - c2, _ := cols[2].(map[string]interface{}) - if c2["type"] != "number" || c2["format"] != "#,##0" { - t.Errorf("col 销售额 = %#v, want number #,##0", c2) + dtypes, _ := s0["dtypes"].(map[string]interface{}) + if dtypes["月份"] != "datetime64[ns]" { + t.Errorf("dtypes[月份] = %v, want datetime64[ns]", dtypes["月份"]) } - rows, _ := s0["rows"].([]interface{}) + if dtypes["销售额"] != "float64" { + t.Errorf("dtypes[销售额] = %v, want float64 (numeric)", dtypes["销售额"]) + } + if dtypes["门店"] != "object" { + t.Errorf("dtypes[门店] = %v, want object (string column)", dtypes["门店"]) + } + formats, _ := s0["formats"].(map[string]interface{}) + if formats["月份"] != "yyyy-mm" { + t.Errorf("formats[月份] = %v, want yyyy-mm (number_format preserved)", formats["月份"]) + } + if formats["销售额"] != "#,##0" { + t.Errorf("formats[销售额] = %v, want #,##0", formats["销售额"]) + } + rows, _ := s0["data"].([]interface{}) r0, _ := rows[0].([]interface{}) if r0[1] != "2024-01-15" { t.Errorf("date roundtrip = %v, want 2024-01-15 (serial 45306 → ISO)", r0[1]) @@ -974,6 +1109,59 @@ func TestTableGet_ExecuteRoundTrip(t *testing.T) { } } +// TestTableGet_OutputRoundTripsBackIntoTablePut is the contract test: the +// output of +table-get must be a payload +table-put accepts. This catches +// dtype/format symmetry breaks early — if the reader ever emits a dtype the +// writer doesn't recognize (or under a key the writer doesn't read), pipe-back +// loops in agent scripts would fail with a confusing JSON error instead of a +// schema error here. +func TestTableGet_OutputRoundTripsBackIntoTablePut(t *testing.T) { + t.Parallel() + region := toolOutputStub(testToken, "read", `{"current_region":"A1:D2"}`) + cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+ + `[{"value":"city"},{"value":"day"},{"value":"revenue"},{"value":"closed"}],`+ + `[{"value":"BJ"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm-dd"}},{"value":1234.5,"cell_styles":{"number_format":"#,##0.00"}},{"value":true}]`+ + `]}]}`) + out, err := runShortcutWithStubs(t, TableGet, + []string{"--url", testURL, "--sheet-name", "S"}, region, cells) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + // The reader's "sheets" array is the same key +table-put consumes, so wrap + // the whole `data` envelope back as a fresh --sheets payload and parse it. + // A name is mandatory on each sheet, so make sure it survived. + body, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal reader output: %v", err) + } + parsed, err := parseTablePutPayload(stubFlagView{"sheets": string(body)}) + if err != nil { + t.Fatalf("reader output rejected by writer: %v\npayload=%s", err, body) + } + if len(parsed.Sheets) != 1 { + t.Fatalf("round-trip lost sheets: %#v", parsed.Sheets) + } + s := parsed.Sheets[0] + if s.Name != "S" { + t.Errorf("name = %q, want S", s.Name) + } + wantCols := []tableColumnSpec{ + {Name: "city", Type: "string", Format: "@"}, + {Name: "day", Type: "date", Format: "yyyy-mm-dd"}, + {Name: "revenue", Type: "number", Format: "#,##0.00"}, + {Name: "closed", Type: "bool", Format: ""}, + } + for i, w := range wantCols { + if s.Columns[i] != w { + t.Errorf("columns[%d] after round-trip = %+v, want %+v", i, s.Columns[i], w) + } + } + if len(s.Rows) != 1 || len(s.Rows[0]) != 4 { + t.Fatalf("rows shape changed: %#v", s.Rows) + } +} + func TestTableGet_DryRunIncludesCellRead(t *testing.T) { t.Parallel() calls := parseDryRunAPI(t, TableGet, []string{"--url", testURL, "--sheet-name", "S"}) diff --git a/skills/lark-sheets/references/lark-sheets-financial-modeling-standards.md b/skills/lark-sheets/references/lark-sheets-financial-modeling-standards.md index f1bd7ca3b..08af44790 100644 --- a/skills/lark-sheets/references/lark-sheets-financial-modeling-standards.md +++ b/skills/lark-sheets/references/lark-sheets-financial-modeling-standards.md @@ -43,7 +43,7 @@ | 写入已有 spreadsheet | `+table-put --sheets` | 把 DataFrame 转成 `{sheets:[...]}`,按 sheet 名匹配,缺 sheet 时创建,支持覆盖 / 追加 | | 新建 spreadsheet 并写入结果 | `+workbook-create --sheets` | 协议与 `+table-put` 同构,一步建表 + typed 写入,适合 pandas 算完直接交付新模型 | -typed payload 结构: +typed payload 结构(形状对齐 pandas `df.to_json(orient="split")`): ```json { @@ -52,12 +52,10 @@ typed payload 结构: "name": "Output", "start_cell": "A1", "mode": "overwrite", - "columns": [ - {"name": "Date", "type": "date", "format": "yyyy-mm-dd"}, - {"name": "Revenue", "type": "number", "format": "$#,##0;($#,##0);\"-\""}, - {"name": "EBITDA Margin", "type": "number", "format": "0.0%"} - ], - "rows": [ + "columns": ["Date", "Revenue", "EBITDA Margin"], + "dtypes": {"Date": "datetime64[ns]", "Revenue": "float64", "EBITDA Margin": "float64"}, + "formats": {"Revenue": "$#,##0;($#,##0);\"-\"", "EBITDA Margin": "0.0%"}, + "data": [ ["2026-12-31", 708000000, 0.29] ] } @@ -65,11 +63,27 @@ typed payload 结构: } ``` -DataFrame 转 payload 时按业务语义定列类型: +pandas 构造(用 write-cells reference 里的 5 行 `df_to_sheet(df, name, formats=None)` helper): -- 金额、收入、费用、利润、人数、股数、倍数、百分比都用 `type:"number"`;百分比存小数,如 `12.5%` 写 `0.125`,靠 `format:"0.0%"` 显示。 -- 日期列用 `type:"date"`,值用 ISO 日期字符串;不要把日期预格式化成普通文本。 -- 订单号、股票代码、员工编号等需要保留前导零或不参与计算的字段用 `type:"string"`。 +```python +payload = {"sheets": [ + df_to_sheet(df, "Output", + formats={"Revenue": "$#,##0;($#,##0);\"-\"", + "EBITDA Margin": "0.0%"}) +]} +# 多 sheet 时 helper 优势更明显——income / balance / cashflow / sensitivity 各一行: +payload = {"sheets": [df_to_sheet(income, "Income Statement"), + df_to_sheet(balance, "Balance Sheet"), + df_to_sheet(cashflow, "Cash Flow"), + df_to_sheet(sensitivity, "Sensitivity", + formats={"WACC": "0.00%", "Terminal Growth": "0.00%"})]} +``` + +DataFrame 转 payload 时按业务语义对齐 dtype + format: + +- 金额、收入、费用、利润、人数、股数、倍数、百分比都是 `number`(dtype 用 `int64` / `float64`,或 nullable `Int64` / `Float64`);百分比存小数,如 `12.5%` 写 `0.125`,靠 `formats[列名]="0.0%"` 显示。 +- 日期列用 `datetime64[ns]`(pandas 默认 dtype,CLI 映射成 date),值用 ISO 日期字符串;不要把日期预格式化成普通文本。 +- 订单号、股票代码、员工编号等需要保留前导零或不参与计算的字段用 `object`(dtype 缺省也是这个,含前导零的字符串会被 CLI 自动套文本格式 `@`、读回不塌缩成数字)。 - pandas 计算出的源数据 / 输出表先用 `+table-put` 或 `+workbook-create --sheets` 落地;公式、颜色编码、边框、Sensitivity baseline 高亮再用 `+cells-set` / `+cells-set-style` 补。 ## 财务逻辑规范 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md index 08fd674ec..baca5ea46 100644 --- a/skills/lark-sheets/references/lark-sheets-read-data.md +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -22,7 +22,7 @@ | 读取目的 | 用这个 shortcut | 数据去向 | 说明 | |---------|----------------|---------|------| | 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) | -| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[{name,type}]` + `rows`),列类型由 `number_format` 推断、混合列无损降 `string`;类型保真往返 | +| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put` | | 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | | 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 | @@ -170,7 +170,9 @@ lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --she ### `+table-get`(飞书 → DataFrame,类型保真读出) -`+table-put`(写入侧,见 write-cells reference)的镜像:把表格读回与 `--sheets` 同构的 typed 协议(`sheets[]` + `columns:[{name,type}]` + `rows`),可直接喂回 `+table-put` 或转 DataFrame。列 `type` 从每列 `number_format` 推断(日期格式→`date`、数值→`number`),`date` 列的序列号转回 ISO `yyyy-mm-dd`——日期、数字往返不丢类型。**列类型只在该列所有非空值一致时才定(`number` / `date` / `bool`);一列混了类型(如数字列混入「暂无」、日期列混入裸数字)会降为 `string`,让 `columns[].type` 与 `rows` 里每个值自洽——能 round-trip 回 `+table-put`、不让 pandas 崩。降级是无损的(脏值原样保留为文本);若要把零星脏值转成数值列,交给调用方在 pandas 侧做(`to_numeric(errors='coerce')`),那里原始值仍在、可追溯。** 底层复用 `get_cell_ranges` / `get_range_as_csv`。默认读所有子表、第一行当表头(`--no-header` 把首行当数据、列名取 `col1` / `col2` …)。 +`+table-put`(写入侧,见 write-cells reference)的镜像:把表格读回与 `--sheets` 完全同构的 typed 协议(`sheets[]` + `columns:[列名]` + `data:[[行]]` + `dtypes:{列名:pandas_dtype}` + `formats?:{列名:number_format}`),可直接喂回 `+table-put` 或一行还原 DataFrame。 + +列类型从每列 `number_format` 推断(日期格式→`date`/`datetime64[ns]`、数值→`number`/`float64`、bool→`bool`),`date` 列的序列号转回 ISO `yyyy-mm-dd`——日期、数字往返不丢类型。**列类型只在该列所有非空值一致时才定(`number` / `date` / `bool`);一列混了类型(如数字列混入「暂无」、日期列混入裸数字)会降为 `string`(dtypes 输出 `object`),让 `dtypes` 与 `data` 里每个值自洽——能 round-trip 回 `+table-put`、不让 pandas `astype` 崩。降级是无损的(脏值原样保留为文本);若要把零星脏值转成数值列,交给调用方在 pandas 侧做(`to_numeric(errors='coerce')`),那里原始值仍在、可追溯。** 底层复用 `get_cell_ranges` / `get_range_as_csv`。默认读所有子表、第一行当表头(`--no-header` 把首行当数据、列名取 `col1` / `col2` …)。 ```bash # 默认读所有子表 → sheets[](与 +table-put 的 --sheets 同构,可喂回或转 DataFrame) @@ -179,16 +181,48 @@ lark-cli sheets +table-get --url "<表URL>" lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售" ``` -`+table-get` 输出 → DataFrame(按读回的 `type` 还原 dtype): +#### 输出 → DataFrame(2 行 helper) + +输出形状对齐 pandas split:`columns` 是列名数组、`data` 是二维数据、`dtypes` 是 `{列名: pandas_dtype_str}` 映射。直接喂给 `pd.DataFrame(...).astype(...)` 就能一次性还原所有列类型(不必逐列 `to_datetime` / `to_numeric`),写入侧 `df_to_sheet` 的镜像 helper: ```python -sheet = out["data"]["sheets"][0] -df = pd.DataFrame(sheet["rows"], columns=[c["name"] for c in sheet["columns"]]) -for c in sheet["columns"]: - if c["type"] == "date": df[c["name"]] = pd.to_datetime(df[c["name"]]) - elif c["type"] == "number": df[c["name"]] = pd.to_numeric(df[c["name"]]) +import pandas as pd +def sheet_to_df(sheet): + return pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"]) + +# 单 sheet +df = sheet_to_df(out["data"]["sheets"][0]) + +# 多 sheet——按名字取 +sheets = {s["name"]: sheet_to_df(s) for s in out["data"]["sheets"]} +df_sales = sheets["销售"] ``` +> 显示格式(千分位、百分比、自定义日期)在 `sheet["formats"]`,pandas 不消费;改完数据 round-trip 回去时透传给 `+table-put` 即可,飞书侧显示不变。 + +#### round-trip:读 → 改 → 写回(写读对偶) + +`sheet_to_df` 和 write-cells reference 里的 `df_to_sheet` 是一对镜像 helper,round-trip 三段读 / 改 / 写各一行: + +```python +import json, subprocess +# 1. 读 +out = json.loads(subprocess.check_output( + ["lark-cli","sheets","+table-get","--url",URL,"--sheet-name","销售"])) +sheet = out["data"]["sheets"][0] +df = sheet_to_df(sheet) + +# 2. 改(pandas 操作) +df["营收"] = df["营收"] * 1.1 + +# 3. 写回(formats 是飞书侧显示格式,pandas 不消费,透传保留显示) +payload = {"sheets": [df_to_sheet(df, sheet["name"], formats=sheet.get("formats"))]} +subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--sheets","-"], + input=json.dumps(payload).encode(), check=True) +``` + +`sheet_to_df(sheet)` 消费 `(columns, data, dtypes)`,`df_to_sheet(df, name, formats=...)` 重新生成同样三个字段——读 / 写完全对偶,只有 `formats` 需要手工透传一次。 + ### Validate / DryRun / Execute 约束 - `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。 diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md index c7ac73351..304c35d2b 100644 --- a/skills/lark-sheets/references/lark-sheets-workbook.md +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -139,7 +139,7 @@ _系统:`--dry-run`_ | `--title` | string | required | 新 spreadsheet 标题 | | `--folder-token` | string | optional | 目标文件夹 token;省略时放在云空间根目录 | | `--values` | string + File + Stdin(简单 JSON) | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 | -| `--sheets` | string + File + Stdin(复合 JSON) | optional | 建表后写入的 typed 表格协议 JSON(同 +table-put):顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]};type 为 string/number/date/bool。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 | +| `--sheets` | string + File + Stdin(复合 JSON) | optional | 建表后写入的 typed 表格协议 JSON(同 +table-put):顶层 sheets 数组,每项 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`。Agents 通常用 `{**json.loads(df.to_json(orient="split")), "dtypes": df.dtypes.astype(str).to_dict()}` 一行构造。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 | | `--styles` | string + File + Stdin(复合 JSON) | optional | 建表时同时写入的视觉处理操作 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style,含 number_format / 颜色 / 对齐 / border_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。 | ### `+workbook-export` @@ -174,8 +174,10 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表; - `mode` (enum?) — overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头) [overwrite / append] - `header` (boolean?) — 是否写一行列名表头 - `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success) -- `columns` (array) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string } -- `rows` (array>) — 数据行;每行是一个数组,长度必须等于 columns 数 +- `columns` (array) — 列名字符串数组,顺序与 `data` 中每行取值一一对应 +- `data` (array>) — 数据行;每行是一个数组,长度必须等于 `columns` 数 +- `dtypes` (object?) — 可选 +- `formats` (object?) — 可选 ### `+workbook-create` `--styles` @@ -209,11 +211,11 @@ lark-cli sheets +workbook-create --title "销售" \ # number 不丢精度、string 列保前导零(如订单号 00123);多子表一次建。 lark-cli sheets +workbook-create --title "交易" --sheets '{ "sheets":[ - {"name":"明细","columns":[ - {"name":"日期","type":"date"}, - {"name":"金额","type":"number","format":"#,##0.00"}, - {"name":"单号","type":"string"} - ],"rows":[["2024-01-15",1234.5,"00123"]]} + {"name":"明细", + "columns":["日期","金额","单号"], + "dtypes":{"日期":"datetime64[ns]","金额":"float64","单号":"object"}, + "formats":{"金额":"#,##0.00"}, + "data":[["2024-01-15",1234.5,"00123"]]} ]}' ``` @@ -244,10 +246,11 @@ lark-cli sheets +workbook-create --title "销售" \ # 4) typed 单子表:--styles.styles[0].name 必须对应 --sheets.sheets[0].name lark-cli sheets +workbook-create --title "交易" --sheets '{ "sheets":[ - {"name":"明细","columns":[ - {"name":"日期","type":"date"}, - {"name":"金额","type":"number","format":"#,##0.00"} - ],"rows":[["2024-01-15",1234.5]]} + {"name":"明细", + "columns":["日期","金额"], + "dtypes":{"日期":"datetime64[ns]","金额":"float64"}, + "formats":{"金额":"#,##0.00"}, + "data":[["2024-01-15",1234.5]]} ]}' --styles '{ "styles":[ {"name":"明细", @@ -266,8 +269,8 @@ lark-cli sheets +workbook-create --title "交易" --sheets '{ # 5) typed 多子表:styles 数组和 sheets 数组长度、顺序、name 都必须一致 lark-cli sheets +workbook-create --title "经营看板" --sheets '{ "sheets":[ - {"name":"收入","columns":[{"name":"月份","type":"string"},{"name":"收入","type":"number","format":"#,##0"}],"rows":[["2026-05",1200000]]}, - {"name":"成本","columns":[{"name":"月份","type":"string"},{"name":"成本","type":"number","format":"#,##0"}],"rows":[["2026-05",730000]]} + {"name":"收入","columns":["月份","收入"],"dtypes":{"收入":"int64"},"formats":{"收入":"#,##0"},"data":[["2026-05",1200000]]}, + {"name":"成本","columns":["月份","成本"],"dtypes":{"成本":"int64"},"formats":{"成本":"#,##0"},"data":[["2026-05",730000]]} ]}' --styles '{ "styles":[ {"name":"收入","cell_styles":[ diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md index 349c6d868..42bcb341f 100644 --- a/skills/lark-sheets/references/lark-sheets-write-cells.md +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -317,7 +317,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--sheets` | string + File + Stdin(复合 JSON) | required | typed 表格协议 JSON:顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]};type 为 string/number/date/bool | +| `--sheets` | string + File + Stdin(复合 JSON) | required | Typed 表格协议(pandas-DataFrame-shaped)JSON:顶层 sheets 数组,每项 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`。Agents 通常用 `{**json.loads(df.to_json(orient="split")), "dtypes": df.dtypes.astype(str).to_dict()}` 一行构造。`dtypes` 值是 pandas dtype 字符串(`int64`、`float64`、`Int64`、`bool`、`boolean`、`datetime64[ns]`、`object`、...),CLI 端映射成内部 string/number/date/bool —— 省略 `dtypes` 时该列按文本写入(适合原始 CSV-shaped 数据)。`formats[col]` 是 Excel number_format 字符串(如 `#,##0.00`、`0.0%`、`yyyy-mm`);缺省时 date 列用 `yyyy-mm-dd`,string 列用文本格式 `@`。 | ## Schemas @@ -364,8 +364,10 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表; - `mode` (enum?) — overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头) [overwrite / append] - `header` (boolean?) — 是否写一行列名表头 - `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success) -- `columns` (array) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string } -- `rows` (array>) — 数据行;每行是一个数组,长度必须等于 columns 数 +- `columns` (array) — 列名字符串数组,顺序与 `data` 中每行取值一一对应 +- `data` (array>) — 数据行;每行是一个数组,长度必须等于 `columns` 数 +- `dtypes` (object?) — 可选 +- `formats` (object?) — 可选 ## Examples @@ -462,9 +464,9 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ ### `+table-put`(DataFrame → 飞书,类型保真写入) -把带类型的结构化数据(DataFrame)类型保真地写入**已有**表,底层复用 `set_cell_range`(同 `+cells-set`)。typed 协议:顶层 `sheets[]`,每 sheet 带 `columns:[{name,type,format?}]` + `rows`(二维数组,`null`=空单元格),列 `type` ∈ `string` / `number` / `date` / `bool`(**显式声明**,不让 CLI 猜,避免邮编 / 订单号等"像数字的文本"被误判)。`date` 列的 ISO `yyyy-mm-dd` 字符串会转成 Excel 序列号 + 日期 `number_format`(真日期,可排序 / 透视 / 筛选)。 +把结构化数据(DataFrame、list of dict、Counter)类型保真写入**已有**表,底层复用 `set_cell_range`(同 `+cells-set`)。协议形状**对齐 pandas `to_json(orient="split")`**:`columns:[列名]` + `data:[[行...]]`,可选 `dtypes:{列名:pandas_dtype}` 决定每列类型(number 保精度、date 落真日期),可选 `formats:{列名:number_format}` 覆盖显示格式(千分位 / 百分比 / 自定义日期)。dtypes 缺失时整张表按 string 写入(带 `@` 文本格式,邮编 / 订单号等含前导零的 id 保真)。 -只写入**已有**表(`--url` / `--spreadsheet-token` 二选一必填),不新建工作簿——**要新建表格直接用 `+workbook-create --sheets`**(同 typed 协议、一步建表 + 类型保真写入,无需先建空表再回来,详见 workbook reference)。读回用镜像命令 `+table-get`(见 read-data reference),输出与 `--sheets` 同构、可 round-trip。 +只写入**已有**表(`--url` / `--spreadsheet-token` 二选一必填),不新建工作簿——**要新建表格直接用 `+workbook-create --sheets`**(同协议、一步建表 + 类型保真写入,详见 workbook reference)。读回用镜像命令 `+table-get`(见 read-data reference),输出与 `--sheets` 同构、可 round-trip。 ```bash # sheet 按 name 匹配、缺则新建;多 DataFrame 经 stdin 一次写多 sheet @@ -475,29 +477,49 @@ lark-cli sheets +table-put --spreadsheet-token "" --sheets @payload.json 每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`。 -**前提:此 helper 需 pandas。** 注意一台机器常装多个 Python,`python3` 未必指向装了 pandas 的那个——撞 `ModuleNotFoundError` 就换个解释器(如 `/usr/bin/python3`)再试。**不想依赖 pandas 也行**:typed 协议就是纯 JSON,直接手写 `columns` + `rows`(不经 helper)一样喂给 `--sheets -`。DataFrame → 协议 的薄 helper(一次清洗:`NaN→null`、`Timestamp→ISO`、`numpy 标量→原生`): +#### DataFrame → 协议(5 行 helper) + +pandas 的 `df.to_json(orient="split", date_format="iso")` 一步完成所有清洗(NaN→null、Timestamp→ISO 字符串、numpy 标量→原生数字),helper 只要把 dtypes 拼上去——5 行覆盖单 / 多 sheet: ```python -import pandas as pd, numpy as np +import json def df_to_sheet(df, name, formats=None): - formats = formats or {} - def coltype(s): - if pd.api.types.is_datetime64_any_dtype(s): return "date" - if pd.api.types.is_bool_dtype(s): return "bool" - if pd.api.types.is_numeric_dtype(s): return "number" - return "string" - def cell(v): - if pd.isna(v): return None - if isinstance(v, pd.Timestamp): return v.date().isoformat() - if isinstance(v, np.generic): return v.item() - return v - columns = [{"name": str(c), "type": coltype(df[c]), - **({"format": formats[c]} if c in formats else {})} for c in df.columns] - rows = [[cell(v) for v in r] for r in df.itertuples(index=False, name=None)] - return {"name": name, "columns": columns, "rows": rows} -# payload = {"sheets": [df_to_sheet(df, "销售", {"日期": "yyyy-mm-dd"})]};json.dump 经 stdin 喂给 +table-put --sheets - + return {"name": name, + **json.loads(df.to_json(orient="split", date_format="iso")), + "dtypes": df.dtypes.astype(str).to_dict(), + **({"formats": formats} if formats else {})} + +# 单 sheet(显式 format 覆盖默认显示) +payload = {"sheets": [df_to_sheet(df, "销售", {"营收": "#,##0.00", "毛利率": "0.0%"})]} + +# 多 sheet——helper 让每个 sheet 一行,不再重复 boilerplate +payload = {"sheets": [df_to_sheet(df1, "销售"), + df_to_sheet(df2, "成本"), + df_to_sheet(df3, "利润")]} ``` +> **CSV-shaped 全文本数据**(不需要类型保真、含前导零的 id 也要保留)省掉 dtypes 即可,inline 一行写完,不必走 helper(注意保留 `date_format="iso"`,否则 datetime 列会被序列化成 epoch 毫秒数字,CLI 拒绝): +> ```python +> payload = {"sheets": [{"name": "原始", +> **json.loads(df.to_json(orient="split", date_format="iso"))}]} +> ``` +> **别把 `to_json + json.loads` 换成 `df.to_dict(orient="split")`**:会留 `numpy.int64` 让 `json.dumps` 后续报 "not serializable"——这一步是清洗的关键。 + +不用 pandas 也行——typed 协议就是纯 JSON。手写场景: + +```python +# Counter / dict / 手拼数据:直接写 columns + data,按需加 dtypes/formats +payload = {"sheets": [{ + "name": "渠道", + "columns": ["channel", "count", "rate"], + "data": [["app", 1240, 0.62], ["web", 760, 0.38]], + "dtypes": {"count": "int64", "rate": "float64"}, + "formats": {"rate": "0.0%"}, +}]} +``` + +> **dtype 速查**:`int64`/`float64`(数值)、`Int64`(含空值的整数,nullable)、`bool`/`boolean`、`datetime64[ns]`(date,默认 `yyyy-mm-dd`)、`object`(string)。pandas dtype 字符串原样塞进 dtypes 即可,CLI 端按前缀匹配(`int*`/`uint*`/`Int*`/`float*` → number 等)。未识别 dtype 兜底为 string。 + ### Validate / DryRun / Execute 约束 - `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。