docuhand 0.1.0.dev1__py3-none-any.whl

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.
@@ -0,0 +1,92 @@
1
+ """export_pdf — print-fidelity PDF via the real Word/WPS layout engine.
2
+
3
+ Tool layer: parse the page range with pure helpers (engine/pdf_plan.py),
4
+ gate through allowlist + audit (export creates a NEW file but never
5
+ touches the source, so it stays available in read-only mode), then run
6
+ one engine export on the COM STA worker.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..engine import get_engine, get_worker
16
+ from ..engine.pdf_plan import parse_page_range
17
+ from ..errors import DocuhandError, FileNotFoundError, InternalError, InvalidParamError
18
+ from ..safety import allowlist, audit
19
+
20
+ TOOL_NAME = "export_pdf"
21
+
22
+
23
+ def run(
24
+ path: str,
25
+ output_path: str | None = None,
26
+ page_range: str | None = None,
27
+ ) -> dict[str, Any]:
28
+ t0 = time.perf_counter()
29
+
30
+ # -- argument validation ---------------------------------------------------
31
+ src = Path(path).expanduser()
32
+ if not src.exists():
33
+ return FileNotFoundError(str(src)).to_dict()
34
+
35
+ try:
36
+ pages = parse_page_range(page_range)
37
+ except ValueError as exc:
38
+ return InvalidParamError(str(exc), llm_hint="Use page_range='all', '7' or '3-9', or omit it.").to_dict()
39
+
40
+ if output_path:
41
+ dst = Path(output_path).expanduser()
42
+ if dst.suffix.lower() != ".pdf":
43
+ return InvalidParamError(
44
+ f"output_path must end in .pdf (got {dst.suffix or 'none'})",
45
+ llm_hint="PDF export writes a .pdf file; pick a name like 'report.pdf'.",
46
+ ).to_dict()
47
+ else:
48
+ dst = src.with_suffix(".pdf")
49
+
50
+ try:
51
+ if dst.exists() and dst.samefile(src):
52
+ return InvalidParamError(
53
+ "output_path must differ from the source document",
54
+ llm_hint="Pick another name or omit output_path to write next to the source.",
55
+ ).to_dict()
56
+ except OSError:
57
+ pass
58
+
59
+ # -- safety gates ------------------------------------------------------------
60
+ try:
61
+ allowlist.check_allowed(str(src))
62
+ allowlist.check_allowed(str(dst))
63
+ except DocuhandError as exc:
64
+ audit.record(TOOL_NAME, str(src), ok=False, duration_ms=0, error_code=exc.error_code)
65
+ return exc.to_dict()
66
+
67
+ # -- execute -------------------------------------------------------------------
68
+ worker = get_worker()
69
+ engine = get_engine()
70
+ page_from, page_to = pages if pages else (None, None)
71
+ try:
72
+ result = worker.submit(engine.export_pdf, str(src), str(dst), page_from, page_to)
73
+ except DocuhandError as exc:
74
+ audit.record(
75
+ TOOL_NAME,
76
+ str(src),
77
+ ok=False,
78
+ duration_ms=(time.perf_counter() - t0) * 1000,
79
+ error_code=exc.error_code,
80
+ )
81
+ return exc.to_dict()
82
+ except Exception as exc: # unknown engine crash — still structured
83
+ err = InternalError(
84
+ f"Unexpected error exporting {src.name}: {type(exc).__name__}: {exc}",
85
+ llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
86
+ details={"source": str(src)},
87
+ )
88
+ audit.record(TOOL_NAME, str(src), ok=False, duration_ms=(time.perf_counter() - t0) * 1000, error_code=err.error_code)
89
+ return err.to_dict()
90
+
91
+ audit.record(TOOL_NAME, str(src), ok=True, duration_ms=result.get("duration_ms") or 0, engine=result.get("engine"))
92
+ return result
@@ -0,0 +1,63 @@
1
+ """extract_content — text / tables / outline out of legacy .doc as JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from ..engine import get_engine, get_worker
9
+ from ..engine.extraction import extract_content as engine_extract
10
+ from ..errors import DocuhandError, InvalidParamError, InternalError
11
+ from ..safety import allowlist, audit
12
+
13
+ TOOL_NAME = "extract_content"
14
+
15
+ _WHAT = ("text", "tables", "outline")
16
+
17
+
18
+ def run(path: str, what: str = "text") -> dict[str, Any]:
19
+ t0 = time.perf_counter()
20
+
21
+ what_norm = (what or "text").strip().lower()
22
+ if what_norm not in _WHAT:
23
+ return InvalidParamError(
24
+ f"what must be one of {list(_WHAT)} (got {what!r})",
25
+ llm_hint="'tables' pulls every table as a JSON grid; 'outline' returns the heading tree; 'text' is the full plain text.",
26
+ ).to_dict()
27
+
28
+ try:
29
+ allowlist.check_allowed(path)
30
+ except DocuhandError as exc:
31
+ audit.record(TOOL_NAME, path, ok=False, duration_ms=0, error_code=exc.error_code)
32
+ return exc.to_dict()
33
+
34
+ worker = get_worker()
35
+ engine = get_engine()
36
+ try:
37
+ result = worker.submit(engine_extract, engine, worker, path, what_norm)
38
+ except DocuhandError as exc:
39
+ audit.record(
40
+ TOOL_NAME,
41
+ path,
42
+ ok=False,
43
+ duration_ms=(time.perf_counter() - t0) * 1000,
44
+ error_code=exc.error_code,
45
+ )
46
+ return exc.to_dict()
47
+ except Exception as exc:
48
+ err = InternalError(
49
+ f"Unexpected error extracting content: {type(exc).__name__}: {exc}",
50
+ llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
51
+ details={"path": path},
52
+ )
53
+ audit.record(TOOL_NAME, path, ok=False, duration_ms=(time.perf_counter() - t0) * 1000, error_code=err.error_code)
54
+ return err.to_dict()
55
+
56
+ audit.record(
57
+ TOOL_NAME,
58
+ path,
59
+ ok=True,
60
+ duration_ms=result.get("duration_ms") or 0,
61
+ engine=result.get("engine"),
62
+ )
63
+ return result
@@ -0,0 +1,115 @@
1
+ """fill_template — fill bookmarks / {{placeholders}} / content controls.
2
+
3
+ Tool layer: validate + plan with pure helpers (engine/template_plan.py),
4
+ gate through the safety stack (read-only policy, allowlist, audit), then
5
+ run one engine fill on the COM STA worker. The template file itself is
6
+ never modified; failures come back as structured errors.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..engine import get_engine, get_worker
16
+ from ..engine.template_plan import (
17
+ normalize_mode,
18
+ parse_bookmark_names,
19
+ resolve_output,
20
+ validate_data,
21
+ )
22
+ from ..errors import DocuhandError, FileNotFoundError, InternalError, InvalidParamError, TemplateFillError
23
+ from ..safety import allowlist, audit
24
+ from ..safety.policy import ensure_writable
25
+
26
+ TOOL_NAME = "fill_template"
27
+
28
+
29
+ def run(
30
+ template_path: str,
31
+ data: dict[str, Any],
32
+ output_path: str | None = None,
33
+ mode: str = "auto",
34
+ bookmarks: list[str] | None = None,
35
+ ) -> dict[str, Any]:
36
+ t0 = time.perf_counter()
37
+
38
+ # -- argument validation ---------------------------------------------------
39
+ try:
40
+ norm_mode = normalize_mode(mode)
41
+ clean_data = validate_data(data)
42
+ names = parse_bookmark_names(bookmarks)
43
+ except ValueError as exc:
44
+ return InvalidParamError(str(exc), llm_hint="Fix the argument and retry.").to_dict()
45
+
46
+ template = Path(template_path).expanduser()
47
+ if not template.exists():
48
+ return FileNotFoundError(str(template)).to_dict()
49
+
50
+ try:
51
+ output = resolve_output(template, output_path)
52
+ except ValueError as exc:
53
+ return InvalidParamError(
54
+ str(exc),
55
+ llm_hint="Choose a different output_path (extension .doc/.docx); the template is never rewritten.",
56
+ ).to_dict()
57
+
58
+ # -- safety gates ------------------------------------------------------------
59
+ try:
60
+ ensure_writable(TOOL_NAME)
61
+ allowlist.check_allowed(str(template))
62
+ allowlist.check_allowed(str(output))
63
+ except DocuhandError as exc:
64
+ audit.record(TOOL_NAME, str(template), ok=False, duration_ms=0, error_code=exc.error_code)
65
+ return exc.to_dict()
66
+
67
+ # -- execute -------------------------------------------------------------------
68
+ worker = get_worker()
69
+ engine = get_engine()
70
+ try:
71
+ result = worker.submit(
72
+ engine.fill_document,
73
+ str(template),
74
+ str(output),
75
+ clean_data,
76
+ norm_mode,
77
+ names,
78
+ )
79
+ except DocuhandError as exc:
80
+ audit.record(
81
+ TOOL_NAME,
82
+ str(template),
83
+ ok=False,
84
+ duration_ms=(time.perf_counter() - t0) * 1000,
85
+ error_code=exc.error_code,
86
+ )
87
+ return exc.to_dict()
88
+ except Exception as exc: # unknown engine crash — still structured
89
+ err = InternalError(
90
+ f"Unexpected error filling {template.name}: {type(exc).__name__}: {exc}",
91
+ llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
92
+ details={"template": str(template)},
93
+ )
94
+ audit.record(TOOL_NAME, str(template), ok=False, duration_ms=(time.perf_counter() - t0) * 1000, error_code=err.error_code)
95
+ return err.to_dict()
96
+
97
+ audit.record(TOOL_NAME, str(template), ok=True, duration_ms=result.get("duration_ms") or 0, engine=result.get("engine"))
98
+
99
+ # A fill that silently skipped keys is a warning, not a crash — surface it.
100
+ problems: list[str] = []
101
+ if result.get("missing_keys"):
102
+ problems.append(f"{len(result['missing_keys'])} data key(s) found no target in the template")
103
+ if result.get("leftover_placeholders"):
104
+ problems.append(f"{len(result['leftover_placeholders'])} {{{{...}}}} placeholder(s) remain unfilled")
105
+ if problems and norm_mode == "auto":
106
+ incomplete = TemplateFillError(
107
+ str(output),
108
+ "; ".join(problems),
109
+ missing=result.get("missing_keys") or None,
110
+ )
111
+ # success-shaped payload plus the structured warning — the file IS
112
+ # written; the agent decides whether it is good enough to keep.
113
+ result["ok"] = True
114
+ result["warning"] = incomplete.to_dict()["error"]
115
+ return result
@@ -0,0 +1,57 @@
1
+ """inspect_document — pre-flight health check for Word documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from ..engine import get_engine
9
+ from ..errors import DocuhandError, InternalError
10
+ from ..safety import allowlist, audit
11
+
12
+ TOOL_NAME = "inspect_document"
13
+
14
+
15
+ def run(path: str) -> dict[str, Any]:
16
+ t0 = time.perf_counter()
17
+
18
+ try:
19
+ allowlist.check_allowed(path)
20
+ except DocuhandError as exc:
21
+ audit.record(TOOL_NAME, path, ok=False, duration_ms=0, error_code=exc.error_code)
22
+ return exc.to_dict()
23
+
24
+ try:
25
+ result = get_engine().inspect_document(path)
26
+ except DocuhandError as exc:
27
+ audit.record(
28
+ TOOL_NAME,
29
+ path,
30
+ ok=False,
31
+ duration_ms=(time.perf_counter() - t0) * 1000,
32
+ error_code=exc.error_code,
33
+ )
34
+ return exc.to_dict()
35
+ except Exception as exc: # unknown engine crash — still structured
36
+ err = InternalError(
37
+ f"Unexpected error: {type(exc).__name__}: {exc}",
38
+ llm_hint="Retry once. If it persists, report error_code and details to the DocuHand repository.",
39
+ details={"path": path},
40
+ )
41
+ audit.record(
42
+ TOOL_NAME,
43
+ path,
44
+ ok=False,
45
+ duration_ms=(time.perf_counter() - t0) * 1000,
46
+ error_code=err.error_code,
47
+ )
48
+ return err.to_dict()
49
+
50
+ audit.record(
51
+ TOOL_NAME,
52
+ path,
53
+ ok=True,
54
+ duration_ms=(time.perf_counter() - t0) * 1000,
55
+ engine=result.get("engine"),
56
+ )
57
+ return result
@@ -0,0 +1,143 @@
1
+ """merge_documents — one template × N data rows → N filled files.
2
+
3
+ Mail-merge style batch generation (the 干部任免审批表 production scar,
4
+ rebuilt as a product tool). Per-row fault isolation: one bad row fails
5
+ alone and the batch continues — the convert_documents survival guarantee,
6
+ applied to generation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..engine import get_engine, get_worker
16
+ from ..engine.merge_plan import build_name, default_output_dir, resolve_collision
17
+ from ..engine.template_plan import validate_data
18
+ from ..errors import DocuhandError, InternalError, InvalidParamError
19
+ from ..safety import allowlist, audit
20
+ from ..safety.policy import ensure_writable
21
+
22
+ TOOL_NAME = "merge_documents"
23
+
24
+
25
+ def run(
26
+ template_path: str,
27
+ data_rows: list[dict[str, Any]],
28
+ output_dir: str | None = None,
29
+ name_pattern: str = "{i}_{name}",
30
+ ) -> dict[str, Any]:
31
+ t0 = time.perf_counter()
32
+
33
+ # -- argument validation ---------------------------------------------------
34
+ template = Path(template_path).expanduser()
35
+ if not template.exists():
36
+ from ..errors import FileNotFoundError
37
+
38
+ return FileNotFoundError(str(template)).to_dict()
39
+
40
+ if not isinstance(data_rows, list) or not data_rows:
41
+ return InvalidParamError(
42
+ "data_rows must be a non-empty array of row objects",
43
+ llm_hint="Each row is one output document: [{\"name\": \"张三\", ...}, ...].",
44
+ ).to_dict()
45
+ if len(data_rows) > 500:
46
+ return InvalidParamError(
47
+ f"at most 500 rows per call (got {len(data_rows)})",
48
+ llm_hint="Split very large merges into several calls; the COM session stays healthier.",
49
+ ).to_dict()
50
+
51
+ cleaned_rows: list[dict[str, str]] = []
52
+ for i, row in enumerate(data_rows):
53
+ try:
54
+ cleaned_rows.append(validate_data(row))
55
+ except ValueError as exc:
56
+ return InvalidParamError(
57
+ f"data_rows[{i}]: {exc}",
58
+ llm_hint="Fix this row and retry.",
59
+ ).to_dict()
60
+
61
+ out_dir = Path(output_dir).expanduser() if output_dir else default_output_dir(template)
62
+ try:
63
+ out_dir.mkdir(parents=True, exist_ok=True)
64
+ except OSError as exc:
65
+ return DocuhandError(
66
+ f"Cannot create output directory {out_dir}: {exc}",
67
+ llm_hint="Check the path (drive letter, permissions) or pass a different output_dir.",
68
+ details={"output_dir": str(out_dir)},
69
+ ).to_dict()
70
+
71
+ ext = template.suffix.lower()
72
+ if ext not in (".doc", ".docx"):
73
+ return InvalidParamError(
74
+ f"template must be .doc or .docx (got {ext or 'none'})",
75
+ llm_hint="Pick a Word template file; PDF templates are not supported.",
76
+ ).to_dict()
77
+
78
+ # -- safety gates ------------------------------------------------------------
79
+ try:
80
+ ensure_writable(TOOL_NAME)
81
+ allowlist.check_allowed(str(template))
82
+ allowlist.check_allowed(str(out_dir))
83
+ except DocuhandError as exc:
84
+ audit.record(TOOL_NAME, str(template), ok=False, duration_ms=0, error_code=exc.error_code)
85
+ return exc.to_dict()
86
+
87
+ # -- plan outputs (collisions resolved before any engine work) -----------------
88
+ planned: list[tuple[dict[str, str], Path]] = []
89
+ for i, row in enumerate(cleaned_rows, start=1):
90
+ stem = build_name(name_pattern, i, row)
91
+ target = resolve_collision(out_dir, stem, ext)
92
+ planned.append((row, target))
93
+
94
+ # -- execute per row with fault isolation ----------------------------------------
95
+ worker = get_worker()
96
+ engine = get_engine()
97
+ results: list[dict[str, Any]] = []
98
+ counts = {"merged": 0, "failed": 0, "with_warnings": 0}
99
+
100
+ for i, (row, target) in enumerate(planned, start=1):
101
+ try:
102
+ r = worker.submit(engine.merge_one, str(template), str(target), row)
103
+ except DocuhandError as exc:
104
+ counts["failed"] += 1
105
+ results.append({"row": i, "output": str(target), "status": "failed", "error": exc.to_dict()["error"]})
106
+ audit.record(TOOL_NAME, str(template), ok=False, duration_ms=0, error_code=exc.error_code)
107
+ continue
108
+ except Exception as exc:
109
+ err = InternalError(
110
+ f"row {i}: {type(exc).__name__}: {exc}",
111
+ llm_hint="Retry once; if it persists report error_code to the DocuHand repository.",
112
+ )
113
+ counts["failed"] += 1
114
+ results.append({"row": i, "output": str(target), "status": "failed", "error": err.to_dict()["error"]})
115
+ audit.record(TOOL_NAME, str(template), ok=False, duration_ms=0, error_code=err.error_code)
116
+ continue
117
+
118
+ counts["merged"] += 1
119
+ entry: dict[str, Any] = {
120
+ "row": i,
121
+ "output": str(target),
122
+ "status": "merged",
123
+ "engine": r.get("engine"),
124
+ "output_size_bytes": r.get("output_size_bytes"),
125
+ }
126
+ if r.get("row_warning"):
127
+ counts["with_warnings"] += 1
128
+ entry["warning"] = r["row_warning"]
129
+ results.append(entry)
130
+ audit.record(TOOL_NAME, str(template), ok=True, duration_ms=r.get("duration_ms") or 0, engine=r.get("engine"))
131
+
132
+ return {
133
+ "ok": counts["failed"] < len(planned), # total engine failure only if every row failed
134
+ "tool": TOOL_NAME,
135
+ "template": str(template),
136
+ "output_dir": str(out_dir),
137
+ "summary": {
138
+ **counts,
139
+ "rows": len(planned),
140
+ "duration_ms": round((time.perf_counter() - t0) * 1000),
141
+ },
142
+ "results": results,
143
+ }
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: docuhand
3
+ Version: 0.1.0.dev1
4
+ Summary: Give your AI agent hands to operate real Word & WPS documents on Windows — an MCP server driving real Office apps via COM.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: mcp<2,>=1.2
9
+ Requires-Dist: olefile>=0.47
10
+ Requires-Dist: pywin32<312,>=306
11
+ Description-Content-Type: text/markdown
12
+
13
+ # docuhand
14
+ Give your AI agent hands to operate real Word &amp; WPS documents on Windows
@@ -0,0 +1,36 @@
1
+ docuhand/__init__.py,sha256=xSZFzhFrXp33G8dvHJ4wvsUPHP7JBr2Rm1sljA_puJU,110
2
+ docuhand/__main__.py,sha256=9it1my_mM7xBpDh79QSi7FAvZD3Vki3GQFPJIDZw_zs,64
3
+ docuhand/_compat.py,sha256=Ue5Uo8_nKDpfkmI5lGBIeLY_JG31I-jTstlMrOhoET0,984
4
+ docuhand/errors.py,sha256=ia1ikuJjZHged-7HB_V1wjMm0AcItBJzRKnzTea9cSc,7199
5
+ docuhand/server.py,sha256=tlJjWLeOqouPvOsR1OLuIOOyXDz-z6cAf28iY22-ZdY,9346
6
+ docuhand/engine/__init__.py,sha256=SMAJZMblCqWR39XWPlcb6_fMHhDk2xMIwxTOOxoT4nA,1047
7
+ docuhand/engine/com_thread.py,sha256=srRaHYjbv99vOzWLtKBeeD8yaKKJwnpzWitwEVBviOo,3139
8
+ docuhand/engine/com_utils.py,sha256=cNmIDXHuxSUSwvthNGfCCGvLEN4JKaAddHG2V6_TGKA,1707
9
+ docuhand/engine/container_guard.py,sha256=BTE2v-LkDL9TCj5cgNLp3OM7ubrM77VkfE-5aicyRHw,7111
10
+ docuhand/engine/convert_plan.py,sha256=ysajq0Ikv2IwQ7WLXm6PI6cwFJb2wixym55c_QAUNO4,1632
11
+ docuhand/engine/edit_plan.py,sha256=qSBR3f9W7gXcR7tL4R4Y2V5SgNKVMS_8zs1iInBjVQc,3098
12
+ docuhand/engine/extraction.py,sha256=bmDi9HN4CTrgfMNsrNpL9Lr6PX-2C40RXovNgYUho7Q,6379
13
+ docuhand/engine/live_edit.py,sha256=hjYMU72HoiOFNUEF4JXFqfb62CgodkjQ3mdgdV2zApY,7477
14
+ docuhand/engine/merge_plan.py,sha256=t1hAoQ4msDmTZqq_snfQpIy1gWOZGe8Ki0f6b4n8tzk,1972
15
+ docuhand/engine/office_app.py,sha256=zWoyUDO5cYu88KrxO0vmxcTmQDQu57VIN-noXKa8ZKU,32036
16
+ docuhand/engine/pdf_plan.py,sha256=x9kxHWE4HH8dquzC5TxjhzwYXajx8oB3WWmiWYSHNEc,1898
17
+ docuhand/engine/template_plan.py,sha256=bPORNXxe4WpURTrY54jwGeWF4SrOt98Ee-BvGB3zi4c,3910
18
+ docuhand/engine/templating.py,sha256=PkQT3inty0PDFMJfIlLj_H2554hD9u0t2aClF4qKMLc,11137
19
+ docuhand/engine/wd_constants.py,sha256=MT0YLnaiDo5opf86C9rl_4I9ukPnTff7tnH8DazsNLg,547
20
+ docuhand/safety/__init__.py,sha256=HbcyxKcUAaotJ5-sXh6clm1oca9KepfGKzzgTTnSuI8,315
21
+ docuhand/safety/allowlist.py,sha256=a7ooM9aBjnni2EY_YSnq4dIYN0bPIeWw61VFRx9fLks,1190
22
+ docuhand/safety/audit.py,sha256=_jQNEDrrUqaykdFuLTNTziqACGfRGpf-Z4ZZlrwYxAw,1387
23
+ docuhand/safety/policy.py,sha256=J9uiSCW3Maszeg6-qS9BNj0Qav8ZIISQu7y4WLKJcIQ,877
24
+ docuhand/tools/__init__.py,sha256=GadG7o7O1tWO4jjTPrWx6R_4u7xiJPW9MXqSf-fMsZs,344
25
+ docuhand/tools/convert.py,sha256=4hCMRGmIGSPiza2ujcRJc4kejkcMV0ZJjsw0t7vVZ5k,8527
26
+ docuhand/tools/edit_open.py,sha256=dwlClXEWTidHlOEWC7i3swV0ALIhE1sy7QQvNldE52c,3458
27
+ docuhand/tools/export_pdf.py,sha256=sQjtJ0q1-JAb2kRzj9Hui3ojIXUOKXL_S4Rj3IYBvIY,3441
28
+ docuhand/tools/extract.py,sha256=ToJwVk3_U6ZEKxJaJRbGSMRuPlb7VB_8z6mdb_nV464,2086
29
+ docuhand/tools/fill_template.py,sha256=7G5CNDdVoUEDfeER8u1ldyC8B8hWe6kiERqGuQPRi0o,4304
30
+ docuhand/tools/inspect.py,sha256=O8dayfhlyrFpCZdTfO2rU_f1-4nLvByMurP9wENCiYs,1611
31
+ docuhand/tools/merge.py,sha256=tDC4hQEiBPfu2FSvV0TZHkIzxu27OImVvrbP_u0IyVQ,5681
32
+ docuhand-0.1.0.dev1.dist-info/METADATA,sha256=z3nNmRe3Q96wZ4gGyn9fwCfgHGQu9LGQho285eO81gg,479
33
+ docuhand-0.1.0.dev1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
34
+ docuhand-0.1.0.dev1.dist-info/entry_points.txt,sha256=c0HU90ex3jSZhvCHIelsFweolWagjDOm1UUCcmoDqDQ,50
35
+ docuhand-0.1.0.dev1.dist-info/licenses/LICENSE,sha256=hPw9rU1TQ7AAqcXcmBq2qtrQyq1-Yo-e6u-BFy_ndJ4,1088
36
+ docuhand-0.1.0.dev1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docuhand = docuhand.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frank Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.