siyuan-cli 1.6.0__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.
siyuan_cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """siyuan-cli — SiYuan Note command-line tool.
2
+
3
+ Package for interacting with SiYuan Note's HTTP API.
4
+ Create, read, search, and manage notes, blocks, tags, and assets.
5
+ """
6
+
7
+ __version__ = "1.6.0"
siyuan_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for `python3 -m siyuan_cli`."""
2
+
3
+ from siyuan_cli.cli import main
4
+
5
+ main()
siyuan_cli/cli.py ADDED
@@ -0,0 +1,422 @@
1
+ """CLI argument parser and main entry point for siyuan-cli."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+
7
+ from siyuan_cli import __version__
8
+ from siyuan_cli.commands.asset import cmd_asset
9
+ from siyuan_cli.commands.attr import cmd_attr as cmd_attr_fn
10
+ from siyuan_cli.commands.batch import cmd_batch
11
+ from siyuan_cli.commands.block import cmd_block
12
+ from siyuan_cli.commands.config_cmd import (
13
+ cmd_config,
14
+ cmd_config_get,
15
+ cmd_keyring_set,
16
+ cmd_keyring_unset,
17
+ )
18
+ from siyuan_cli.commands.doc import (
19
+ cmd_create,
20
+ cmd_delete,
21
+ cmd_doc,
22
+ cmd_list_notebooks,
23
+ cmd_read,
24
+ cmd_tree,
25
+ )
26
+ from siyuan_cli.commands.doc import (
27
+ cmd_export as cmd_export_doc,
28
+ )
29
+ from siyuan_cli.commands.export_cmd import cmd_export as cmd_export_fn
30
+ from siyuan_cli.commands.file_cmd import cmd_file
31
+ from siyuan_cli.commands.notebook import cmd_notebook
32
+ from siyuan_cli.commands.notify import cmd_notify
33
+ from siyuan_cli.commands.search import cmd_search, cmd_sql
34
+ from siyuan_cli.commands.stats import cmd_stats
35
+ from siyuan_cli.commands.tag import cmd_tag
36
+ from siyuan_cli.commands.template import cmd_template
37
+ from siyuan_cli.exceptions import SiYuanError
38
+ from siyuan_cli.utils import set_json_mode
39
+
40
+
41
+ def create_parser() -> argparse.ArgumentParser:
42
+ """Build the argument parser with all subcommands."""
43
+ p = argparse.ArgumentParser(
44
+ prog="siyuan",
45
+ description="SiYuan Note command-line tool",
46
+ epilog="See https://github.com/RowanGrove/siyuan-skills",
47
+ )
48
+ p.add_argument("--json", action="store_true", help="JSON output")
49
+ p.add_argument("-v", "--verbose", action="store_true", help="Debug logs")
50
+ p.add_argument("--log-file", help="Write logs to file instead of stderr")
51
+ p.add_argument("--version", action="version", version=f"siyuan {__version__}")
52
+
53
+ sub = p.add_subparsers(dest="command", required=True)
54
+
55
+ # ───────────────────── LEGACY FLAT COMMANDS (backward compatible) ────────────
56
+
57
+ # config
58
+ cf = sub.add_parser("config", help="Show or set connection config")
59
+ cf.add_argument("set", nargs="*", metavar="key val", help="Set config key (url|token)")
60
+
61
+ # list-notebooks
62
+ sub.add_parser("list-notebooks", help="List all notebooks (alias for 'notebook list')")
63
+
64
+ # create
65
+ cr = sub.add_parser("create", help="Create a document")
66
+ cr.add_argument("path", help="Document path, e.g. /folder/Note")
67
+ cr.add_argument("content", nargs="?", default="", help="Markdown content")
68
+ cr.add_argument("--notebook", "-n", help="Notebook ID (default: first)")
69
+
70
+ # read
71
+ rd = sub.add_parser("read", help="Read a document")
72
+ rd.add_argument("path", help="Document path")
73
+ rd.add_argument("--notebook", "-n", help="Notebook ID")
74
+
75
+ # delete
76
+ dl = sub.add_parser("delete", help="Delete a document (by path or --id)")
77
+ dl.add_argument("path", help="Document path (or any value if --id)")
78
+ dl.add_argument("--notebook", "-n", help="Notebook ID")
79
+ dl.add_argument("--id", help="Delete by block ID instead of path")
80
+
81
+ # search
82
+ sr = sub.add_parser("search", help="Full-text search")
83
+ sr.add_argument("query", nargs="+", help="Search terms")
84
+ sr.add_argument("--limit", "-l", type=int, default=10, help="Max results")
85
+
86
+ # sql
87
+ sq = sub.add_parser("sql", help="Run SQL query")
88
+ sq.add_argument("statement", nargs="+", help="SELECT statement")
89
+
90
+ # tree
91
+ tr = sub.add_parser("tree", help="Show document tree")
92
+ tr.add_argument("notebook", nargs="?", default="", help="Notebook ID")
93
+
94
+ # config-get
95
+ cg = sub.add_parser("config-get", help="Read SiYuan server config value")
96
+ cg.add_argument("key", nargs="?", default=None, help="Dot-notation key")
97
+
98
+ # stats
99
+ sub.add_parser("stats", help="Workspace statistics")
100
+
101
+ # export (legacy — by path)
102
+ ex = sub.add_parser("export", help="Export document as Markdown (by path)")
103
+ ex.add_argument("path", help="Document path")
104
+ ex.add_argument("--notebook", "-n", help="Notebook ID")
105
+
106
+ # tag-list
107
+ sub.add_parser("tag-list", help="List all tags (alias for 'tag list')")
108
+
109
+ # ───────────────────── TAG ─────────────────────
110
+ tp = sub.add_parser("tag", help="Tag management (list/blocks/rename/remove)")
111
+ tsub = tp.add_subparsers(dest="action", required=True)
112
+ tsub.add_parser("list", help="List all unique tags")
113
+ tb = tsub.add_parser("blocks", help="Find blocks with a tag")
114
+ tb.add_argument("tag", help="Tag name (without #)")
115
+ trn = tsub.add_parser("rename", help="Rename a tag across all blocks")
116
+ trn.add_argument("old", help="Old tag name")
117
+ trn.add_argument("new", help="New tag name")
118
+ trm = tsub.add_parser("remove", help="Remove a tag from all blocks")
119
+ trm.add_argument("tag", help="Tag name to remove")
120
+
121
+ # ───────────────────── ASSET ─────────────────────
122
+ ap = sub.add_parser("asset", help="Asset management (upload/list)")
123
+ asub = ap.add_subparsers(dest="action", required=True)
124
+ au = asub.add_parser("upload", help="Upload a file as asset")
125
+ au.add_argument("file", help="Path to local file")
126
+ au.add_argument("--dir", default="/assets/", help="Asset directory (default: /assets/)")
127
+ al = asub.add_parser("list", help="List assets")
128
+ al.add_argument("--limit", "-l", type=int, default=50, help="Max results")
129
+ al.add_argument("--offset", "-o", type=int, default=0, help="Result offset")
130
+ al.add_argument("--filter", "-f", help="Filter by name")
131
+
132
+ # ───────────────────── ATTR ─────────────────────
133
+ ap2 = sub.add_parser("attr", help="Block attribute management (get/set)")
134
+ a2sub = ap2.add_subparsers(dest="action", required=True)
135
+ a2g = a2sub.add_parser("get", help="Get block attributes")
136
+ a2g.add_argument("id", help="Block ID")
137
+ a2s = a2sub.add_parser("set", help="Set block attributes")
138
+ a2s.add_argument("id", help="Block ID")
139
+ a2s.add_argument("key", help="Attribute key (e.g. custom-myattr)")
140
+ a2s.add_argument("value", help="Attribute value")
141
+
142
+ # ───────────────────── BLOCK ─────────────────────
143
+ bp = sub.add_parser(
144
+ "block",
145
+ help="Block operations: get/children/insert/prepend/append/"
146
+ "update/delete/move/fold/unfold/transfer-ref",
147
+ )
148
+ bsub = bp.add_subparsers(dest="action", required=True)
149
+
150
+ bg = bsub.add_parser("get", help="Get block info via SQL")
151
+ bg.add_argument("id", help="Block ID")
152
+ bc = bsub.add_parser("children", help="Get child blocks")
153
+ bc.add_argument("id", help="Parent block ID")
154
+ bi = bsub.add_parser("insert", help="Insert a block after a sibling")
155
+ bi.add_argument(
156
+ "parent_id", nargs="?", default="", help="Parent block ID (used if --previous is empty)"
157
+ )
158
+ bi.add_argument("-c", "--content", required=True, help="Block markdown content")
159
+ bi.add_argument("--previous", help="Previous sibling block ID (higher priority)")
160
+ bp1 = bsub.add_parser("prepend", help="Prepend block as first child")
161
+ bp1.add_argument("id", help="Parent block ID")
162
+ bp1.add_argument("-c", "--content", required=True, help="Block markdown content")
163
+ bp2 = bsub.add_parser("append", help="Append block as last child")
164
+ bp2.add_argument("id", help="Parent block ID")
165
+ bp2.add_argument("-c", "--content", required=True, help="Block markdown content")
166
+ bu = bsub.add_parser("update", help="Update a block's content")
167
+ bu.add_argument("id", help="Block ID")
168
+ bu.add_argument("-c", "--content", required=True, help="New markdown content")
169
+ bd = bsub.add_parser("delete", help="Delete a block")
170
+ bd.add_argument("id", help="Block ID")
171
+ bm = bsub.add_parser("move", help="Move a block")
172
+ bm.add_argument("id", help="Block ID to move")
173
+ bm.add_argument("--parent", "-p", default="", help="New parent block ID")
174
+ bm.add_argument("--previous", default="", help="Previous sibling ID (higher priority)")
175
+ bf = bsub.add_parser("fold", help="Fold (collapse) a heading block")
176
+ bf.add_argument("id", help="Block ID")
177
+ buf = bsub.add_parser("unfold", help="Unfold (expand) a block")
178
+ buf.add_argument("id", help="Block ID")
179
+ btr = bsub.add_parser("transfer-ref", help="Transfer block refs to another block")
180
+ btr.add_argument("from_id", help="Source def block ID")
181
+ btr.add_argument("to_id", help="Target block ID")
182
+ btr.add_argument(
183
+ "--ref-ids", nargs="*", default=None, help="Specific ref block IDs (omit for all)"
184
+ )
185
+
186
+ # ───────────────────── TEMPLATE ─────────────────────
187
+ tpl = sub.add_parser("template", help="Template rendering")
188
+ tsub2 = tpl.add_subparsers(dest="action", required=True)
189
+ tr = tsub2.add_parser("render", help="Render a template file")
190
+ tr.add_argument("id", help="Block ID of the calling document")
191
+ tr.add_argument("path", help="Absolute path to template file on SiYuan server")
192
+ trs = tsub2.add_parser("render-sprig", help="Render a Sprig template string")
193
+ trs.add_argument("template", help="Template string with Sprig syntax")
194
+
195
+ # ───────────────────── NOTIFY ─────────────────────
196
+ nt = sub.add_parser("notify", help="Push notifications to SiYuan UI")
197
+ nsub = nt.add_subparsers(dest="action", required=True)
198
+ np = nsub.add_parser("push", help="Push an info notification")
199
+ np.add_argument("msg", help="Message text")
200
+ np.add_argument(
201
+ "--timeout", type=int, default=7000, help="Display duration in ms (default: 7000)"
202
+ )
203
+ npe = nsub.add_parser("push-err", help="Push an error notification")
204
+ npe.add_argument("msg", help="Error message text")
205
+ npe.add_argument(
206
+ "--timeout", type=int, default=7000, help="Display duration in ms (default: 7000)"
207
+ )
208
+
209
+ # ───────────────────── NOTEBOOK ─────────────────────
210
+ nb = sub.add_parser(
211
+ "notebook",
212
+ help="Notebook management: list/create/remove/rename/open/close/conf",
213
+ )
214
+ nbsub = nb.add_subparsers(dest="action", required=True)
215
+ nbsub.add_parser("list", help="List notebooks (with icons and status)")
216
+ nbc = nbsub.add_parser("create", help="Create a notebook")
217
+ nbc.add_argument("name", help="Notebook name")
218
+ nbr = nbsub.add_parser("remove", help="Remove a notebook (irreversible)")
219
+ nbr.add_argument("id", help="Notebook ID")
220
+ nbrn = nbsub.add_parser("rename", help="Rename a notebook")
221
+ nbrn.add_argument("id", help="Notebook ID")
222
+ nbrn.add_argument("name", help="New notebook name")
223
+ nbo = nbsub.add_parser("open", help="Open a notebook")
224
+ nbo.add_argument("id", help="Notebook ID")
225
+ nbc2 = nbsub.add_parser("close", help="Close a notebook")
226
+ nbc2.add_argument("id", help="Notebook ID")
227
+ nbcf = nbsub.add_parser("conf", help="Get notebook configuration")
228
+ nbcf.add_argument("id", help="Notebook ID")
229
+ nbsc = nbsub.add_parser("set-conf", help="Set notebook configuration")
230
+ nbsc.add_argument("id", help="Notebook ID")
231
+ nbsc.add_argument(
232
+ "overrides", nargs="+", help="key=value overrides (e.g. dailyNoteSavePath=...)"
233
+ )
234
+
235
+ # ───────────────────── DOC (new hierarchical) ─────────────────────
236
+ dc = sub.add_parser(
237
+ "doc",
238
+ help="Document management: create/read/delete/rename/move/tree/get-path",
239
+ )
240
+ dcsub = dc.add_subparsers(dest="action", required=True)
241
+
242
+ dcr = dcsub.add_parser("create", help="Create a document")
243
+ dcr.add_argument("path", help="Document path")
244
+ dcr.add_argument("content", nargs="?", default="", help="Markdown content")
245
+ dcr.add_argument("--notebook", "-n", help="Notebook ID")
246
+
247
+ dcread = dcsub.add_parser("read", help="Read a document")
248
+ dcread.add_argument("path", help="Document path")
249
+ dcread.add_argument("--notebook", "-n", help="Notebook ID")
250
+
251
+ dcdel = dcsub.add_parser("delete", help="Delete a document")
252
+ dcdel.add_argument("path", nargs="?", default="", help="Document path")
253
+ dcdel.add_argument("--id", help="Delete by block ID")
254
+ dcdel.add_argument("--notebook", "-n", help="Notebook ID")
255
+
256
+ dcex = dcsub.add_parser("export", help="Export document as Markdown")
257
+ dcex.add_argument("path", help="Document path")
258
+ dcex.add_argument("--notebook", "-n", help="Notebook ID")
259
+
260
+ dct = dcsub.add_parser("tree", help="Show document tree")
261
+ dct.add_argument("notebook", nargs="?", default="", help="Notebook ID")
262
+
263
+ dcrn = dcsub.add_parser("rename", help="Rename a document")
264
+ dcrn.add_argument("--id", help="Document block ID (ID-based rename)")
265
+ dcrn.add_argument("--path", help="Document path (path-based rename)")
266
+ dcrn.add_argument("--notebook", "-n", help="Notebook ID (for path-based)")
267
+ dcrn.add_argument("title", help="New document title")
268
+
269
+ dcmv = dcsub.add_parser("move", help="Move document(s) to another location")
270
+ dcmv.add_argument("paths", nargs="+", help="Source document path(s)")
271
+ dcmv.add_argument("--to-notebook", help="Target notebook ID")
272
+ dcmv.add_argument("--to-path", default="/", help="Target folder path (default: /)")
273
+ dcmv.add_argument("--by-id", action="store_true", help="Interpret paths as IDs")
274
+
275
+ dcgp = dcsub.add_parser("get-path", help="Get human-readable or storage path from ID")
276
+ dcgp.add_argument("id", help="Block or document ID")
277
+ dcgp.add_argument("--storage", action="store_true", help="Get storage path instead of hpath")
278
+ dcgp.add_argument("--hpath", action="store_true", help="Get human-readable path (default)")
279
+
280
+ # ───────────────────── FILE ─────────────────────
281
+ fl = sub.add_parser(
282
+ "file",
283
+ help="File operations on SiYuan workspace: get/put/remove/rename/list",
284
+ )
285
+ flsub = fl.add_subparsers(dest="action", required=True)
286
+
287
+ fg = flsub.add_parser("get", help="Get file content from workspace")
288
+ fg.add_argument("path", help="Path under workspace (e.g. /data/assets/foo.png)")
289
+ fg.add_argument("--output", "-o", help="Save to local file instead of stdout")
290
+
291
+ fp = flsub.add_parser("put", help="Upload a file to workspace")
292
+ fp.add_argument("local", help="Local file path")
293
+ fp.add_argument("path", help="Destination path under workspace")
294
+
295
+ fr = flsub.add_parser("remove", help="Remove a file from workspace")
296
+ fr.add_argument("path", help="Path under workspace")
297
+
298
+ frn = flsub.add_parser("rename", help="Rename a file in workspace")
299
+ frn.add_argument("path", help="Current path")
300
+ frn.add_argument("new_path", help="New path")
301
+
302
+ fls = flsub.add_parser("list", help="List files in a workspace directory")
303
+ fls.add_argument("path", help="Directory path under workspace")
304
+
305
+ # ───────────────────── EXPORT (hierarchical) ─────────────────────
306
+ ex2 = sub.add_parser("export2", help="Export: md <id> or resources <paths...>")
307
+ ex2sub = ex2.add_subparsers(dest="action", required=True)
308
+ ex2m = ex2sub.add_parser("md", help="Export document as Markdown by block ID")
309
+ ex2m.add_argument("id", help="Document block ID")
310
+ ex2r = ex2sub.add_parser("resources", help="Export files/folders as zip")
311
+ ex2r.add_argument("paths", nargs="+", help="Path(s) under workspace to export")
312
+ ex2r.add_argument("--name", help="Zip file name (without .zip)")
313
+
314
+ # ───────────────────── KEYRING ─────────────────────
315
+ ks = sub.add_parser("keyring-set", help="Store API token in system keyring")
316
+ ks.add_argument("token", help="API token to store")
317
+ sub.add_parser("keyring-unset", help="Remove API token from system keyring")
318
+
319
+ # ───────────────────── BATCH ─────────────────────
320
+ bt = sub.add_parser("batch", help="Batch operations: create/delete/tag/export")
321
+ btsub = bt.add_subparsers(dest="action", required=True)
322
+
323
+ bc = btsub.add_parser("create", help="Batch create docs from file/stdin")
324
+ bc.add_argument("input", help="Input file path or '-' for stdin")
325
+ bc.add_argument("--notebook", "-n", help="Notebook ID")
326
+
327
+ bd = btsub.add_parser("delete", help="Batch delete docs by path from file/stdin")
328
+ bd.add_argument("input", help="Input file path or '-' for stdin")
329
+ bd.add_argument("--notebook", "-n", help="Notebook ID")
330
+ bd.add_argument(
331
+ "--by-id", action="store_true", help="Input contains block IDs instead of paths"
332
+ )
333
+
334
+ bt2 = btsub.add_parser("tag", help="Batch add/remove tags on blocks from file/stdin")
335
+ bt2.add_argument("input", help="Input file with block IDs, one per line")
336
+ bt2.add_argument("--tag", required=True, help="Tag name (without #)")
337
+ bt2.add_argument(
338
+ "--action", required=True, choices=["add", "remove"], help="Add or remove the tag"
339
+ )
340
+
341
+ be = btsub.add_parser("export", help="Batch export docs by ID from file/stdin")
342
+ be.add_argument("input", help="Input file with block IDs, one per line")
343
+ be.add_argument(
344
+ "--output", "-o", default="./export", help="Output directory (default: ./export)"
345
+ )
346
+
347
+ return p
348
+
349
+
350
+ COMMANDS = {
351
+ "config": cmd_config,
352
+ "list-notebooks": cmd_list_notebooks,
353
+ "create": cmd_create,
354
+ "read": cmd_read,
355
+ "delete": cmd_delete,
356
+ "search": cmd_search,
357
+ "sql": cmd_sql,
358
+ "tree": cmd_tree,
359
+ "config-get": cmd_config_get,
360
+ "stats": cmd_stats,
361
+ "export": cmd_export_doc,
362
+ "tag-list": cmd_tag,
363
+ "tag": cmd_tag,
364
+ "block": cmd_block,
365
+ "asset": cmd_asset,
366
+ "attr": cmd_attr_fn,
367
+ "template": cmd_template,
368
+ "notify": cmd_notify,
369
+ "notebook": cmd_notebook,
370
+ "doc": cmd_doc,
371
+ "file": cmd_file,
372
+ "export2": cmd_export_fn,
373
+ "keyring-set": cmd_keyring_set,
374
+ "keyring-unset": cmd_keyring_unset,
375
+ "batch": cmd_batch,
376
+ }
377
+
378
+
379
+ def setup_logging(verbose: bool = False, log_file: str = "") -> None:
380
+ """Configure logging.
381
+
382
+ Args:
383
+ verbose: If True, set level to DEBUG instead of WARNING.
384
+ log_file: If provided, write logs to this file instead of stderr.
385
+ """
386
+ level = logging.DEBUG if verbose else logging.WARNING
387
+ fmt = logging.Formatter(
388
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
389
+ datefmt="%Y-%m-%d %H:%M:%S",
390
+ )
391
+ root = logging.getLogger("siyuan")
392
+ root.setLevel(level)
393
+
394
+ handler = logging.FileHandler(log_file) if log_file else logging.StreamHandler(sys.stderr)
395
+ handler.setFormatter(fmt)
396
+ root.addHandler(handler)
397
+
398
+
399
+ def main(argv: list[str] | None = None) -> None:
400
+ """Entry point for siyuan CLI."""
401
+ parser = create_parser()
402
+ args = parser.parse_args(argv)
403
+
404
+ set_json_mode(args.json)
405
+ setup_logging(args.verbose, getattr(args, "log_file", ""))
406
+
407
+ try:
408
+ handler = COMMANDS.get(args.command)
409
+ if handler:
410
+ handler(args)
411
+ except SiYuanError as e:
412
+ print(e.friendly(), file=sys.stderr)
413
+ sys.exit(1)
414
+ except Exception as e:
415
+ logger = logging.getLogger("siyuan")
416
+ logger.debug("Unhandled exception", exc_info=True)
417
+ print(f"Unexpected error: {e}", file=sys.stderr)
418
+ sys.exit(1)
419
+
420
+
421
+ if __name__ == "__main__":
422
+ main()
siyuan_cli/client.py ADDED
@@ -0,0 +1,175 @@
1
+ """SiYuan HTTP API client with lazy config and connection reuse."""
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any, Optional
9
+
10
+ from siyuan_cli.config import resolve_base, resolve_token
11
+ from siyuan_cli.exceptions import (
12
+ AuthError,
13
+ ConflictError,
14
+ NetworkError,
15
+ RateLimitError,
16
+ ServerError,
17
+ SiYuanError,
18
+ )
19
+
20
+ logger = logging.getLogger("siyuan")
21
+
22
+ API_TIMEOUT = 15
23
+ MAX_RETRIES = 3
24
+
25
+
26
+ class SiYuanClient:
27
+ """Reusable SiYuan API client with lazy config resolution.
28
+
29
+ Usage:
30
+ client = SiYuanClient()
31
+ result = client.call("notebook/lsNotebooks")
32
+
33
+ Or with explicit config:
34
+ client = SiYuanClient(base_url="http://...", token="...")
35
+ """
36
+
37
+ def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None):
38
+ self._base_url = base_url
39
+ self._token = token
40
+ self._ready = False
41
+
42
+ def _ensure_config(self) -> None:
43
+ """Lazy-load config on first use."""
44
+ if not self._ready:
45
+ if self._base_url is None:
46
+ self._base_url = resolve_base()
47
+ if self._token is None:
48
+ self._token = resolve_token()
49
+ self._ready = True
50
+
51
+ def call(self, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]:
52
+ """Make an API call with retry for transient errors.
53
+
54
+ Args:
55
+ endpoint: API endpoint path (e.g. "notebook/lsNotebooks").
56
+ data: JSON-serializable request body (POSTed).
57
+
58
+ Returns:
59
+ Parsed JSON response dict.
60
+
61
+ Raises:
62
+ AuthError: On 401 responses.
63
+ NetworkError: On connection failures after retries.
64
+ ConflictError: On 409 responses.
65
+ RateLimitError: On 429 responses.
66
+ ServerError: On 5xx responses.
67
+ SiYuanError: On other failures.
68
+ """
69
+ self._ensure_config()
70
+ url = f"{self._base_url}/api/{endpoint}"
71
+ body = json.dumps(data).encode() if data else b"{}"
72
+
73
+ for attempt in range(MAX_RETRIES + 1):
74
+ t0 = time.time()
75
+ try:
76
+ req = urllib.request.Request(
77
+ url,
78
+ data=body,
79
+ method="POST",
80
+ headers={
81
+ "Authorization": f"Token {self._token}",
82
+ "Content-Type": "application/json",
83
+ },
84
+ )
85
+ with urllib.request.urlopen(req, timeout=API_TIMEOUT) as resp:
86
+ elapsed = int((time.time() - t0) * 1000)
87
+ logger.debug("→ %s 200 (%dms)", endpoint, elapsed)
88
+ return json.loads(resp.read())
89
+
90
+ except urllib.error.HTTPError as e:
91
+ elapsed = int((time.time() - t0) * 1000)
92
+ resp_body = e.read().decode(errors="replace")[:200]
93
+ logger.debug("→ %s %d (%dms) %s", endpoint, e.code, elapsed, resp_body[:60])
94
+
95
+ if e.code == 401:
96
+ raise AuthError(
97
+ "Authentication failed. Check your API token.\n"
98
+ " Run: siyuan config set token <your-token>"
99
+ )
100
+ if e.code == 404:
101
+ return {"code": 404, "msg": f"Endpoint not found: {endpoint}"}
102
+ if e.code == 409:
103
+ raise ConflictError(f"Resource conflict: {resp_body[:100]}")
104
+ if e.code == 429:
105
+ raise RateLimitError("Too many requests, please wait")
106
+ if e.code >= 500:
107
+ raise ServerError(f"Server error ({e.code}): {resp_body[:100]}")
108
+ if e.code in (502, 503, 504) and attempt < MAX_RETRIES:
109
+ wait = 2**attempt
110
+ logger.warning(
111
+ "→ %s %d, retry %d/%d in %ds",
112
+ endpoint,
113
+ e.code,
114
+ attempt + 1,
115
+ MAX_RETRIES,
116
+ wait,
117
+ )
118
+ time.sleep(wait)
119
+ continue
120
+ return {"code": e.code, "msg": e.reason, "body": resp_body}
121
+
122
+ except urllib.error.URLError as e:
123
+ reason = str(e.reason) if hasattr(e, "reason") else str(e)
124
+ logger.debug("→ %s NET_ERROR: %s", endpoint, reason)
125
+ if attempt < MAX_RETRIES:
126
+ wait = 2**attempt
127
+ logger.warning(
128
+ "→ %s connection: %s, retry %d/%d in %ds",
129
+ endpoint,
130
+ reason,
131
+ attempt + 1,
132
+ MAX_RETRIES,
133
+ wait,
134
+ )
135
+ time.sleep(wait)
136
+ continue
137
+ raise NetworkError(f"Cannot connect to {self._base_url}: {reason}")
138
+
139
+ except Exception as e:
140
+ logger.debug("→ %s ERROR: %s", endpoint, e)
141
+ if attempt < MAX_RETRIES:
142
+ time.sleep(2**attempt)
143
+ continue
144
+ raise SiYuanError(str(e), "UNKNOWN")
145
+
146
+ raise NetworkError(f"Request to {endpoint} failed after {MAX_RETRIES} retries")
147
+
148
+
149
+ # Module-level singleton for backward compatibility
150
+ _client: Optional[SiYuanClient] = None
151
+
152
+
153
+ def get_client() -> SiYuanClient:
154
+ """Get or create the shared SiYuanClient singleton.
155
+
156
+ Returns:
157
+ A configured SiYuanClient instance (lazy config).
158
+ """
159
+ global _client
160
+ if _client is None:
161
+ _client = SiYuanClient()
162
+ return _client
163
+
164
+
165
+ def api_call(endpoint: str, data: Optional[dict] = None) -> dict[str, Any]:
166
+ """Backward-compatible wrapper — delegates to SiYuanClient singleton.
167
+
168
+ Args:
169
+ endpoint: API endpoint path (e.g. "notebook/lsNotebooks").
170
+ data: JSON-serializable request body (POSTed).
171
+
172
+ Returns:
173
+ Parsed JSON response dict.
174
+ """
175
+ return get_client().call(endpoint, data)
@@ -0,0 +1 @@
1
+ # commands package