licos-dev-sdk 0.2.7__tar.gz → 0.2.8__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_sdk-0.2.7 → licos_dev_sdk-0.2.8}/PKG-INFO +3 -1
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/pyproject.toml +5 -3
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/__init__.py +11 -9
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/document.py +25 -3
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/model.py +18 -18
- licos_dev_sdk-0.2.8/src/licos_dev_sdk/spreadsheet.py +199 -0
- licos_dev_sdk-0.2.8/tests/test_document_spreadsheet.py +58 -0
- licos_dev_sdk-0.2.7/src/licos_dev_sdk/spreadsheet.py +0 -75
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/.gitignore +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/chart.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/observability.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/presentation.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/web.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/tests/test_model.py +0 -0
- {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/tests/test_observability.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: licos-dev-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: LICOS Dev SDK - file generation and model capability clients
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: docxtpl>=0.16
|
|
6
7
|
Requires-Dist: graphviz>=0.20
|
|
7
8
|
Requires-Dist: jinja2>=3.1
|
|
8
9
|
Requires-Dist: licos-platform-sdk>=0.2.8
|
|
@@ -16,3 +17,4 @@ Requires-Dist: python-pptx>=1.0
|
|
|
16
17
|
Requires-Dist: pyyaml>=6.0
|
|
17
18
|
Requires-Dist: qrcode[pil]>=8.0
|
|
18
19
|
Requires-Dist: weasyprint>=62.0
|
|
20
|
+
Requires-Dist: xlsxwriter>=3.2
|
|
@@ -4,14 +4,16 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "licos-dev-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.8"
|
|
8
8
|
description = "LICOS Dev SDK - file generation and model capability clients"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
11
11
|
"licos-platform-sdk>=0.2.8",
|
|
12
12
|
"weasyprint>=62.0",
|
|
13
|
-
"python-docx>=1.1",
|
|
14
|
-
"
|
|
13
|
+
"python-docx>=1.1",
|
|
14
|
+
"docxtpl>=0.16",
|
|
15
|
+
"openpyxl>=3.1",
|
|
16
|
+
"xlsxwriter>=3.2",
|
|
15
17
|
"python-pptx>=1.0",
|
|
16
18
|
"matplotlib>=3.9",
|
|
17
19
|
"graphviz>=0.20",
|
|
@@ -23,12 +23,14 @@ def __getattr__(name: str):
|
|
|
23
23
|
"create_html": ("web", "create_html"),
|
|
24
24
|
"create_markdown": ("web", "create_markdown"),
|
|
25
25
|
"markdown_to_html": ("web", "markdown_to_html"),
|
|
26
|
-
# document
|
|
27
|
-
"create_pdf": ("document", "create_pdf"),
|
|
28
|
-
"create_docx": ("document", "create_docx"),
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
26
|
+
# document
|
|
27
|
+
"create_pdf": ("document", "create_pdf"),
|
|
28
|
+
"create_docx": ("document", "create_docx"),
|
|
29
|
+
"create_docx_from_template": ("document", "create_docx_from_template"),
|
|
30
|
+
# spreadsheet
|
|
31
|
+
"create_xlsx": ("spreadsheet", "create_xlsx"),
|
|
32
|
+
"create_xlsx_workbook": ("spreadsheet", "create_xlsx_workbook"),
|
|
33
|
+
"create_csv": ("spreadsheet", "create_csv"),
|
|
32
34
|
# chart
|
|
33
35
|
"create_chart": ("chart", "create_chart"),
|
|
34
36
|
# diagram
|
|
@@ -85,9 +87,9 @@ __all__ = [
|
|
|
85
87
|
"create_json", "create_xml", "create_yaml",
|
|
86
88
|
"create_zip", "create_tar_gz",
|
|
87
89
|
"create_qrcode", "create_barcode", "create_watermark",
|
|
88
|
-
"create_html", "create_markdown", "markdown_to_html",
|
|
89
|
-
"create_pdf", "create_docx",
|
|
90
|
-
"create_xlsx", "create_csv",
|
|
90
|
+
"create_html", "create_markdown", "markdown_to_html",
|
|
91
|
+
"create_pdf", "create_docx", "create_docx_from_template",
|
|
92
|
+
"create_xlsx", "create_xlsx_workbook", "create_csv",
|
|
91
93
|
"create_chart",
|
|
92
94
|
"create_diagram",
|
|
93
95
|
"create_pptx",
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
7
|
from docx import Document
|
|
8
8
|
from docx.shared import Pt, Inches
|
|
9
|
-
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
10
9
|
|
|
11
10
|
from ._utils import resolve_output_path
|
|
12
11
|
|
|
@@ -29,6 +28,9 @@ blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 1em; col
|
|
|
29
28
|
def create_pdf(content: str, filename: str, *, content_type: str = "markdown",
|
|
30
29
|
output_dir: str | None = None, page_size: str = "A4") -> str:
|
|
31
30
|
"""Generate a PDF file from Markdown or HTML. Returns absolute path."""
|
|
31
|
+
import mistune
|
|
32
|
+
from weasyprint import HTML
|
|
33
|
+
|
|
32
34
|
path = resolve_output_path(filename, ".pdf", output_dir)
|
|
33
35
|
html_body = mistune.html(content) if content_type == "markdown" else content
|
|
34
36
|
css = _PDF_CSS.replace("{page_size}", page_size)
|
|
@@ -96,3 +98,23 @@ def create_docx(content: str, filename: str, *, content_type: str = "markdown",
|
|
|
96
98
|
|
|
97
99
|
doc.save(str(path))
|
|
98
100
|
return str(path.resolve())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_docx_from_template(template_path: str, data: dict[str, Any], filename: str, *,
|
|
104
|
+
output_dir: str | None = None) -> str:
|
|
105
|
+
"""Render a DOCX template with Jinja-style placeholders.
|
|
106
|
+
|
|
107
|
+
The template must be a `.docx` file. Placeholders use docxtpl syntax, for example
|
|
108
|
+
`{{ customer_name }}` or `{% for item in items %}...{% endfor %}`.
|
|
109
|
+
Returns the absolute path of the generated document.
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(data, dict):
|
|
112
|
+
raise TypeError("data must be a dict")
|
|
113
|
+
|
|
114
|
+
from docxtpl import DocxTemplate
|
|
115
|
+
|
|
116
|
+
path = resolve_output_path(filename, ".docx", output_dir)
|
|
117
|
+
doc = DocxTemplate(template_path)
|
|
118
|
+
doc.render(data)
|
|
119
|
+
doc.save(str(path))
|
|
120
|
+
return str(path.resolve())
|
|
@@ -698,22 +698,22 @@ def _fetch_model_detail(
|
|
|
698
698
|
model_code = str(model_code or "").strip()
|
|
699
699
|
if not model_code:
|
|
700
700
|
return None
|
|
701
|
-
_ = workspace_id
|
|
702
|
-
cache_key = (runtime.base_url, runtime.token, model_code)
|
|
701
|
+
_ = workspace_id
|
|
702
|
+
cache_key = (runtime.base_url, runtime.token, model_code)
|
|
703
703
|
ttl = _int_env("LICOS_MODEL_CATALOG_CACHE_TTL_SECS", DEFAULT_CATALOG_CACHE_TTL_SECS)
|
|
704
704
|
cached = _DETAIL_CACHE.get(cache_key)
|
|
705
705
|
if cached and not refresh and time.time() - cached[0] <= ttl:
|
|
706
706
|
return cached[1]
|
|
707
707
|
|
|
708
|
-
query = {"code": model_code}
|
|
709
|
-
url = f"{runtime.base_url}{MODEL_DETAIL_PATH}?{parse.urlencode(query)}"
|
|
710
|
-
try:
|
|
711
|
-
payload = _request_json(
|
|
712
|
-
"GET",
|
|
713
|
-
url,
|
|
714
|
-
token=runtime.token,
|
|
715
|
-
timeout=30,
|
|
716
|
-
)
|
|
708
|
+
query = {"code": model_code}
|
|
709
|
+
url = f"{runtime.base_url}{MODEL_DETAIL_PATH}?{parse.urlencode(query)}"
|
|
710
|
+
try:
|
|
711
|
+
payload = _request_json(
|
|
712
|
+
"GET",
|
|
713
|
+
url,
|
|
714
|
+
token=runtime.token,
|
|
715
|
+
timeout=30,
|
|
716
|
+
)
|
|
717
717
|
except ApiError as exc:
|
|
718
718
|
if not refresh and should_refresh_user_token(exc):
|
|
719
719
|
return _fetch_model_detail(
|
|
@@ -779,11 +779,11 @@ def _resolve_chat_endpoint(
|
|
|
779
779
|
"chat",
|
|
780
780
|
model_group=model_group,
|
|
781
781
|
requested_model=requested_model,
|
|
782
|
-
)
|
|
783
|
-
selected_model = _selected_model(requested_model, endpoint.model)
|
|
784
|
-
endpoint = replace(endpoint, model=selected_model)
|
|
785
|
-
detail = _fetch_model_detail(runtime, selected_model)
|
|
786
|
-
return _apply_model_detail(endpoint, detail)
|
|
782
|
+
)
|
|
783
|
+
selected_model = _selected_model(requested_model, endpoint.model)
|
|
784
|
+
endpoint = replace(endpoint, model=selected_model)
|
|
785
|
+
detail = _fetch_model_detail(runtime, selected_model)
|
|
786
|
+
return _apply_model_detail(endpoint, detail)
|
|
787
787
|
|
|
788
788
|
|
|
789
789
|
def _resolve_endpoint_with_detail(
|
|
@@ -801,8 +801,8 @@ def _resolve_endpoint_with_detail(
|
|
|
801
801
|
requested_model=requested_model,
|
|
802
802
|
input_capabilities=input_capabilities,
|
|
803
803
|
)
|
|
804
|
-
detail = _fetch_model_detail(runtime, endpoint.model)
|
|
805
|
-
return _apply_model_detail(endpoint, detail)
|
|
804
|
+
detail = _fetch_model_detail(runtime, endpoint.model)
|
|
805
|
+
return _apply_model_detail(endpoint, detail)
|
|
806
806
|
|
|
807
807
|
|
|
808
808
|
def _resolve_endpoint(
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Spreadsheet generation — XLSX, CSV."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from openpyxl import Workbook
|
|
9
|
+
from openpyxl.styles import Font, PatternFill, Alignment
|
|
10
|
+
|
|
11
|
+
from ._utils import resolve_output_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_xlsx(data: list[dict] | list[list], filename: str, *,
|
|
15
|
+
output_dir: str | None = None, sheet_name: str = "Sheet1",
|
|
16
|
+
header_color: str = "4472C4") -> str:
|
|
17
|
+
"""Generate an XLSX file from data. Returns absolute path.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
data: List of dicts (keys as headers) or 2D list (first row as headers).
|
|
21
|
+
"""
|
|
22
|
+
path = resolve_output_path(filename, ".xlsx", output_dir)
|
|
23
|
+
wb = Workbook()
|
|
24
|
+
ws = wb.active
|
|
25
|
+
ws.title = sheet_name
|
|
26
|
+
|
|
27
|
+
header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
|
|
28
|
+
header_font = Font(bold=True, color="FFFFFF")
|
|
29
|
+
|
|
30
|
+
if data and isinstance(data[0], dict):
|
|
31
|
+
headers = list(data[0].keys())
|
|
32
|
+
ws.append(headers)
|
|
33
|
+
for row in data:
|
|
34
|
+
ws.append([row.get(h) for h in headers])
|
|
35
|
+
elif data and isinstance(data[0], list):
|
|
36
|
+
for row in data:
|
|
37
|
+
ws.append(row)
|
|
38
|
+
else:
|
|
39
|
+
wb.save(str(path))
|
|
40
|
+
return str(path.resolve())
|
|
41
|
+
|
|
42
|
+
# Style header row
|
|
43
|
+
for cell in ws[1]:
|
|
44
|
+
cell.fill = header_fill
|
|
45
|
+
cell.font = header_font
|
|
46
|
+
cell.alignment = Alignment(horizontal="center")
|
|
47
|
+
|
|
48
|
+
# Auto-width columns
|
|
49
|
+
for col in ws.columns:
|
|
50
|
+
max_len = 0
|
|
51
|
+
col_letter = col[0].column_letter
|
|
52
|
+
for cell in col:
|
|
53
|
+
val = str(cell.value) if cell.value is not None else ""
|
|
54
|
+
max_len = max(max_len, len(val))
|
|
55
|
+
ws.column_dimensions[col_letter].width = min(max_len + 4, 50)
|
|
56
|
+
|
|
57
|
+
wb.save(str(path))
|
|
58
|
+
return str(path.resolve())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_csv(data: list[dict] | list[list], filename: str, *, output_dir: str | None = None) -> str:
|
|
62
|
+
"""Generate a CSV file from data. Returns absolute path."""
|
|
63
|
+
path = resolve_output_path(filename, ".csv", output_dir)
|
|
64
|
+
|
|
65
|
+
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
|
66
|
+
if data and isinstance(data[0], dict):
|
|
67
|
+
writer = csv.DictWriter(f, fieldnames=data[0].keys())
|
|
68
|
+
writer.writeheader()
|
|
69
|
+
writer.writerows(data)
|
|
70
|
+
elif data and isinstance(data[0], list):
|
|
71
|
+
writer = csv.writer(f)
|
|
72
|
+
writer.writerows(data)
|
|
73
|
+
|
|
74
|
+
return str(path.resolve())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_xlsx_workbook(workbook: dict[str, Any] | list[dict[str, Any]], filename: str, *,
|
|
78
|
+
output_dir: str | None = None, header_color: str = "4472C4",
|
|
79
|
+
freeze_header: bool = True, autofilter: bool = True) -> str:
|
|
80
|
+
"""Generate a multi-sheet XLSX workbook. Returns absolute path.
|
|
81
|
+
|
|
82
|
+
Supported input shapes:
|
|
83
|
+
|
|
84
|
+
- `{"Sheet1": [{"A": 1}], "Sheet2": [["Name", "Score"], ["Alice", 95]]}`
|
|
85
|
+
- `{"sheets": [{"name": "Sheet1", "data": [{"A": 1}]}]}`
|
|
86
|
+
- `[{"name": "Sheet1", "data": [{"A": 1}]}]`
|
|
87
|
+
"""
|
|
88
|
+
path = resolve_output_path(filename, ".xlsx", output_dir)
|
|
89
|
+
|
|
90
|
+
import xlsxwriter
|
|
91
|
+
|
|
92
|
+
sheets = _normalize_workbook_sheets(workbook)
|
|
93
|
+
book = xlsxwriter.Workbook(str(path))
|
|
94
|
+
header_fmt = book.add_format({
|
|
95
|
+
"bold": True,
|
|
96
|
+
"font_color": "white",
|
|
97
|
+
"bg_color": f"#{header_color.lstrip('#')}",
|
|
98
|
+
"align": "center",
|
|
99
|
+
"valign": "vcenter",
|
|
100
|
+
"border": 1,
|
|
101
|
+
})
|
|
102
|
+
cell_fmt = book.add_format({"border": 1, "valign": "top"})
|
|
103
|
+
|
|
104
|
+
used_names: set[str] = set()
|
|
105
|
+
for raw_name, data in sheets:
|
|
106
|
+
sheet_name = _unique_sheet_name(_sanitize_sheet_name(raw_name), used_names)
|
|
107
|
+
used_names.add(sheet_name)
|
|
108
|
+
ws = book.add_worksheet(sheet_name)
|
|
109
|
+
rows = _rows_from_sheet_data(data)
|
|
110
|
+
|
|
111
|
+
column_widths: list[int] = []
|
|
112
|
+
for row_index, row in enumerate(rows):
|
|
113
|
+
for col_index, value in enumerate(row):
|
|
114
|
+
fmt = header_fmt if row_index == 0 else cell_fmt
|
|
115
|
+
ws.write(row_index, col_index, value, fmt)
|
|
116
|
+
text_len = len(str(value)) if value is not None else 0
|
|
117
|
+
if col_index >= len(column_widths):
|
|
118
|
+
column_widths.append(0)
|
|
119
|
+
column_widths[col_index] = min(max(column_widths[col_index], text_len + 4), 60)
|
|
120
|
+
|
|
121
|
+
for col_index, width in enumerate(column_widths):
|
|
122
|
+
ws.set_column(col_index, col_index, max(width, 10))
|
|
123
|
+
if rows and freeze_header:
|
|
124
|
+
ws.freeze_panes(1, 0)
|
|
125
|
+
if rows and autofilter and rows[0]:
|
|
126
|
+
ws.autofilter(0, 0, max(len(rows) - 1, 0), len(rows[0]) - 1)
|
|
127
|
+
|
|
128
|
+
if not sheets:
|
|
129
|
+
book.add_worksheet("Sheet1")
|
|
130
|
+
book.close()
|
|
131
|
+
return str(path.resolve())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _normalize_workbook_sheets(workbook: dict[str, Any] | list[dict[str, Any]]) -> list[tuple[str, Any]]:
|
|
135
|
+
if isinstance(workbook, dict) and "sheets" in workbook:
|
|
136
|
+
workbook = workbook["sheets"]
|
|
137
|
+
|
|
138
|
+
if isinstance(workbook, dict):
|
|
139
|
+
return [(str(name), data) for name, data in workbook.items()]
|
|
140
|
+
|
|
141
|
+
if isinstance(workbook, list):
|
|
142
|
+
sheets: list[tuple[str, Any]] = []
|
|
143
|
+
for index, item in enumerate(workbook, start=1):
|
|
144
|
+
if not isinstance(item, dict):
|
|
145
|
+
raise TypeError("workbook sheet list items must be dicts")
|
|
146
|
+
name = str(item.get("name") or item.get("sheet_name") or f"Sheet{index}")
|
|
147
|
+
data = item.get("data", item.get("rows", []))
|
|
148
|
+
sheets.append((name, data))
|
|
149
|
+
return sheets
|
|
150
|
+
|
|
151
|
+
raise TypeError("workbook must be a dict or a list of sheet definitions")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _rows_from_sheet_data(data: Any) -> list[list[Any]]:
|
|
155
|
+
if isinstance(data, dict) and "rows" in data:
|
|
156
|
+
columns = data.get("columns")
|
|
157
|
+
rows = data.get("rows") or []
|
|
158
|
+
if columns:
|
|
159
|
+
keys = [str(col.get("key") if isinstance(col, dict) else col) for col in columns]
|
|
160
|
+
labels = [str(col.get("label") or col.get("key")) if isinstance(col, dict) else str(col) for col in columns]
|
|
161
|
+
body = [[row.get(key) if isinstance(row, dict) else None for key in keys] for row in rows]
|
|
162
|
+
return [labels, *body]
|
|
163
|
+
data = rows
|
|
164
|
+
|
|
165
|
+
if not data:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
169
|
+
headers: list[str] = []
|
|
170
|
+
for row in data:
|
|
171
|
+
if not isinstance(row, dict):
|
|
172
|
+
raise TypeError("dict-row sheets cannot mix non-dict rows")
|
|
173
|
+
for key in row.keys():
|
|
174
|
+
key_str = str(key)
|
|
175
|
+
if key_str not in headers:
|
|
176
|
+
headers.append(key_str)
|
|
177
|
+
return [headers, *[[row.get(header) for header in headers] for row in data]]
|
|
178
|
+
|
|
179
|
+
if isinstance(data, list) and all(isinstance(row, list) for row in data):
|
|
180
|
+
return data
|
|
181
|
+
|
|
182
|
+
raise TypeError("sheet data must be a list of dicts, a 2D list, or {columns, rows}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _sanitize_sheet_name(name: str) -> str:
|
|
186
|
+
sanitized = "".join("_" if ch in "[]:*?/\\" else ch for ch in name).strip()
|
|
187
|
+
return (sanitized or "Sheet")[:31]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _unique_sheet_name(name: str, used_names: set[str]) -> str:
|
|
191
|
+
if name not in used_names:
|
|
192
|
+
return name
|
|
193
|
+
base = name[:28]
|
|
194
|
+
index = 2
|
|
195
|
+
while True:
|
|
196
|
+
candidate = f"{base}_{index}"[:31]
|
|
197
|
+
if candidate not in used_names:
|
|
198
|
+
return candidate
|
|
199
|
+
index += 1
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
9
|
+
|
|
10
|
+
from docx import Document
|
|
11
|
+
from openpyxl import load_workbook
|
|
12
|
+
|
|
13
|
+
from licos_dev_sdk import create_docx_from_template, create_xlsx_workbook
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DocumentSpreadsheetTests(unittest.TestCase):
|
|
17
|
+
def test_create_docx_from_template_renders_placeholders(self) -> None:
|
|
18
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
19
|
+
tmp_path = Path(tmp)
|
|
20
|
+
template_path = tmp_path / "template.docx"
|
|
21
|
+
|
|
22
|
+
doc = Document()
|
|
23
|
+
doc.add_paragraph("客户:{{ customer }}")
|
|
24
|
+
doc.add_paragraph("金额:{{ amount }}")
|
|
25
|
+
doc.save(template_path)
|
|
26
|
+
|
|
27
|
+
output = create_docx_from_template(
|
|
28
|
+
str(template_path),
|
|
29
|
+
{"customer": "张三", "amount": "10000.00"},
|
|
30
|
+
"contract",
|
|
31
|
+
output_dir=str(tmp_path),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
rendered = Document(output)
|
|
35
|
+
text = "\n".join(paragraph.text for paragraph in rendered.paragraphs)
|
|
36
|
+
self.assertIn("客户:张三", text)
|
|
37
|
+
self.assertIn("金额:10000.00", text)
|
|
38
|
+
|
|
39
|
+
def test_create_xlsx_workbook_writes_multiple_sheets(self) -> None:
|
|
40
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
41
|
+
output = create_xlsx_workbook(
|
|
42
|
+
{
|
|
43
|
+
"销售": [{"月份": "1月", "金额": 12000}, {"月份": "2月", "金额": 18000}],
|
|
44
|
+
"客户": [["姓名", "等级"], ["张三", "A"]],
|
|
45
|
+
},
|
|
46
|
+
"report",
|
|
47
|
+
output_dir=tmp,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
wb = load_workbook(output)
|
|
51
|
+
self.assertEqual(wb.sheetnames, ["销售", "客户"])
|
|
52
|
+
self.assertEqual(wb["销售"]["A1"].value, "月份")
|
|
53
|
+
self.assertEqual(wb["销售"]["B2"].value, 12000)
|
|
54
|
+
self.assertEqual(wb["客户"]["A2"].value, "张三")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
unittest.main()
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
"""Spreadsheet generation — XLSX, CSV."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import csv
|
|
6
|
-
import io
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from openpyxl import Workbook
|
|
10
|
-
from openpyxl.styles import Font, PatternFill, Alignment
|
|
11
|
-
|
|
12
|
-
from ._utils import resolve_output_path
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def create_xlsx(data: list[dict] | list[list], filename: str, *,
|
|
16
|
-
output_dir: str | None = None, sheet_name: str = "Sheet1",
|
|
17
|
-
header_color: str = "4472C4") -> str:
|
|
18
|
-
"""Generate an XLSX file from data. Returns absolute path.
|
|
19
|
-
|
|
20
|
-
Args:
|
|
21
|
-
data: List of dicts (keys as headers) or 2D list (first row as headers).
|
|
22
|
-
"""
|
|
23
|
-
path = resolve_output_path(filename, ".xlsx", output_dir)
|
|
24
|
-
wb = Workbook()
|
|
25
|
-
ws = wb.active
|
|
26
|
-
ws.title = sheet_name
|
|
27
|
-
|
|
28
|
-
header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
|
|
29
|
-
header_font = Font(bold=True, color="FFFFFF")
|
|
30
|
-
|
|
31
|
-
if data and isinstance(data[0], dict):
|
|
32
|
-
headers = list(data[0].keys())
|
|
33
|
-
ws.append(headers)
|
|
34
|
-
for row in data:
|
|
35
|
-
ws.append([row.get(h) for h in headers])
|
|
36
|
-
elif data and isinstance(data[0], list):
|
|
37
|
-
for row in data:
|
|
38
|
-
ws.append(row)
|
|
39
|
-
else:
|
|
40
|
-
wb.save(str(path))
|
|
41
|
-
return str(path.resolve())
|
|
42
|
-
|
|
43
|
-
# Style header row
|
|
44
|
-
for cell in ws[1]:
|
|
45
|
-
cell.fill = header_fill
|
|
46
|
-
cell.font = header_font
|
|
47
|
-
cell.alignment = Alignment(horizontal="center")
|
|
48
|
-
|
|
49
|
-
# Auto-width columns
|
|
50
|
-
for col in ws.columns:
|
|
51
|
-
max_len = 0
|
|
52
|
-
col_letter = col[0].column_letter
|
|
53
|
-
for cell in col:
|
|
54
|
-
val = str(cell.value) if cell.value is not None else ""
|
|
55
|
-
max_len = max(max_len, len(val))
|
|
56
|
-
ws.column_dimensions[col_letter].width = min(max_len + 4, 50)
|
|
57
|
-
|
|
58
|
-
wb.save(str(path))
|
|
59
|
-
return str(path.resolve())
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def create_csv(data: list[dict] | list[list], filename: str, *, output_dir: str | None = None) -> str:
|
|
63
|
-
"""Generate a CSV file from data. Returns absolute path."""
|
|
64
|
-
path = resolve_output_path(filename, ".csv", output_dir)
|
|
65
|
-
|
|
66
|
-
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
|
67
|
-
if data and isinstance(data[0], dict):
|
|
68
|
-
writer = csv.DictWriter(f, fieldnames=data[0].keys())
|
|
69
|
-
writer.writeheader()
|
|
70
|
-
writer.writerows(data)
|
|
71
|
-
elif data and isinstance(data[0], list):
|
|
72
|
-
writer = csv.writer(f)
|
|
73
|
-
writer.writerows(data)
|
|
74
|
-
|
|
75
|
-
return str(path.resolve())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|