licos-dev-sdk 0.2.7__tar.gz → 0.2.9__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 (21) hide show
  1. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/.gitignore +3 -0
  2. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/PKG-INFO +3 -1
  3. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/pyproject.toml +3 -1
  4. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/__init__.py +4 -2
  5. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/_utils.py +9 -3
  6. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/document.py +120 -98
  7. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/model.py +18 -18
  8. licos_dev_sdk-0.2.9/src/licos_dev_sdk/spreadsheet.py +199 -0
  9. licos_dev_sdk-0.2.9/tests/test_document_spreadsheet.py +58 -0
  10. licos_dev_sdk-0.2.9/tests/test_output_paths.py +60 -0
  11. licos_dev_sdk-0.2.7/src/licos_dev_sdk/spreadsheet.py +0 -75
  12. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/archive.py +0 -0
  13. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/chart.py +0 -0
  14. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/data.py +0 -0
  15. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/diagram.py +0 -0
  16. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/image.py +0 -0
  17. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/observability.py +0 -0
  18. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/presentation.py +0 -0
  19. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/src/licos_dev_sdk/web.py +0 -0
  20. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/tests/test_model.py +0 -0
  21. {licos_dev_sdk-0.2.7 → licos_dev_sdk-0.2.9}/tests/test_observability.py +0 -0
@@ -33,6 +33,7 @@ crates/industrial/industrial-stack.env
33
33
  # Build
34
34
  *.log
35
35
  *.pid
36
+ crates/industrial/bin/
36
37
  .licos
37
38
  .tmp
38
39
  .playwright-cli
