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.
Files changed (20) hide show
  1. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/PKG-INFO +3 -1
  2. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/pyproject.toml +5 -3
  3. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/__init__.py +11 -9
  4. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/document.py +25 -3
  5. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/model.py +18 -18
  6. licos_dev_sdk-0.2.8/src/licos_dev_sdk/spreadsheet.py +199 -0
  7. licos_dev_sdk-0.2.8/tests/test_document_spreadsheet.py +58 -0
  8. licos_dev_sdk-0.2.7/src/licos_dev_sdk/spreadsheet.py +0 -75
  9. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/.gitignore +0 -0
  10. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/_utils.py +0 -0
  11. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/archive.py +0 -0
  12. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/chart.py +0 -0
  13. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/data.py +0 -0
  14. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/diagram.py +0 -0
  15. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/image.py +0 -0
  16. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/observability.py +0 -0
  17. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/presentation.py +0 -0
  18. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/src/licos_dev_sdk/web.py +0 -0
  19. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.8}/tests/test_model.py +0 -0
  20. {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.7
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"
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
- "openpyxl>=3.1",
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
- # spreadsheet
30
- "create_xlsx": ("spreadsheet", "create_xlsx"),
31
- "create_csv": ("spreadsheet", "create_csv"),
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 mistune
6
- from weasyprint import HTML
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