licos-dev-cli 0.4.1__tar.gz → 0.4.3__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.1
3
+ Version: 0.4.3
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.1
7
+ Requires-Dist: licos-dev-sdk>=0.4.3
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-cli"
7
- version = "0.4.1"
7
+ version = "0.4.3"
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.1",
11
+ "licos-dev-sdk>=0.4.3",
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,187 @@
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", "tables"]), 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", type=click.IntRange(min=1), help="Insert before this one-based row. Choose --before or --append.")
145
+ @click.option("--append", is_flag=True, help="Append after the last row; no row count calculation needed.")
146
+ @click.option("--template-row", required=True, type=click.IntRange(min=1), help="Existing styled row; must not intersect a vertical merge.")
147
+ @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.")
148
+ @sdk_errors
149
+ def docx_table_insert_rows(input_path, **arguments):
150
+ """Clone styled template rows and fill values. No action, count or operations wrapper."""
151
+ from licos_dev_sdk import insert_docx_rows
152
+ result(insert_docx_rows(rows=json.loads(Path(input_path).read_text(encoding="utf-8")), **arguments))
153
+
154
+
155
+ @click.command("docx-verify")
156
+ @click.option("--file", "source", required=True, type=click.Path(exists=True, dir_okay=False))
157
+ @click.option("--original", type=click.Path(exists=True, dir_okay=False), help="Compare non-body package parts (headers, footers, styles, images) and section settings with the original.")
158
+ @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).")
159
+ @target_options
160
+ @click.option("--text", default=None, help="Expected text at one target; use this or --text-file instead of --input.")
161
+ @click.option("--text-file", type=click.Path(exists=True, dir_okay=False), help="UTF-8 expected text; preserves whitespace and line breaks.")
162
+ @click.option("--mode", type=click.Choice(["equals", "contains", "absent"]), help="Single-target comparison; defaults to equals.")
163
+ @sdk_errors
164
+ def docx_verify(source, original, input_path, paragraph, block, table, row, column, text, text_file, mode):
165
+ """Verify exact positions and optional original parts. No assertions means no content verification.
166
+
167
+ Example: [{"target":{"table":1,"row":1,"column":1},"text":"Supplier: A"}]
168
+
169
+ Exits 2 if an assertion or original comparison fails. Does not certify rendered page layout.
170
+ """
171
+ from licos_dev_sdk import verify_docx
172
+ position = target(paragraph, block, table, row, column)
173
+ single = bool(position) or text is not None or text_file is not None or mode is not None
174
+ if input_path is not None and single:
175
+ raise click.UsageError("use --input or a single target with --text/--text-file, not both")
176
+ expectations = json.loads(Path(input_path).read_text(encoding="utf-8")) if input_path else None
177
+ if single:
178
+ expectations = [{"target": position, "text": text_input(text, text_file, "text"), "mode": mode or "equals"}]
179
+ report = verify_docx(source, original=original, expectations=expectations)
180
+ result(report)
181
+ if not report["verified"]:
182
+ raise click.exceptions.Exit(2)
183
+
184
+
185
+ DOCX_COMMANDS = [docx, docx_template, docx_edit, docx_read, docx_structure, docx_target,
186
+ docx_replace, docx_delete_blocks, docx_edit_structure, docx_table_set,
187
+ 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,102 +2,27 @@
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 ────────────────────────────────────────────────────────────────
93
15
 
94
16
  @click.group()
17
+ @click.version_option(package_name="licos-dev-cli")
95
18
  def cli():
96
19
  """LICOS Dev - file generation and model CLI for AI agents."""
97
20
  pass
98
21
 
99
22
 
100
23
  cli.add_command(ppt_master)
24
+ for command in DOCX_COMMANDS:
25
+ cli.add_command(command)
101
26
 
102
27
 
103
28
  @cli.command("credit-check")
@@ -199,97 +124,6 @@ def pdf_read(file_path, offset, limit, max_chars):
199
124
  _echo_json(report)
200
125
 
201
126
 
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
127
  # ── XLSX ─────────────────────────────────────────────────────────────────────
294
128
 
295
129
  @cli.command()
@@ -0,0 +1,126 @@
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
84
+
85
+
86
+ def test_append_and_verify_with_original_and_text_file(source, tmp_path):
87
+ rows = tmp_path / "rows.json"
88
+ rows.write_text('[["pump",2]]', encoding="utf-8")
89
+ output = tmp_path / "rows.docx"
90
+ read = invoke("docx-structure", "--file", source, "--scope", "tables")
91
+ assert read["items"][0]["row_count"] == 1
92
+ invoke("docx-table-insert-rows", "--source", source, "--output", output, "--revision", read["revision"],
93
+ "--table", 1, "--append", "--template-row", 1, "--input", rows)
94
+ assert invoke("docx-verify", "--file", output, "--original", source, "--table", 1, "--row", 2, "--column", 1, "--text", "pump")["verified"]
95
+ replacement = tmp_path / "text.txt"
96
+ text = " 中文🙂\t空格\n" * 100 + " "
97
+ replacement.write_text(text, encoding="utf-8")
98
+ invoke("docx-replace", "--source", output, "--output", output, "--overwrite",
99
+ "--revision", invoke("docx-structure", "--file", output)["revision"],
100
+ "--block", 1, "--search", "账号:123", "--replace-file", replacement)
101
+ verified = invoke("docx-verify", "--file", output, "--original", source, "--block", 1, "--text-file", replacement)
102
+ assert verified["content_verified"] and verified["preservation_verified"]
103
+
104
+
105
+ @pytest.mark.parametrize("arguments", [
106
+ ["--block", "1"], ["--text", "x"], ["--mode", "contains"],
107
+ ["--block", "1", "--text", "x", "--input", "checks.json"],
108
+ ["--block", "1", "--text", "x", "--text-file", "text.txt"],
109
+ ])
110
+ def test_invalid_verification_options_are_reported(source, tmp_path, monkeypatch, arguments):
111
+ monkeypatch.chdir(tmp_path)
112
+ (tmp_path / "checks.json").write_text("[]", encoding="utf-8")
113
+ (tmp_path / "text.txt").write_text("x", encoding="utf-8")
114
+ result = CliRunner().invoke(cli, ["docx-verify", "--file", str(source), *arguments])
115
+ assert result.exit_code != 0
116
+ assert "Error:" in result.output
117
+
118
+
119
+ def test_original_comparison_failure_exits_two(source, tmp_path):
120
+ changed = tmp_path / "changed.docx"
121
+ doc = Document(source)
122
+ doc.sections[0].footer.paragraphs[0].text = "changed footer"
123
+ doc.save(changed)
124
+ failure = CliRunner().invoke(cli, ["docx-verify", "--file", str(changed), "--original", str(source)])
125
+ assert failure.exit_code == 2
126
+ assert not json.loads(failure.output)["preservation_verified"]