@@ -42,6 +43,8 @@ tools/android-sdk-cache/*.zip
42
43
 
43
44
  *.codex-*
44
45
 
46
+ __pycache__
47
+
45
48
  dist
46
49
  logs
47
50
 
@@ -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.9
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.9"
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
13
  "python-docx>=1.1",
14
+ "docxtpl>=0.16",
14
15
  "openpyxl>=3.1",
16
+ "xlsxwriter>=3.2",
15
17
  "python-pptx>=1.0",
16
18
  "matplotlib>=3.9",
17
19
  "graphviz>=0.20",
@@ -26,8 +26,10 @@ def __getattr__(name: str):
26
26
  # document
27
27
  "create_pdf": ("document", "create_pdf"),
28
28
  "create_docx": ("document", "create_docx"),
29
+ "create_docx_from_template": ("document", "create_docx_from_template"),
29
30
  # spreadsheet
30
31
  "create_xlsx": ("spreadsheet", "create_xlsx"),
32
+ "create_xlsx_workbook": ("spreadsheet", "create_xlsx_workbook"),
31
33
  "create_csv": ("spreadsheet", "create_csv"),
32
34
  # chart
33
35
  "create_chart": ("chart", "create_chart"),
@@ -86,8 +88,8 @@ __all__ = [
86
88
  "create_zip", "create_tar_gz",
87
89
  "create_qrcode", "create_barcode", "create_watermark",
88
90
  "create_html", "create_markdown", "markdown_to_html",
89
- "create_pdf", "create_docx",
90
- "create_xlsx", "create_csv",
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",
@@ -10,13 +10,19 @@ from pathlib import Path
10
10
  def resolve_output_dir(output_dir: str | None = None) -> Path:
11
11
  """Resolve the output directory.
12
12
 
13
- Priority: explicit arg > $LICOS_WORKSPACE_PATH > cwd
13
+ Priority: explicit arg > $LICOS_PROJECT_PATH > $LICOS_WORKSPACE_PATH/projects > cwd
14
14
  """
15
15
  if output_dir:
16
16
  p = Path(output_dir)
17
17
  else:
18
- env = os.environ.get("LICOS_WORKSPACE_PATH", "")
19
- p = Path(env) if env else Path.cwd()
18
+ project_path = os.environ.get("LICOS_PROJECT_PATH", "")
19
+ workspace_path = os.environ.get("LICOS_WORKSPACE_PATH", "")
20
+ if project_path:
21
+ p = Path(project_path)
22
+ elif workspace_path:
23
+ p = Path(workspace_path) / "projects"
24
+ else:
25
+ p = Path.cwd()
20
26
  p.mkdir(parents=True, exist_ok=True)
21
27
  return p
22
28
 
@@ -1,98 +1,120 @@
1
- """Document generation — PDF, DOCX."""
2
-
3
- from __future__ import annotations
4
-
5
- import mistune
6
- from weasyprint import HTML
7
- from docx import Document
8
- from docx.shared import Pt, Inches
9
- from docx.enum.text import WD_ALIGN_PARAGRAPH
10
-
11
- from ._utils import resolve_output_path
12
-
13
- _PDF_CSS = """
14
- @page { size: {page_size}; margin: 2cm; }
15
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans CJK SC", sans-serif;
16
- font-size: 11pt; line-height: 1.6; color: #333; }
17
- h1 { font-size: 22pt; margin-top: 0; }
18
- h2 { font-size: 16pt; }
19
- h3 { font-size: 13pt; }
20
- table { border-collapse: collapse; width: 100%; margin: 1em 0; }
21
- th, td { border: 1px solid #ccc; padding: 6px 10px; text-align: left; }
22
- th { background: #f0f0f0; font-weight: 600; }
23
- code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }
24
- pre { background: #f4f4f4; padding: 12px; border-radius: 4px; overflow-x: auto; }
25
- blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 1em; color: #666; }
26
- """
27
-
28
-
29
- def create_pdf(content: str, filename: str, *, content_type: str = "markdown",
30
- output_dir: str | None = None, page_size: str = "A4") -> str:
31
- """Generate a PDF file from Markdown or HTML. Returns absolute path."""
32
- path = resolve_output_path(filename, ".pdf", output_dir)
33
- html_body = mistune.html(content) if content_type == "markdown" else content
34
- css = _PDF_CSS.replace("{page_size}", page_size)
35
- full_html = f"<html><head><style>{css}</style></head><body>{html_body}</body></html>"
36
- HTML(string=full_html).write_pdf(str(path))
37
- return str(path.resolve())
38
-
39
-
40
- def create_docx(content: str, filename: str, *, content_type: str = "markdown",
41
- output_dir: str | None = None, font_name: str = "Arial", font_size: int = 11) -> str:
42
- """Generate a DOCX file from Markdown or HTML. Returns absolute path.
43
-
44
- Converts Markdown to a simple DOCX with headings, paragraphs, and lists.
45
- For complex HTML, consider generating PDF instead.
46
- """
47
- path = resolve_output_path(filename, ".docx", output_dir)
48
-
49
- if content_type == "html":
50
- # For HTML input, convert to markdown-like text first
51
- import re
52
- text = re.sub(r"<[^>]+>", "", content)
53
- lines = [l.strip() for l in text.split("\n") if l.strip()]
54
- else:
55
- lines = content.split("\n")
56
-
57
- doc = Document()
58
- # Set default font
59
- style = doc.styles["Normal"]
60
- style.font.name = font_name
61
- style.font.size = Pt(font_size)
62
-
63
- for line in lines:
64
- stripped = line.strip()
65
- if not stripped:
66
- continue
67
- # Headings
68
- if stripped.startswith("######"):
69
- doc.add_heading(stripped.lstrip("#").strip(), level=6)
70
- elif stripped.startswith("#####"):
71
- doc.add_heading(stripped.lstrip("#").strip(), level=5)
72
- elif stripped.startswith("####"):
73
- doc.add_heading(stripped.lstrip("#").strip(), level=4)
74
- elif stripped.startswith("###"):
75
- doc.add_heading(stripped.lstrip("#").strip(), level=3)
76
- elif stripped.startswith("##"):
77
- doc.add_heading(stripped.lstrip("#").strip(), level=2)
78
- elif stripped.startswith("#"):
79
- doc.add_heading(stripped.lstrip("#").strip(), level=1)
80
- # Unordered list
81
- elif stripped.startswith("- ") or stripped.startswith("* "):
82
- doc.add_paragraph(stripped[2:], style="List Bullet")
83
- # Ordered list
84
- elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1] in (".", ")"):
85
- doc.add_paragraph(stripped[2:].strip(), style="List Number")
86
- # Blockquote
87
- elif stripped.startswith("> "):
88
- p = doc.add_paragraph(stripped[2:])
89
- p.paragraph_format.left_indent = Inches(0.5)
90
- p.style.font.italic = True
91
- # Horizontal rule
92
- elif stripped in ("---", "***", "___"):
93
- doc.add_paragraph("─" * 50)
94
- else:
95
- doc.add_paragraph(stripped)
96
-
97
- doc.save(str(path))
98
- return str(path.resolve())
1
+ """Document generation — PDF, DOCX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from docx import Document
8
+ from docx.shared import Pt, Inches
9
+
10
+ from ._utils import resolve_output_path
11
+
12
+ _PDF_CSS = """
13
+ @page { size: {page_size}; margin: 2cm; }
14
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans CJK SC", sans-serif;
15
+ font-size: 11pt; line-height: 1.6; color: #333; }
16
+ h1 { font-size: 22pt; margin-top: 0; }
17
+ h2 { font-size: 16pt; }
18
+ h3 { font-size: 13pt; }
19
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
20
+ th, td { border: 1px solid #ccc; padding: 6px 10px; text-align: left; }
21
+ th { background: #f0f0f0; font-weight: 600; }
22
+ code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }
23
+ pre { background: #f4f4f4; padding: 12px; border-radius: 4px; overflow-x: auto; }
24
+ blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 1em; color: #666; }
25
+ """
26
+
27
+
28
+ def create_pdf(content: str, filename: str, *, content_type: str = "markdown",
29
+ output_dir: str | None = None, page_size: str = "A4") -> str:
30
+ """Generate a PDF file from Markdown or HTML. Returns absolute path."""
31
+ import mistune
32
+ from weasyprint import HTML
33
+
34
+ path = resolve_output_path(filename, ".pdf", output_dir)
35
+ html_body = mistune.html(content) if content_type == "markdown" else content
36
+ css = _PDF_CSS.replace("{page_size}", page_size)
37
+ full_html = f"<html><head><style>{css}</style></head><body>{html_body}</body></html>"
38
+ HTML(string=full_html).write_pdf(str(path))
39
+ return str(path.resolve())
40
+
41
+
42
+ def create_docx(content: str, filename: str, *, content_type: str = "markdown",
43
+ output_dir: str | None = None, font_name: str = "Arial", font_size: int = 11) -> str:
44
+ """Generate a DOCX file from Markdown or HTML. Returns absolute path.
45
+
46
+ Converts Markdown to a simple DOCX with headings, paragraphs, and lists.
47
+ For complex HTML, consider generating PDF instead.
48
+ """
49
+ path = resolve_output_path(filename, ".docx", output_dir)
50
+
51
+ if content_type == "html":
52
+ # For HTML input, convert to markdown-like text first
53
+ import re
54
+ text = re.sub(r"<[^>]+>", "", content)
55
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
56
+ else:
57
+ lines = content.split("\n")
58
+
59
+ doc = Document()
60
+ # Set default font
61
+ style = doc.styles["Normal"]
62
+ style.font.name = font_name
63
+ style.font.size = Pt(font_size)
64
+
65
+ for line in lines:
66
+ stripped = line.strip()
67
+ if not stripped:
68
+ continue
69
+ # Headings
70
+ if stripped.startswith("######"):
71
+ doc.add_heading(stripped.lstrip("#").strip(), level=6)
72
+ elif stripped.startswith("#####"):
73
+ doc.add_heading(stripped.lstrip("#").strip(), level=5)
74
+ elif stripped.startswith("####"):
75
+ doc.add_heading(stripped.lstrip("#").strip(), level=4)
76
+ elif stripped.startswith("###"):
77
+ doc.add_heading(stripped.lstrip("#").strip(), level=3)
78
+ elif stripped.startswith("##"):
79
+ doc.add_heading(stripped.lstrip("#").strip(), level=2)
80
+ elif stripped.startswith("#"):
81
+ doc.add_heading(stripped.lstrip("#").strip(), level=1)
82
+ # Unordered list
83
+ elif stripped.startswith("- ") or stripped.startswith("* "):
84
+ doc.add_paragraph(stripped[2:], style="List Bullet")
85
+ # Ordered list
86
+ elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1] in (".", ")"):
87
+ doc.add_paragraph(stripped[2:].strip(), style="List Number")
88
+ # Blockquote
89
+ elif stripped.startswith("> "):
90
+ p = doc.add_paragraph(stripped[2:])
91
+ p.paragraph_format.left_indent = Inches(0.5)
92
+ p.style.font.italic = True
93
+ # Horizontal rule
94
+ elif stripped in ("---", "***", "___"):
95
+ doc.add_paragraph("─" * 50)
96
+ else:
97
+ doc.add_paragraph(stripped)
98
+
99
+ doc.save(str(path))
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()
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ from licos_dev_sdk._utils import resolve_output_dir
13
+
14
+
15
+ class OutputPathTests(unittest.TestCase):
16
+ def test_explicit_output_dir_wins(self) -> None:
17
+ with tempfile.TemporaryDirectory() as tmp:
18
+ explicit = Path(tmp) / "custom"
19
+ with patch.dict(
20
+ os.environ,
21
+ {
22
+ "LICOS_PROJECT_PATH": str(Path(tmp) / "project"),
23
+ "LICOS_WORKSPACE_PATH": str(Path(tmp) / "workspace"),
24
+ },
25
+ clear=False,
26
+ ):
27
+ self.assertEqual(resolve_output_dir(str(explicit)), explicit)
28
+ self.assertTrue(explicit.exists())
29
+
30
+ def test_defaults_to_licos_project_path(self) -> None:
31
+ with tempfile.TemporaryDirectory() as tmp:
32
+ project = Path(tmp) / "projects"
33
+ workspace = Path(tmp) / "workspace"
34
+ with patch.dict(
35
+ os.environ,
36
+ {
37
+ "LICOS_PROJECT_PATH": str(project),
38
+ "LICOS_WORKSPACE_PATH": str(workspace),
39
+ },
40
+ clear=False,
41
+ ):
42
+ self.assertEqual(resolve_output_dir(), project)
43
+ self.assertTrue(project.exists())
44
+
45
+ def test_workspace_fallback_uses_projects_subdir(self) -> None:
46
+ with tempfile.TemporaryDirectory() as tmp:
47
+ workspace = Path(tmp) / "workspace"
48
+ with patch.dict(
49
+ os.environ,
50
+ {"LICOS_WORKSPACE_PATH": str(workspace)},
51
+ clear=False,
52
+ ):
53
+ os.environ.pop("LICOS_PROJECT_PATH", None)
54
+ expected = workspace / "projects"
55
+ self.assertEqual(resolve_output_dir(), expected)
56
+ self.assertTrue(expected.exists())
57
+
58
+
59
+ if __name__ == "__main__":
60
+ 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())