mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
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.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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` 补。
|
||||
|
||||
## 财务逻辑规范
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
@@ -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<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
|
||||
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 columns 数
|
||||
- `columns` (array<string>) — 列名字符串数组,顺序与 `data` 中每行取值一一对应
|
||||
- `data` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 `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":[
|
||||
|
||||
@@ -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<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
|
||||
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 columns 数
|
||||
- `columns` (array<string>) — 列名字符串数组,顺序与 `data` 中每行取值一一对应
|
||||
- `data` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 `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 "<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 解析;防爆参数上限校验。
|
||||
|
||||
Reference in New Issue
Block a user