zettcode-im 0.4.3__tar.gz → 0.4.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode-im
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Chat bridges: a WeChat conversation that drives one ZettCode daemon, and a place for the next chat app
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Author: Chang-LeHung
@@ -47,8 +47,10 @@ class DaemonGuard:
47
47
  workspace: The directory the daemon works in.
48
48
  state: Where the session id lives between runs; the guard writes it when it learns one.
49
49
  model: The model by name, or ``None`` for the first configured.
50
- effort: The reasoning level, or ``None`` for the model's own.
51
- permission: The shell level the daemon gets: ``deny``, ``safe`` or ``full``.
50
+ effort: The reasoning level, or ``None`` for the middle of the ones the model takes — providers do not all
51
+ accept every level, and a daemon nobody is watching should reason as much as its model will by default.
52
+ permission: The shell level the daemon gets: ``deny``, ``safe`` or ``full``. ``full`` by default, the same
53
+ one ``zettcode daemon spawn`` starts with: there is nobody to answer a sandbox's questions from a chat.
52
54
  name: The label recorded beside the daemon, so a reader finds it in ``zettcode daemon list``.
53
55
  waits: The waits between failed starts, in order; the last one repeats.
54
56
  """
@@ -57,7 +59,7 @@ class DaemonGuard:
57
59
  state: BridgeState
58
60
  model: str | None = None
59
61
  effort: str | None = None
60
- permission: str = "safe"
62
+ permission: str = "full"
61
63
  name: str = "chat bridge"
62
64
  waits: tuple[float, ...] = ()
63
65
  _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
@@ -119,12 +121,12 @@ class DaemonGuard:
119
121
 
120
122
  async def _start(self, session: str | None) -> str:
121
123
  """Start a daemon, resuming ``session`` when there is one, and return its session id."""
122
- from zettcode.sdk.daemons import spawn_daemon
124
+ from zettcode.sdk.daemons import default_effort, spawn_daemon
123
125
 
124
126
  info = await spawn_daemon(
125
127
  Path(self.workspace),
126
128
  model=self.model,
127
- effort=self.effort,
129
+ effort=self.effort or default_effort(self.model) or None,
128
130
  resume=session,
129
131
  name=self.name,
130
132
  permission=self.permission,
@@ -47,13 +47,21 @@ def build_parser() -> argparse.ArgumentParser:
47
47
  commands = parser.add_subparsers(dest="command")
48
48
  for name, help_line in (
49
49
  ("login", "scan a QR code and start the background process (the default)"),
50
+ ("start", "start the background process with the stored login, without scanning"),
51
+ ("set", "change the settings the daemon is started with, and apply them now"),
50
52
  ("run", "run the bridge in this terminal (what the background process does)"),
51
53
  ("status", "say whether a bridge is running and what it is doing"),
52
54
  ("stop", "stop the background process"),
53
55
  ("logs", "print the end of the background process's log"),
54
56
  ):
55
57
  sub = commands.add_parser(name, help=help_line, description=help_line)
56
- _common(sub, login=name == "login", run=name == "run")
58
+ _common(sub, login=name in ("login", "set"), run=name == "run")
59
+ if name == "set":
60
+ sub.add_argument(
61
+ "--keep-daemon",
62
+ action="store_true",
63
+ help="leave a running daemon alone; by default it is stopped so the new settings take effect",
64
+ )
57
65
  if name == "login":
58
66
  sub.add_argument(
59
67
  "--login-timeout",
@@ -85,7 +93,7 @@ def _common(sub: argparse.ArgumentParser, *, login: bool, run: bool) -> None:
85
93
  "--permission",
86
94
  choices=("deny", "safe", "full"),
87
95
  default=None,
88
- help="the daemon's shell level (default: the one already stored, or safe)",
96
+ help="the daemon's shell level (default: the one already stored, or full)",
89
97
  )
90
98
  sub.add_argument(
91
99
  "--allow",
@@ -114,6 +122,10 @@ def main(argv: list[str] | None = None) -> int:
114
122
  state_dir: Path = arguments.state_dir.expanduser()
115
123
  if arguments.command == "login":
116
124
  return asyncio.run(_login(arguments, state_dir))
125
+ if arguments.command == "start":
126
+ return _start(state_dir)
127
+ if arguments.command == "set":
128
+ return _set(arguments, state_dir)
117
129
  if arguments.command == "run":
118
130
  return asyncio.run(_run(arguments, state_dir))
119
131
  if arguments.command == "status":
@@ -186,22 +198,12 @@ async def _login(arguments: argparse.Namespace, state_dir: Path) -> int:
186
198
  # A login renews the connection; the settings are what the reader decided and outlive it. A flag replaces its
187
199
  # setting, and a flag that was not given leaves the stored one alone — a re-scan that forgot `--allow` used to
188
200
  # wipe the allow-list and turn every message into a refusal.
189
- if arguments.workspace is not None:
190
- state.workspace = str(Path(arguments.workspace).expanduser().resolve())
191
- elif not state.workspace:
192
- state.workspace = str(Path.cwd())
193
- if arguments.model is not None:
194
- state.model = arguments.model
195
- if arguments.effort is not None:
196
- state.effort = arguments.effort
197
- if arguments.permission is not None:
198
- state.permission = arguments.permission
199
- elif not state.permission:
200
- state.permission = "safe"
201
- if arguments.allow is not None:
202
- state.allow = list(arguments.allow)
203
- if arguments.allow_all is not None:
204
- state.allow_all = bool(arguments.allow_all)
201
+ previous_workspace = state.workspace
202
+ previous_session = state.session_id
203
+ _apply_settings(state, arguments)
204
+ if state.workspace != previous_workspace:
205
+ # A session lives in one workspace: its id in another would resume a conversation that is not there.
206
+ state.session_id = ""
205
207
  if not state.token():
206
208
  # A login without a token is a bridge that cannot send or receive, and a background process that says
207
209
  # nothing about it. Better to say so here, where the reader is still watching the phone.
@@ -216,6 +218,24 @@ async def _login(arguments: argparse.Namespace, state_dir: Path) -> int:
216
218
  print("the old bridge did not stop, so the new login was not saved. Stop it by hand and run again.")
217
219
  return 3
218
220
  state.save()
221
+ if previous_session and state.workspace != previous_workspace:
222
+ # The daemon of the old workspace is nobody's now: the bridge will start a new one here, and leaving the old
223
+ # one running is a session a reader never asked to keep. Best effort: a busy one is reported, not waited on.
224
+ from zettcode.sdk.daemons import stop_daemon
225
+ from zettcode.sdk.errors import SessionNotFoundError
226
+
227
+ try:
228
+ said = await stop_daemon(previous_session, keep=True)
229
+ except SessionNotFoundError:
230
+ said = "no daemon was running for the old workspace"
231
+ except Exception as error: # noqa: BLE001 - the login is finished either way
232
+ said = f"the daemon of the old workspace was left running ({error})"
233
+ print(f"daemon {said}")
234
+ elif previous_session and (arguments.model or arguments.effort or arguments.permission):
235
+ print(
236
+ "note a daemon that is already running keeps the settings it was started with; "
237
+ "`zettcode-weixin set` applies new ones now"
238
+ )
219
239
  try:
220
240
  pid = worker.start(command("--state-dir", str(state_dir)))
221
241
  except RuntimeError as error:
@@ -280,7 +300,7 @@ async def _run(arguments: argparse.Namespace, state_dir: Path) -> int:
280
300
  state=state,
281
301
  model=state.model or None,
282
302
  effort=state.effort or None,
283
- permission=state.permission or "safe",
303
+ permission=state.permission or "full",
284
304
  name="weixin bridge",
285
305
  )
286
306
  await link.start()
@@ -327,6 +347,206 @@ def _bridge_id(state_dir: Path) -> str:
327
347
  return "im-weixin-" + hashlib.sha1(str(state_dir.resolve()).encode("utf-8")).hexdigest()[:8]
328
348
 
329
349
 
350
+ # -- settings ------------------------------------------------------------------------------------------
351
+ def _apply_settings(state: object, arguments: argparse.Namespace) -> None:
352
+ """Take the flags that were given and leave the ones that were not: a re-scan renews the connection, not the
353
+ decisions.
354
+
355
+ ``None`` is how the parser says "not given" for every one of these, which is why ``--allow`` defaults to
356
+ ``None`` rather than to an empty list: an empty list means "nobody is allowed", and forgetting a flag must not
357
+ mean that.
358
+ """
359
+ if arguments.workspace is not None:
360
+ state.workspace = str(Path(arguments.workspace).expanduser().resolve()) # type: ignore[attr-defined]
361
+ elif not state.workspace: # type: ignore[attr-defined]
362
+ state.workspace = str(Path.cwd()) # type: ignore[attr-defined]
363
+ if arguments.model is not None:
364
+ state.model = arguments.model # type: ignore[attr-defined]
365
+ if arguments.effort is not None:
366
+ state.effort = arguments.effort # type: ignore[attr-defined]
367
+ if arguments.permission is not None:
368
+ state.permission = arguments.permission # type: ignore[attr-defined]
369
+ elif not state.permission: # type: ignore[attr-defined]
370
+ state.permission = "full" # type: ignore[attr-defined]
371
+ if arguments.allow is not None:
372
+ state.allow = list(arguments.allow) # type: ignore[attr-defined]
373
+ if arguments.allow_all is not None:
374
+ state.allow_all = bool(arguments.allow_all) # type: ignore[attr-defined]
375
+
376
+
377
+ def _start(state_dir: Path) -> int:
378
+ """Start the background process with the login that is already stored: what a scan ends with, without the scan."""
379
+ from ..state import BridgeState
380
+
381
+ state = BridgeState.load(state_dir / "state.json")
382
+ if not state.logged_in:
383
+ print(f"no login in {state_dir}: run `zettcode-weixin` to scan one")
384
+ return 2
385
+ worker = Worker(state_dir)
386
+ if worker.running():
387
+ print(f"the bridge is already running (pid {worker.pid()})")
388
+ return 0
389
+ try:
390
+ pid = worker.start(command("--state-dir", str(state_dir)))
391
+ except RuntimeError as error:
392
+ print(f"the bridge did not start: {error}")
393
+ return 3
394
+ print(
395
+ f"bridge running (pid {pid}); allows: {', '.join(state.allow) or ('anyone' if state.allow_all else 'nobody')}"
396
+ )
397
+ return 0
398
+
399
+
400
+ def _setting_problem(arguments: argparse.Namespace, state: object) -> str:
401
+ """Return what is wrong with the settings asked for, or an empty string.
402
+
403
+ Checked before anything is stopped or written: a level or a model the machine does not have would otherwise be
404
+ saved and fail on the next message, with the old daemon already gone and a chat waiting for an answer.
405
+ """
406
+ from zettcode.sdk.models import list_models
407
+
408
+ if arguments.workspace is not None and not Path(arguments.workspace).expanduser().is_dir():
409
+ return f"no such directory: {arguments.workspace}"
410
+ try:
411
+ models = list(list_models())
412
+ except (OSError, ValueError) as error:
413
+ return f"the model configuration cannot be read: {error}"
414
+ wanted = arguments.model if arguments.model is not None else getattr(state, "model", "")
415
+ chosen = None
416
+ if wanted:
417
+ matched = [info for info in models if info.name == wanted or info.model == wanted]
418
+ if len(matched) != 1:
419
+ return f"no model named {wanted!r}; `zettcode daemon model` lists the ones there are"
420
+ chosen = matched[0]
421
+ elif models:
422
+ chosen = models[0]
423
+ if arguments.effort is not None and chosen is not None:
424
+ levels = [str(level) for level in chosen.efforts]
425
+ if arguments.effort not in levels:
426
+ return f"{chosen.name} takes {', '.join(levels) or 'no level'}, not {arguments.effort!r}"
427
+ return ""
428
+
429
+
430
+ def _set(arguments: argparse.Namespace, state_dir: Path) -> int:
431
+ """Write the settings a daemon is started with, and put them in force now.
432
+
433
+ Two things keep the old values alive if this only wrote the file: a running daemon was started with the old
434
+ level, and the background process read the file once at its own start. So the daemon is stopped (the bridge
435
+ starts it again with the stored settings on the next message) and the process is restarted.
436
+
437
+ The daemon is stopped *before* the file is written, and a daemon that is busy stops the whole command: it would
438
+ otherwise keep running with the old settings while the file describes new ones, and the bridge would find it
439
+ alive and never start the one the reader asked for.
440
+ """
441
+ import asyncio
442
+
443
+ from zettcode.sdk.daemons import stop_daemon
444
+ from zettcode.sdk.errors import ConfigurationError, SessionNotFoundError
445
+
446
+ from ..state import BridgeState
447
+
448
+ state = BridgeState.load(state_dir / "state.json")
449
+ if not state.logged_in:
450
+ print(f"no login in {state_dir}: run `zettcode-weixin` to scan one")
451
+ return 2
452
+ problem = _setting_problem(arguments, state)
453
+ if problem:
454
+ print(f"not changed: {problem}")
455
+ return 2
456
+
457
+ worker = Worker(state_dir)
458
+ was_running = worker.running()
459
+ if was_running:
460
+ print(worker.stop())
461
+ if worker.running():
462
+ # It would read the old file and write its own copy back over the new one.
463
+ print("not changed: the bridge did not stop; stop it by hand and run the command again")
464
+ return 3
465
+ if state.session_id and not arguments.keep_daemon:
466
+ try:
467
+ said = asyncio.run(stop_daemon(state.session_id, keep=True))
468
+ except SessionNotFoundError:
469
+ said = "no daemon was running for it"
470
+ except ConfigurationError as error:
471
+ print(f"not changed: the daemon is still doing what it was: {error}")
472
+ if was_running:
473
+ _restart_worker(worker, state_dir)
474
+ return 3
475
+ except Exception as error: # noqa: BLE001 - any other failure is still a stop that did not happen
476
+ print(f"not changed: the daemon could not be stopped: {error}")
477
+ if was_running:
478
+ _restart_worker(worker, state_dir)
479
+ return 3
480
+ print(f"daemon {said}")
481
+
482
+ previous_workspace = state.workspace
483
+ _apply_settings(state, arguments)
484
+ if state.workspace != previous_workspace:
485
+ # A session lives in one workspace; carrying its id over would resume a conversation that is not there.
486
+ state.session_id = ""
487
+ state.chats.clear()
488
+ dropped = _drop_incompatible_effort(state)
489
+ state.save()
490
+ print(f"workspace {state.workspace or Path.cwd()}")
491
+ print(f"model {state.model or '(the first configured)'}")
492
+ print(f"effort {state.effort or '(the middle of what the model takes)'}")
493
+ print(f"shell {state.permission}")
494
+ if dropped:
495
+ print(f"note {dropped}")
496
+ if state.allow_all:
497
+ print("allows anyone who can message the bot")
498
+ else:
499
+ print(f"allows {', '.join(state.allow) or 'nobody'}")
500
+
501
+ if was_running:
502
+ pid = _restart_worker(worker, state_dir)
503
+ if pid is None:
504
+ return 3
505
+ print(f"bridge running (pid {pid})")
506
+ else:
507
+ print("bridge not running; `zettcode-weixin start` reads the new settings")
508
+ return 0
509
+
510
+
511
+ def _drop_incompatible_effort(state: object) -> str:
512
+ """Clear a stored level the model it now runs on does not take, and say so; return an empty string otherwise.
513
+
514
+ A level belongs to a model. Keeping ``high`` after moving to a model that stops at ``low`` would fail at the
515
+ next start, with the old daemon already gone — so the level goes, and the middle of the new model's list
516
+ applies instead.
517
+ """
518
+ from zettcode.sdk.models import list_models
519
+
520
+ effort = getattr(state, "effort", "")
521
+ if not effort:
522
+ return ""
523
+ try:
524
+ models = list(list_models())
525
+ except (OSError, ValueError):
526
+ return ""
527
+ wanted = getattr(state, "model", "")
528
+ chosen = next((info for info in models if wanted and (info.name == wanted or info.model == wanted)), None)
529
+ if chosen is None:
530
+ chosen = models[0] if models else None
531
+ if chosen is None:
532
+ return ""
533
+ levels = [str(level) for level in chosen.efforts]
534
+ if effort in levels:
535
+ return ""
536
+ state.effort = "" # type: ignore[attr-defined]
537
+ return f"the level {effort!r} is not one {chosen.name} takes ({', '.join(levels) or 'none'}), so it is unset"
538
+
539
+
540
+ def _restart_worker(worker: Worker, state_dir: Path) -> int | None:
541
+ """Start the background process again and return its pid, or say why it did not start."""
542
+ try:
543
+ pid = worker.start(command("--state-dir", str(state_dir)))
544
+ except RuntimeError as error:
545
+ print(f"the bridge did not start again: {error}")
546
+ return None
547
+ return pid
548
+
549
+
330
550
  def _on_signals(stop: object) -> None:
331
551
  """Stop the bridge on ctrl-c and on the signal a stop sends, whatever this platform offers."""
332
552
  loop = asyncio.get_running_loop()
File without changes
File without changes