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.
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/.gitignore +4 -0
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/PKG-INFO +2 -2
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/pyproject.toml +2 -2
- licos_dev_cli-0.4.2/src/licos_dev_cli/common.py +85 -0
- licos_dev_cli-0.4.2/src/licos_dev_cli/docx_commands.py +174 -0
- licos_dev_cli-0.4.2/src/licos_dev_cli/docx_legacy.py +95 -0
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/src/licos_dev_cli/main.py +4 -171
- licos_dev_cli-0.4.2/tests/test_docx_commands.py +83 -0
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/src/licos_dev_cli/__init__.py +0 -0
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/src/licos_dev_cli/ppt_master.py +0 -0
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/tests/test_cli_output_paths.py +0 -0
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/tests/test_credit_check.py +0 -0
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/tests/test_document_read_cli.py +0 -0
- {licos_dev_cli-0.4.0 → licos_dev_cli-0.4.2}/tests/test_ppt_master_cli.py +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|