licos-dev-cli 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,8 @@ crates/industrial/industrial-stack.env
34
34
  *.log
35
35
  *.pid
36
36
  .licos
37
+ # Keep project template metadata in version control.
38
+ !/tools/licos-ai-cli/lib/__templates__/*/.licos
37
39
  .tmp
38
40
  .playwright-cli
39
41
 
@@ -41,6 +43,8 @@ crates/industrial/industrial-stack.env
41
43
  crates/industrial/bin/
42
44
 
43
45
  tools/android-sdk-cache/*.zip
46
+ tools/android-sdk-cache/*.tar.xz
47
+ tools/android-sdk-cache/*.tar.gz
44
48
  tools/codegraph-cache/*.tgz
45
49
  tools/codegraph-cache/*.tgz
46
50
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-cli
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: LICOS Dev CLI - generate files and call model capabilities
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: click>=8.1
7
- Requires-Dist: licos-dev-sdk>=0.4.0
7
+ Requires-Dist: licos-dev-sdk>=0.4.2
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-cli"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "LICOS Dev CLI - generate files and call model capabilities"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "licos-dev-sdk>=0.4.0",
11
+ "licos-dev-sdk>=0.4.2",
12
12
  "click>=8.1",
13
13
  ]
14
14
 
@@ -0,0 +1,85 @@
1
+ """Shared CLI input and output helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import click
10
+
11
+ def _read_input(input_path: str | None, content: str | None) -> str:
12
+ """Read content from --input file, --content arg, or stdin."""
13
+ if input_path:
14
+ with open(input_path, "r", encoding="utf-8") as f:
15
+ return f.read()
16
+ if content:
17
+ return content
18
+ if not sys.stdin.isatty():
19
+ return sys.stdin.read()
20
+ raise click.UsageError("Provide --input, --content, or pipe via stdin.")
21
+
22
+
23
+ def _read_json(input_path: str | None, content: str | None) -> object:
24
+ """Read and parse JSON from input."""
25
+ raw = _read_input(input_path, content)
26
+ return json.loads(raw)
27
+
28
+
29
+ def _json_option(value: str | None, name: str) -> dict | list | None:
30
+ if not value:
31
+ return None
32
+ try:
33
+ return json.loads(value)
34
+ except json.JSONDecodeError as exc:
35
+ raise click.BadParameter(f"{name} must be valid JSON") from exc
36
+
37
+
38
+ def _echo_json(value: object) -> None:
39
+ if hasattr(value, "to_dict"):
40
+ value = value.to_dict()
41
+ click.echo(json.dumps(value, ensure_ascii=False, indent=2))
42
+
43
+
44
+ def _path_to_cli_output(path: object) -> str:
45
+ raw = str(path)
46
+ if not raw:
47
+ return raw
48
+
49
+ normalized = raw.replace("\\", "/")
50
+ string_roots = [
51
+ os.environ.get("LICOS_PROJECT_PATH"),
52
+ (os.path.join(os.environ["LICOS_WORKSPACE_PATH"], "projects") if os.environ.get("LICOS_WORKSPACE_PATH") else None),
53
+ "/workspace/projects",
54
+ ]
55
+ for root in string_roots:
56
+ if not root:
57
+ continue
58
+ prefix = root.replace("\\", "/").rstrip("/")
59
+ if normalized == prefix:
60
+ return "."
61
+ if normalized.startswith(f"{prefix}/"):
62
+ return normalized[len(prefix) + 1 :]
63
+
64
+ p = Path(raw)
65
+ if not p.is_absolute():
66
+ return normalized
67
+
68
+ path_roots = [
69
+ os.environ.get("LICOS_PROJECT_PATH"),
70
+ (os.path.join(os.environ["LICOS_WORKSPACE_PATH"], "projects") if os.environ.get("LICOS_WORKSPACE_PATH") else None),
71
+ str(Path.cwd()),
72
+ ]
73
+ for root in path_roots:
74
+ if not root:
75
+ continue
76
+ try:
77
+ return p.resolve().relative_to(Path(root).resolve()).as_posix()
78
+ except (OSError, RuntimeError, ValueError):
79
+ continue
80
+
81
+ return normalized
82
+
83
+
84
+ def _echo_path(path: object) -> None:
85
+ click.echo(_path_to_cli_output(path))
@@ -0,0 +1,174 @@
1
+ """Focused Word commands; all document operations are implemented by the SDK."""
2
+
3
+ from functools import wraps
4
+ import json
5
+ from pathlib import Path
6
+ from zipfile import BadZipFile
7
+
8
+ import click
9
+
10
+ from .common import _echo_json, _path_to_cli_output
11
+ from .docx_legacy import docx, docx_template, docx_edit, docx_read
12
+
13
+
14
+ def sdk_errors(function):
15
+ @wraps(function)
16
+ def invoke(*args, **kwargs):
17
+ try:
18
+ return function(*args, **kwargs)
19
+ except (ValueError, TypeError, OSError, BadZipFile) as exc:
20
+ raise click.ClickException(str(exc)) from exc
21
+ return invoke
22
+
23
+
24
+ def result(report):
25
+ report["path"] = _path_to_cli_output(report["path"])
26
+ _echo_json(report)
27
+
28
+
29
+ def target_options(function):
30
+ for name in ("paragraph", "block", "table", "row", "column"):
31
+ function = click.option("--" + name, type=click.IntRange(min=1), help="Use the exact target returned by docx-structure/docx-read.")(function)
32
+ return function
33
+
34
+
35
+ def target(paragraph, block, table, row, column):
36
+ return {key: value for key, value in locals().items() if value is not None}
37
+
38
+
39
+ def edit_options(function):
40
+ for decorator in (
41
+ click.option("--source", required=True, type=click.Path(exists=True, dir_okay=False)),
42
+ click.option("--output", required=True, help="Output .docx path."),
43
+ click.option("--revision", required=True, help="Revision from the latest read of this source; edits return a new revision."),
44
+ click.option("--overwrite", is_flag=True, help="Explicitly allow replacing an existing output."),
45
+ ):
46
+ function = decorator(function)
47
+ return function
48
+
49
+
50
+ def text_input(value, path, name):
51
+ if (value is None) == (path is None):
52
+ raise click.UsageError(f"provide exactly one of --{name} and --{name}-file")
53
+ return Path(path).read_text(encoding="utf-8") if path is not None else value
54
+
55
+
56
+ @click.command("docx-structure")
57
+ @click.option("--file", "source", required=True, type=click.Path(exists=True, dir_okay=False))
58
+ @click.option("--scope", type=click.Choice(["blocks", "paragraphs"]), default="blocks", show_default=True)
59
+ @click.option("--offset", type=click.IntRange(min=1), default=1, show_default=True)
60
+ @click.option("--limit", type=click.IntRange(1, 200), default=20, show_default=True)
61
+ @click.option("--max-chars", type=click.IntRange(1, 8000), default=200, show_default=True)
62
+ @sdk_errors
63
+ def docx_structure(**arguments):
64
+ """Read revision and paginated targets. Paragraph indices include table paragraphs.
65
+
66
+ Use docx-target to continue reading a truncated target. Do not convert block
67
+ indices into paragraph indices.
68
+ """
69
+ from licos_dev_sdk import read_docx_structure
70
+ result(read_docx_structure(**arguments))
71
+
72
+
73
+ @click.command("docx-target")
74
+ @click.option("--file", "source", required=True, type=click.Path(exists=True, dir_okay=False))
75
+ @target_options
76
+ @click.option("--text-offset", type=click.IntRange(min=0), default=0, show_default=True)
77
+ @click.option("--max-chars", type=click.IntRange(1, 30000), default=8000, show_default=True)
78
+ @sdk_errors
79
+ def docx_target(source, paragraph, block, table, row, column, **arguments):
80
+ """Read one block, paragraph, or table+row+column. Continue with next_text_offset."""
81
+ from licos_dev_sdk import read_docx_target
82
+ result(read_docx_target(source, target(paragraph, block, table, row, column), **arguments))
83
+
84
+
85
+ @click.command("docx-replace")
86
+ @edit_options
87
+ @target_options
88
+ @click.option("--search", required=True, help="Exact original text; must match once in the target.")
89
+ @click.option("--replace", "replacement", default=None, help="Replacement text; use --replace-file for long text.")
90
+ @click.option("--replace-file", type=click.Path(exists=True, dir_okay=False))
91
+ @sdk_errors
92
+ def docx_replace(paragraph, block, table, row, column, replacement, replace_file, **arguments):
93
+ """Replace text at an exact target, preserving surrounding runs and formatting."""
94
+ from licos_dev_sdk import replace_docx_text
95
+ result(replace_docx_text(target=target(paragraph, block, table, row, column),
96
+ replacement=text_input(replacement, replace_file, "replace"), **arguments))
97
+
98
+
99
+ @click.command("docx-delete-blocks")
100
+ @edit_options
101
+ @click.option("--from", "first", required=True, type=click.IntRange(min=1))
102
+ @click.option("--to", "last", required=True, type=click.IntRange(min=1))
103
+ @sdk_errors
104
+ def docx_delete_blocks(**arguments):
105
+ """Delete an inclusive body block range from one revision; preserve section layout."""
106
+ from licos_dev_sdk import delete_docx_blocks
107
+ result(delete_docx_blocks(**arguments))
108
+
109
+
110
+ @click.command("docx-edit-structure")
111
+ @edit_options
112
+ @click.option("--input", "input_path", required=True, type=click.Path(exists=True, dir_okay=False), help="JSON array of 1-200 operations; use small batches.")
113
+ @sdk_errors
114
+ def docx_edit_structure(input_path, **arguments):
115
+ """Apply a small batch against one revision. Array entries are:
116
+
117
+ {"op":"replace_text","target":{"paragraph":1},"search":"old","replace":"new"}
118
+
119
+ {"op":"delete_blocks","from":3,"to":4}
120
+
121
+ All targets refer to the same input snapshot. Use a returned target unchanged.
122
+ """
123
+ from licos_dev_sdk import edit_docx_structure
124
+ result(edit_docx_structure(operations=json.loads(Path(input_path).read_text(encoding="utf-8")), **arguments))
125
+
126
+
127
+ @click.command("docx-table-set")
128
+ @edit_options
129
+ @click.option("--table", required=True, type=click.IntRange(min=1))
130
+ @click.option("--row", required=True, type=click.IntRange(min=1))
131
+ @click.option("--column", required=True, type=click.IntRange(min=1))
132
+ @click.option("--text", default=None)
133
+ @click.option("--text-file", type=click.Path(exists=True, dir_okay=False))
134
+ @sdk_errors
135
+ def docx_table_set(text, text_file, **arguments):
136
+ """Fill one cell at its logical anchor, preserving cell and template text styles."""
137
+ from licos_dev_sdk import set_docx_cell
138
+ result(set_docx_cell(text=text_input(text, text_file, "text"), **arguments))
139
+
140
+
141
+ @click.command("docx-table-insert-rows")
142
+ @edit_options
143
+ @click.option("--table", required=True, type=click.IntRange(min=1))
144
+ @click.option("--before", required=True, type=click.IntRange(min=1), help="One-based insertion position; row_count+1 appends.")
145
+ @click.option("--template-row", required=True, type=click.IntRange(min=1), help="Existing styled row; must not intersect a vertical merge.")
146
+ @click.option("--input", "input_path", required=True, type=click.Path(exists=True, dir_okay=False), help="Two-dimensional JSON array of scalar values. Fill a merged cell once; count is inferred.")
147
+ @sdk_errors
148
+ def docx_table_insert_rows(input_path, **arguments):
149
+ """Clone styled template rows and fill values. No action, count or operations wrapper."""
150
+ from licos_dev_sdk import insert_docx_rows
151
+ result(insert_docx_rows(rows=json.loads(Path(input_path).read_text(encoding="utf-8")), **arguments))
152
+
153
+
154
+ @click.command("docx-verify")
155
+ @click.option("--file", "source", required=True, type=click.Path(exists=True, dir_okay=False))
156
+ @click.option("--input", "input_path", type=click.Path(exists=True, dir_okay=False), help="JSON array of target/text assertions and optional mode (equals, contains, absent).")
157
+ @sdk_errors
158
+ def docx_verify(source, input_path):
159
+ """Verify exact positions. Without assertions only structure is checked, not content.
160
+
161
+ Example: [{"target":{"table":1,"row":1,"column":1},"text":"Supplier: A"}]
162
+
163
+ Exits 2 if an assertion fails. Does not certify rendered page layout.
164
+ """
165
+ from licos_dev_sdk import verify_docx
166
+ report = verify_docx(source, expectations=json.loads(Path(input_path).read_text(encoding="utf-8")) if input_path else None)
167
+ result(report)
168
+ if not report["verified"]:
169
+ raise click.exceptions.Exit(2)
170
+
171
+
172
+ DOCX_COMMANDS = [docx, docx_template, docx_edit, docx_read, docx_structure, docx_target,
173
+ docx_replace, docx_delete_blocks, docx_edit_structure, docx_table_set,
174
+ docx_table_insert_rows, docx_verify]
@@ -0,0 +1,95 @@
1
+ """Existing DOCX CLI entry points backed by the SDK."""
2
+
3
+ from pathlib import Path
4
+ import click
5
+ from .common import _read_input, _read_json, _echo_path, _path_to_cli_output, _echo_json
6
+
7
+ # ── DOCX ─────────────────────────────────────────────────────────────────────
8
+
9
+ @click.command("docx")
10
+ @click.option("-i", "--input", "input_path")
11
+ @click.option("-c", "--content")
12
+ @click.option("-f", "--filename", required=True)
13
+ @click.option("-o", "--output-dir", default=None)
14
+ @click.option("--format", "content_type", default="markdown")
15
+ @click.option("--font", default="Microsoft YaHei")
16
+ @click.option("--font-size", default=11, type=int)
17
+ @click.option("--theme", default="formal-blue", help="Theme: formal-blue|business|neutral")
18
+ @click.option("--title", default=None, help="Document title used when content has no H1")
19
+ @click.option("--header", default=None, help="Header text")
20
+ @click.option("--footer", default=None, help="Footer text placed before the page number")
21
+ @click.option("--toc/--no-toc", default=False, help="Insert an updatable Word table of contents")
22
+ def docx(input_path, content, filename, output_dir, content_type, font, font_size, theme, title, header, footer, toc):
23
+ """Generate DOCX from Markdown or HTML."""
24
+ from licos_dev_sdk import create_docx
25
+ text = _read_input(input_path, content)
26
+ path = create_docx(
27
+ text,
28
+ filename,
29
+ content_type=content_type,
30
+ output_dir=output_dir,
31
+ font_name=font,
32
+ font_size=font_size,
33
+ theme=theme,
34
+ title=title,
35
+ header=header,
36
+ footer=footer,
37
+ include_toc=toc,
38
+ asset_base_path=str(Path(input_path).resolve().parent) if input_path else None,
39
+ )
40
+ _echo_path(path)
41
+
42
+
43
+ @click.command("docx-template")
44
+ @click.option("-t", "--template", "template_path", required=True, help="DOCX template path")
45
+ @click.option("-i", "--input", "input_path", help="JSON data file")
46
+ @click.option("-d", "--data", "data_content", help="JSON data")
47
+ @click.option("-f", "--filename", required=True)
48
+ @click.option("-o", "--output-dir", default=None)
49
+ def docx_template(template_path, input_path, data_content, filename, output_dir):
50
+ """Generate DOCX by rendering a DOCX template with JSON data."""
51
+ from licos_dev_sdk import create_docx_from_template
52
+ data = _read_json(input_path, data_content)
53
+ if not isinstance(data, dict):
54
+ raise click.BadParameter("template data must be a JSON object")
55
+ path = create_docx_from_template(template_path, data, filename, output_dir=output_dir)
56
+ _echo_path(path)
57
+
58
+
59
+ @click.command("docx-edit")
60
+ @click.option("-s", "--source", "source_path", required=True, type=click.Path(exists=True, dir_okay=False))
61
+ @click.option("-i", "--input", "input_path", help="JSON operation file")
62
+ @click.option("-d", "--data", "data_content", help="JSON operation array")
63
+ @click.option("-f", "--filename", required=True)
64
+ @click.option("-o", "--output-dir", default=None)
65
+ def docx_edit(source_path, input_path, data_content, filename, output_dir):
66
+ """Edit native tables in an existing DOCX package."""
67
+ from licos_dev_sdk import edit_docx
68
+
69
+ payload = _read_json(input_path, data_content)
70
+ operations = payload.get("operations") if isinstance(payload, dict) else payload
71
+ if not isinstance(operations, list):
72
+ raise click.BadParameter("operations must be a JSON array or an object containing operations")
73
+ path = edit_docx(source_path, operations, filename, output_dir=output_dir)
74
+ _echo_path(path)
75
+
76
+
77
+ @click.command("docx-read")
78
+ @click.option("-f", "--file", "file_path", required=True, type=click.Path(exists=True, dir_okay=False))
79
+ @click.option("--offset", type=click.IntRange(min=1), default=1, show_default=True)
80
+ @click.option("--limit", type=click.IntRange(min=1), default=20, show_default=True)
81
+ @click.option("--max-chars", type=click.IntRange(min=256), default=8_000, show_default=True)
82
+ @click.option("--include-styles", is_flag=True, help="Include paragraph, run, row, and cell styles")
83
+ def docx_read(file_path, offset, limit, max_chars, include_styles):
84
+ """Read DOCX paragraphs and table rows as paginated JSON."""
85
+ from licos_dev_sdk import read_docx
86
+
87
+ report = read_docx(
88
+ file_path,
89
+ offset=offset,
90
+ limit=limit,
91
+ max_chars=max_chars,
92
+ include_styles=include_styles,
93
+ )
94
+ report["path"] = _path_to_cli_output(report["path"])
95
+ _echo_json(report)
@@ -2,91 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import sys
6
5
  import json
7
- import os
8
6
  from pathlib import Path
9
7
  import click
10
8
 
11
9
  from .ppt_master import ppt_master
12
-
13
- # ── Helpers ──────────────────────────────────────────────────────────────────
14
-
15
- def _read_input(input_path: str | None, content: str | None) -> str:
16
- """Read content from --input file, --content arg, or stdin."""
17
- if input_path:
18
- with open(input_path, "r", encoding="utf-8") as f:
19
- return f.read()
20
- if content:
21
- return content
22
- if not sys.stdin.isatty():
23
- return sys.stdin.read()
24
- raise click.UsageError("Provide --input, --content, or pipe via stdin.")
25
-
26
-
27
- def _read_json(input_path: str | None, content: str | None) -> object:
28
- """Read and parse JSON from input."""
29
- raw = _read_input(input_path, content)
30
- return json.loads(raw)
31
-
32
-
33
- def _json_option(value: str | None, name: str) -> dict | list | None:
34
- if not value:
35
- return None
36
- try:
37
- return json.loads(value)
38
- except json.JSONDecodeError as exc:
39
- raise click.BadParameter(f"{name} must be valid JSON") from exc
40
-
41
-
42
- def _echo_json(value: object) -> None:
43
- if hasattr(value, "to_dict"):
44
- value = value.to_dict()
45
- click.echo(json.dumps(value, ensure_ascii=False, indent=2))
46
-
47
-
48
- def _path_to_cli_output(path: object) -> str:
49
- raw = str(path)
50
- if not raw:
51
- return raw
52
-
53
- normalized = raw.replace("\\", "/")
54
- string_roots = [
55
- os.environ.get("LICOS_PROJECT_PATH"),
56
- (os.path.join(os.environ["LICOS_WORKSPACE_PATH"], "projects") if os.environ.get("LICOS_WORKSPACE_PATH") else None),
57
- "/workspace/projects",
58
- ]
59
- for root in string_roots:
60
- if not root:
61
- continue
62
- prefix = root.replace("\\", "/").rstrip("/")
63
- if normalized == prefix:
64
- return "."
65
- if normalized.startswith(f"{prefix}/"):
66
- return normalized[len(prefix) + 1 :]
67
-
68
- p = Path(raw)
69
- if not p.is_absolute():
70
- return normalized
71
-
72
- path_roots = [
73
- os.environ.get("LICOS_PROJECT_PATH"),
74
- (os.path.join(os.environ["LICOS_WORKSPACE_PATH"], "projects") if os.environ.get("LICOS_WORKSPACE_PATH") else None),
75
- str(Path.cwd()),
76
- ]
77
- for root in path_roots:
78
- if not root:
79
- continue
80
- try:
81
- return p.resolve().relative_to(Path(root).resolve()).as_posix()
82
- except (OSError, RuntimeError, ValueError):
83
- continue
84
-
85
- return normalized
86
-
87
-
88
- def _echo_path(path: object) -> None:
89
- click.echo(_path_to_cli_output(path))
10
+ from .docx_commands import DOCX_COMMANDS
11
+ from .common import _read_input, _read_json, _json_option, _echo_json, _path_to_cli_output, _echo_path
90
12
 
91
13
 
92
14
  # ── CLI Group ────────────────────────────────────────────────────────────────
@@ -98,6 +20,8 @@ def cli():
98
20
 
99
21
 
100
22
  cli.add_command(ppt_master)
23
+ for command in DOCX_COMMANDS:
24
+ cli.add_command(command)
101
25
 
102
26
 
103
27
  @cli.command("credit-check")
@@ -199,97 +123,6 @@ def pdf_read(file_path, offset, limit, max_chars):
199
123
  _echo_json(report)
200
124
 
201
125
 
202
- # ── DOCX ─────────────────────────────────────────────────────────────────────
203
-
204
- @cli.command()
205
- @click.option("-i", "--input", "input_path")
206
- @click.option("-c", "--content")
207
- @click.option("-f", "--filename", required=True)
208
- @click.option("-o", "--output-dir", default=None)
209
- @click.option("--format", "content_type", default="markdown")
210
- @click.option("--font", default="Microsoft YaHei")
211
- @click.option("--font-size", default=11, type=int)
212
- @click.option("--theme", default="formal-blue", help="Theme: formal-blue|business|neutral")
213
- @click.option("--title", default=None, help="Document title used when content has no H1")
214
- @click.option("--header", default=None, help="Header text")
215
- @click.option("--footer", default=None, help="Footer text placed before the page number")
216
- @click.option("--toc/--no-toc", default=False, help="Insert an updatable Word table of contents")
217
- def docx(input_path, content, filename, output_dir, content_type, font, font_size, theme, title, header, footer, toc):
218
- """Generate DOCX from Markdown or HTML."""
219
- from licos_dev_sdk import create_docx
220
- text = _read_input(input_path, content)
221
- path = create_docx(
222
- text,
223
- filename,
224
- content_type=content_type,
225
- output_dir=output_dir,
226
- font_name=font,
227
- font_size=font_size,
228
- theme=theme,
229
- title=title,
230
- header=header,
231
- footer=footer,
232
- include_toc=toc,
233
- asset_base_path=str(Path(input_path).resolve().parent) if input_path else None,
234
- )
235
- _echo_path(path)
236
-
237
-
238
- @cli.command("docx-template")
239
- @click.option("-t", "--template", "template_path", required=True, help="DOCX template path")
240
- @click.option("-i", "--input", "input_path", help="JSON data file")
241
- @click.option("-d", "--data", "data_content", help="JSON data")
242
- @click.option("-f", "--filename", required=True)
243
- @click.option("-o", "--output-dir", default=None)
244
- def docx_template(template_path, input_path, data_content, filename, output_dir):
245
- """Generate DOCX by rendering a DOCX template with JSON data."""
246
- from licos_dev_sdk import create_docx_from_template
247
- data = _read_json(input_path, data_content)
248
- if not isinstance(data, dict):
249
- raise click.BadParameter("template data must be a JSON object")
250
- path = create_docx_from_template(template_path, data, filename, output_dir=output_dir)
251
- _echo_path(path)
252
-
253
-
254
- @cli.command("docx-edit")
255
- @click.option("-s", "--source", "source_path", required=True, type=click.Path(exists=True, dir_okay=False))
256
- @click.option("-i", "--input", "input_path", help="JSON operation file")
257
- @click.option("-d", "--data", "data_content", help="JSON operation array")
258
- @click.option("-f", "--filename", required=True)
259
- @click.option("-o", "--output-dir", default=None)
260
- def docx_edit(source_path, input_path, data_content, filename, output_dir):
261
- """Edit native tables in an existing DOCX package."""
262
- from licos_dev_sdk import edit_docx
263
-
264
- payload = _read_json(input_path, data_content)
265
- operations = payload.get("operations") if isinstance(payload, dict) else payload
266
- if not isinstance(operations, list):
267
- raise click.BadParameter("operations must be a JSON array or an object containing operations")
268
- path = edit_docx(source_path, operations, filename, output_dir=output_dir)
269
- _echo_path(path)
270
-
271
-
272
- @cli.command("docx-read")
273
- @click.option("-f", "--file", "file_path", required=True, type=click.Path(exists=True, dir_okay=False))
274
- @click.option("--offset", type=click.IntRange(min=1), default=1, show_default=True)
275
- @click.option("--limit", type=click.IntRange(min=1), default=20, show_default=True)
276
- @click.option("--max-chars", type=click.IntRange(min=256), default=8_000, show_default=True)
277
- @click.option("--include-styles", is_flag=True, help="Include paragraph, run, row, and cell styles")
278
- def docx_read(file_path, offset, limit, max_chars, include_styles):
279
- """Read DOCX paragraphs and table rows as paginated JSON."""
280
- from licos_dev_sdk import read_docx
281
-
282
- report = read_docx(
283
- file_path,
284
- offset=offset,
285
- limit=limit,
286
- max_chars=max_chars,
287
- include_styles=include_styles,
288
- )
289
- report["path"] = _path_to_cli_output(report["path"])
290
- _echo_json(report)
291
-
292
-
293
126
  # ── XLSX ─────────────────────────────────────────────────────────────────────
294
127
 
295
128
  @cli.command()
@@ -0,0 +1,83 @@
1
+ import json
2
+
3
+ from click.testing import CliRunner
4
+ from docx import Document
5
+ import pytest
6
+
7
+ from licos_dev_cli.main import cli
8
+
9
+
10
+ @pytest.fixture
11
+ def source(tmp_path):
12
+ path = tmp_path / "input.docx"
13
+ doc = Document()
14
+ doc.add_paragraph("账号:123")
15
+ doc.add_paragraph("附录")
16
+ doc.add_table(rows=1, cols=2).cell(0, 0).text = "模板"
17
+ doc.save(path)
18
+ return path
19
+
20
+
21
+ def invoke(*arguments):
22
+ result = CliRunner().invoke(cli, list(map(str, arguments)))
23
+ assert result.exit_code == 0, result.output or repr(result.exception)
24
+ return json.loads(result.output)
25
+
26
+
27
+ def test_focused_commands_complete_read_replace_delete_and_verify(source, tmp_path):
28
+ read = invoke("docx-structure", "--file", source)
29
+ output = tmp_path / "filled.docx"
30
+ edited = invoke("docx-replace", "--source", source, "--output", output, "--revision", read["revision"],
31
+ "--block", "1", "--search", "123", "--replace", "456")
32
+ deleted = invoke("docx-delete-blocks", "--source", output, "--output", output, "--overwrite", "--revision", edited["revision"], "--from", "2", "--to", "2")
33
+ assert deleted["revision"] != edited["revision"]
34
+ assertions = tmp_path / "assertions.json"
35
+ assertions.write_text(json.dumps([{"target": {"block": 1}, "text": "账号:456"}]), encoding="utf-8")
36
+ assert invoke("docx-verify", "--file", output, "--input", assertions)["content_verified"]
37
+ assertions.write_text('[{"target":{"block":1},"text":"wrong"}]', encoding="utf-8")
38
+ failure = CliRunner().invoke(cli, ["docx-verify", "--file", str(output), "--input", str(assertions)])
39
+ assert failure.exit_code == 2
40
+ assert json.loads(failure.output)["mismatches"]
41
+
42
+
43
+ def test_file_input_and_continuation_keep_long_text_out_of_shell_arguments(source, tmp_path):
44
+ replacement = tmp_path / "replacement.txt"
45
+ replacement.write_text("长文\n" * 100, encoding="utf-8")
46
+ output = tmp_path / "long.docx"
47
+ revision = invoke("docx-structure", "--file", source)["revision"]
48
+ invoke("docx-replace", "--source", source, "--output", output, "--revision", revision,
49
+ "--paragraph", 1, "--search", "123", "--replace-file", replacement)
50
+ first = invoke("docx-target", "--file", output, "--paragraph", 1, "--max-chars", 17)
51
+ second = invoke("docx-target", "--file", output, "--paragraph", 1, "--max-chars", 17, "--text-offset", first["next_text_offset"])
52
+ assert first["text"] + second["text"] == Document(output).paragraphs[0].text[:34]
53
+
54
+
55
+ def test_insert_rows_needs_no_action_or_count_wrapper(source, tmp_path):
56
+ rows = tmp_path / "rows.json"
57
+ rows.write_text('[["pump",2],["valve",3]]', encoding="utf-8")
58
+ output = tmp_path / "rows.docx"
59
+ revision = invoke("docx-structure", "--file", source)["revision"]
60
+ result = invoke("docx-table-insert-rows", "--source", source, "--output", output, "--revision", revision,
61
+ "--table", 1, "--before", 2, "--template-row", 1, "--input", rows)
62
+ assert result["inserted_rows"] == 2
63
+ assert Document(output).tables[0].cell(2, 0).text == "valve"
64
+
65
+
66
+ def test_bad_revision_reports_original_error_and_leaves_output_absent(source, tmp_path):
67
+ output = tmp_path / "bad.docx"
68
+ result = CliRunner().invoke(cli, ["docx-delete-blocks", "--source", str(source), "--output", str(output),
69
+ "--revision", "outdated", "--from", "2", "--to", "2"])
70
+ assert result.exit_code != 0
71
+ assert "revision changed" in result.output
72
+ assert not output.exists()
73
+
74
+
75
+ def test_command_help_is_focused_and_preserves_constraints():
76
+ runner = CliRunner()
77
+ for name in ("docx-structure", "docx-target", "docx-replace", "docx-delete-blocks", "docx-edit-structure",
78
+ "docx-table-set", "docx-table-insert-rows", "docx-verify"):
79
+ help_text = runner.invoke(cli, [name, "--help"])
80
+ assert help_text.exit_code == 0
81
+ assert len(help_text.output.encode("utf-8")) < 5000
82
+ help_text = runner.invoke(cli, ["docx-table-insert-rows", "--help"]).output
83
+ assert "--revision" in help_text and "--template-row" in help_text and "--count" not in help_text