rubber-ducky 1.3.0__py3-none-any.whl → 1.4.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.
ducky/ducky.py CHANGED
@@ -4,7 +4,10 @@ import argparse
4
4
  import asyncio
5
5
  import json
6
6
  import os
7
+ import re
8
+ import shlex
7
9
  import sys
10
+ import signal
8
11
  from dataclasses import dataclass
9
12
  from datetime import UTC, datetime
10
13
  from rich.console import Console
@@ -20,6 +23,10 @@ class Crumb:
20
23
  type: str
21
24
  enabled: bool
22
25
  description: str | None = None
26
+ poll: bool = False
27
+ poll_type: str | None = None # "interval" or "continuous"
28
+ poll_interval: int = 2
29
+ poll_prompt: str | None = None
23
30
 
24
31
 
25
32
  from contextlib import nullcontext
@@ -79,61 +86,93 @@ def ensure_history_dir() -> Path:
79
86
 
80
87
 
81
88
  def load_crumbs() -> Dict[str, Crumb]:
82
- """Populate the global ``CRUMBS`` dictionary from the ``CRUMBS_DIR``.
89
+ """Populate the global ``CRUMBS`` dictionary from both default and user crumbs.
83
90
 
84
91
  Each crumb is expected to be a directory containing an ``info.txt`` and a
85
92
  script file matching the ``type`` field (``shell`` → ``*.sh``).
93
+
94
+ Default crumbs are loaded from the package directory first, then user crumbs
95
+ are loaded from ``~/.ducky/crumbs/`` and can override default crumbs if they
96
+ have the same name.
86
97
  """
87
98
 
88
99
  global CRUMBS
89
100
  CRUMBS.clear()
90
- if not CRUMBS_DIR.exists():
91
- return CRUMBS
92
101
 
93
- for crumb_dir in CRUMBS_DIR.iterdir():
94
- if not crumb_dir.is_dir():
95
- continue
96
- info_path = crumb_dir / "info.txt"
97
- if not info_path.is_file():
98
- continue
99
- # Parse key: value pairs
100
- meta = {}
101
- for line in info_path.read_text(encoding="utf-8").splitlines():
102
- if ":" not in line:
102
+ # Helper function to load crumbs from a directory
103
+ def _load_from_dir(dir_path: Path) -> None:
104
+ if not dir_path.exists():
105
+ return
106
+
107
+ for crumb_dir in dir_path.iterdir():
108
+ if not crumb_dir.is_dir():
103
109
  continue
104
- key, val = line.split(":", 1)
105
- meta[key.strip()] = val.strip()
106
- name = meta.get("name", crumb_dir.name)
107
- ctype = meta.get("type", "shell")
108
- description = meta.get("description")
109
- # Find script file: look for executable in the directory
110
- script_path: Path | None = None
111
- if ctype == "shell":
112
- # Prefer a file named <name>.sh if present
113
- candidate = crumb_dir / f"{name}.sh"
114
- if candidate.is_file() and os.access(candidate, os.X_OK):
115
- script_path = candidate
116
- else:
117
- # Fallback: first .sh in dir
118
- for p in crumb_dir.glob("*.sh"):
119
- if os.access(p, os.X_OK):
120
- script_path = p
121
- break
122
- # Default to first file if script not found
123
- if script_path is None:
124
- files = list(crumb_dir.iterdir())
125
- if files:
126
- script_path = files[0]
127
- if script_path is None:
128
- continue
129
- crumb = Crumb(
130
- name=name,
131
- path=script_path,
132
- type=ctype,
133
- enabled=False,
134
- description=description,
135
- )
136
- CRUMBS[name] = crumb
110
+ info_path = crumb_dir / "info.txt"
111
+ if not info_path.is_file():
112
+ continue
113
+ # Parse key: value pairs
114
+ meta = {}
115
+ for line in info_path.read_text(encoding="utf-8").splitlines():
116
+ if ":" not in line:
117
+ continue
118
+ key, val = line.split(":", 1)
119
+ meta[key.strip()] = val.strip()
120
+ name = meta.get("name", crumb_dir.name)
121
+ ctype = meta.get("type", "shell")
122
+ description = meta.get("description")
123
+ poll = meta.get("poll", "").lower() == "true"
124
+ poll_type = meta.get("poll_type")
125
+ poll_interval = int(meta.get("poll_interval", 2))
126
+ poll_prompt = meta.get("poll_prompt")
127
+ # Find script file: look for executable in the directory
128
+ script_path: Path | None = None
129
+ if ctype == "shell":
130
+ # Prefer a file named <name>.sh if present
131
+ candidate = crumb_dir / f"{name}.sh"
132
+ if candidate.is_file() and os.access(candidate, os.X_OK):
133
+ script_path = candidate
134
+ else:
135
+ # Fallback: first .sh in dir
136
+ for p in crumb_dir.glob("*.sh"):
137
+ if os.access(p, os.X_OK):
138
+ script_path = p
139
+ break
140
+ # Default to first file if script not found
141
+ if script_path is None:
142
+ files = list(crumb_dir.iterdir())
143
+ if files:
144
+ script_path = files[0]
145
+ if script_path is None:
146
+ continue
147
+ crumb = Crumb(
148
+ name=name,
149
+ path=script_path,
150
+ type=ctype,
151
+ enabled=False,
152
+ description=description,
153
+ poll=poll,
154
+ poll_type=poll_type,
155
+ poll_interval=poll_interval,
156
+ poll_prompt=poll_prompt,
157
+ )
158
+ CRUMBS[name] = crumb
159
+
160
+ # Try to load from package directory (where ducky is installed)
161
+ try:
162
+ # Try to locate the crumbs directory relative to the ducky package
163
+ import ducky
164
+ # Get the directory containing the ducky package
165
+ ducky_dir = Path(ducky.__file__).parent
166
+ # Check if crumbs exists in the same directory as ducky package
167
+ default_crumbs_dir = ducky_dir.parent / "crumbs"
168
+ if default_crumbs_dir.exists():
169
+ _load_from_dir(default_crumbs_dir)
170
+ except Exception:
171
+ # If package directory loading fails, continue without default crumbs
172
+ pass
173
+
174
+ # Load user crumbs (these can override default crumbs with the same name)
175
+ _load_from_dir(CRUMBS_DIR)
137
176
 
138
177
  return CRUMBS
139
178
 
@@ -293,7 +332,7 @@ class RubberDuck:
293
332
  self.messages.insert(0, {"role": "system", "content": self.system_prompt})
294
333
 
295
334
  async def send_prompt(
296
- self, prompt: str | None = None, code: str | None = None
335
+ self, prompt: str | None = None, code: str | None = None, command_mode: bool | None = None
297
336
  ) -> AssistantResult:
298
337
  user_content = (prompt or "").strip()
299
338
 
@@ -305,7 +344,10 @@ class RubberDuck:
305
344
  if self.quick and user_content:
306
345
  user_content += ". Return a command and be extremely concise"
307
346
 
308
- if self.command_mode:
347
+ # Use provided command_mode, or fall back to self.command_mode
348
+ effective_command_mode = command_mode if command_mode is not None else self.command_mode
349
+
350
+ if effective_command_mode:
309
351
  instruction = (
310
352
  "Return a single bash command that accomplishes the task. Unless user wants something els"
311
353
  "Do not include explanations or formatting other than the command itself."
@@ -336,7 +378,7 @@ class RubberDuck:
336
378
  if thinking:
337
379
  self.last_thinking = thinking
338
380
 
339
- command = self._extract_command(content) if self.command_mode else None
381
+ command = self._extract_command(content) if effective_command_mode else None
340
382
 
341
383
  return AssistantResult(content=content, command=command, thinking=thinking)
342
384
 
@@ -454,6 +496,7 @@ class InlineInterface:
454
496
  self.pending_command: str | None = None
455
497
  self.session: PromptSession | None = None
456
498
  self.selected_model: str | None = None
499
+ self.running_polling: bool = False
457
500
 
458
501
  if (
459
502
  PromptSession is not None
@@ -623,6 +666,18 @@ class InlineInterface:
623
666
  await self._show_help()
624
667
  return
625
668
 
669
+ if stripped.lower() == "/crumbs":
670
+ await self._show_crumbs()
671
+ return
672
+
673
+ if stripped.lower() == "/stop-poll":
674
+ await self._stop_polling()
675
+ return
676
+
677
+ if stripped.startswith("/poll"):
678
+ await self._handle_poll_command(stripped)
679
+ return
680
+
626
681
  if stripped.startswith("!"):
627
682
  command = stripped[1:].strip()
628
683
  await run_shell_and_print(
@@ -677,6 +732,7 @@ class InlineInterface:
677
732
 
678
733
  commands = [
679
734
  ("[bold]/help[/bold]", "Show this help message"),
735
+ ("[bold]/crumbs[/bold]", "List all available crumbs"),
680
736
  ("[bold]/model[/bold]", "Select a model interactively (local or cloud)"),
681
737
  (
682
738
  "[bold]/local[/bold]",
@@ -687,6 +743,22 @@ class InlineInterface:
687
743
  "[bold]/clear[/bold] or [bold]/reset[/bold]",
688
744
  "Clear conversation history",
689
745
  ),
746
+ (
747
+ "[bold]/poll <crumb>[/bold]",
748
+ "Start polling session for a crumb",
749
+ ),
750
+ (
751
+ "[bold]/poll <crumb> -i 5[/bold]",
752
+ "Start polling with 5s interval",
753
+ ),
754
+ (
755
+ "[bold]/poll <crumb> -p <text>[/bold]",
756
+ "Start polling with custom prompt",
757
+ ),
758
+ (
759
+ "[bold]/stop-poll[/bold]",
760
+ "Stop current polling session",
761
+ ),
690
762
  (
691
763
  "[bold]/run[/bold]",
692
764
  "Re-run the last suggested command",
@@ -702,16 +774,140 @@ class InlineInterface:
702
774
  ]
703
775
 
704
776
  for command, description in commands:
705
- console.print(f"{command:<30} {description}")
777
+ console.print(f"{command:<45} {description}")
706
778
 
707
779
  console.print()
708
780
 
781
+ async def _show_crumbs(self) -> None:
782
+ """Display all available crumbs."""
783
+ crumbs = self.assistant.crumbs
784
+
785
+ if not crumbs:
786
+ console.print("No crumbs available.", style="yellow")
787
+ return
788
+
789
+ console.print("\nAvailable Crumbs", style="bold blue")
790
+ console.print("===============", style="bold blue")
791
+ console.print()
792
+
793
+ # Group crumbs by source (default vs user)
794
+ default_crumbs = []
795
+ user_crumbs = []
796
+
797
+ for name, crumb in sorted(crumbs.items()):
798
+ path_str = str(crumb.path)
799
+ if "crumbs/" in path_str and "/.ducky/crumbs/" not in path_str:
800
+ default_crumbs.append((name, crumb))
801
+ else:
802
+ user_crumbs.append((name, crumb))
803
+
804
+ # Show default crumbs
805
+ if default_crumbs:
806
+ console.print("[bold cyan]Default Crumbs (shipped with ducky):[/bold cyan]", style="cyan")
807
+ for name, crumb in default_crumbs:
808
+ description = crumb.description or "No description"
809
+ # Check if it has polling enabled
810
+ poll_info = " [dim](polling enabled)[/dim]" if crumb.poll else ""
811
+ console.print(f" [bold]{name}[/bold]{poll_info}: {description}")
812
+ console.print()
813
+
814
+ # Show user crumbs
815
+ if user_crumbs:
816
+ console.print("[bold green]Your Crumbs:[/bold green]", style="green")
817
+ for name, crumb in user_crumbs:
818
+ description = crumb.description or "No description"
819
+ # Check if it has polling enabled
820
+ poll_info = " [dim](polling enabled)[/dim]" if crumb.poll else ""
821
+ console.print(f" [bold]{name}[/bold]{poll_info}: {description}")
822
+ console.print()
823
+
824
+ console.print(f"[dim]Total: {len(crumbs)} crumbs available[/dim]")
825
+
709
826
  async def _clear_history(self) -> None:
710
827
  self.assistant.clear_history()
711
828
  self.last_command = None
712
829
  self.pending_command = None
713
830
  self.last_shell_output = None
714
831
 
832
+ async def _handle_poll_command(self, command: str) -> None:
833
+ """Handle /poll command with optional arguments."""
834
+ if self.running_polling:
835
+ console.print(
836
+ "A polling session is already running. Use /stop-poll first.",
837
+ style="yellow",
838
+ )
839
+ return
840
+
841
+ # Parse command: /poll <crumb> [-i interval] [-p prompt]
842
+ parts = command.split()
843
+ if len(parts) < 2:
844
+ console.print("Usage: /poll <crumb-name> [-i interval] [-p prompt]", style="yellow")
845
+ console.print("Example: /poll log-crumb -i 5", style="dim")
846
+ return
847
+
848
+ crumb_name = parts[1]
849
+ interval = None
850
+ prompt = None
851
+
852
+ # Parse optional arguments
853
+ i = 2
854
+ while i < len(parts):
855
+ if parts[i] in {"-i", "--interval"} and i + 1 < len(parts):
856
+ try:
857
+ interval = int(parts[i + 1])
858
+ i += 2
859
+ except ValueError:
860
+ console.print("Invalid interval value.", style="red")
861
+ return
862
+ elif parts[i] in {"-p", "--prompt"} and i + 1 < len(parts):
863
+ prompt = " ".join(parts[i + 1:])
864
+ break
865
+ else:
866
+ i += 1
867
+
868
+ if crumb_name not in self.assistant.crumbs:
869
+ console.print(f"Crumb '{crumb_name}' not found.", style="red")
870
+ console.print(
871
+ f"Available crumbs: {', '.join(self.assistant.crumbs.keys())}",
872
+ style="yellow",
873
+ )
874
+ return
875
+
876
+ crumb = self.assistant.crumbs[crumb_name]
877
+
878
+ if not crumb.poll:
879
+ console.print(
880
+ f"Warning: Crumb '{crumb_name}' doesn't have polling enabled.",
881
+ style="yellow",
882
+ )
883
+ console.print("Proceeding anyway with default polling mode.", style="dim")
884
+
885
+ console.print("Starting polling session... Press Ctrl+C to stop.", style="bold cyan")
886
+
887
+ self.running_polling = True
888
+ try:
889
+ await polling_session(
890
+ self.assistant,
891
+ crumb,
892
+ interval=interval,
893
+ prompt_override=prompt,
894
+ )
895
+ finally:
896
+ self.running_polling = False
897
+ console.print("Polling stopped. Returning to interactive mode.", style="green")
898
+
899
+ async def _stop_polling(self) -> None:
900
+ """Handle /stop-poll command."""
901
+ if not self.running_polling:
902
+ console.print("No polling session is currently running.", style="yellow")
903
+ return
904
+
905
+ # This is handled by the signal handler in polling_session
906
+ console.print(
907
+ "Stopping polling... (press Ctrl+C if it doesn't stop automatically)",
908
+ style="yellow",
909
+ )
910
+
715
911
  async def _select_model(self, host: str = "") -> None:
716
912
  """Show available models and allow user to select one with arrow keys."""
717
913
  if PromptSession is None or KeyBindings is None:
@@ -881,6 +1077,174 @@ async def interactive_session(
881
1077
  await ui.run()
882
1078
 
883
1079
 
1080
+ async def polling_session(
1081
+ rubber_ducky: RubberDuck,
1082
+ crumb: Crumb,
1083
+ interval: int | None = None,
1084
+ prompt_override: str | None = None,
1085
+ ) -> None:
1086
+ """Run a polling session for a crumb.
1087
+
1088
+ For interval polling: Runs the crumb repeatedly at the specified interval.
1089
+ For continuous polling: Runs the crumb once in background and analyzes output periodically.
1090
+
1091
+ Args:
1092
+ rubber_ducky: The RubberDuck assistant
1093
+ crumb: The crumb to poll
1094
+ interval: Override the crumb's default interval
1095
+ prompt_override: Override the crumb's default poll prompt
1096
+ """
1097
+ # Use overrides or crumb defaults
1098
+ poll_interval = interval or crumb.poll_interval
1099
+ poll_prompt = prompt_override or crumb.poll_prompt or "Analyze this output."
1100
+ poll_type = crumb.poll_type or "interval"
1101
+
1102
+ if not crumb.poll_prompt and not prompt_override:
1103
+ console.print("Warning: No poll prompt configured for this crumb.", style="yellow")
1104
+ console.print(f"Using default prompt: '{poll_prompt}'", style="dim")
1105
+
1106
+ if poll_type == "continuous":
1107
+ await _continuous_polling(rubber_ducky, crumb, poll_interval, poll_prompt)
1108
+ else:
1109
+ await _interval_polling(rubber_ducky, crumb, poll_interval, poll_prompt)
1110
+
1111
+
1112
+ async def _interval_polling(
1113
+ rubber_ducky: RubberDuck,
1114
+ crumb: Crumb,
1115
+ interval: int,
1116
+ poll_prompt: str,
1117
+ ) -> None:
1118
+ """Poll by running crumb script at intervals and analyzing with AI."""
1119
+ console.print(
1120
+ f"\nStarting interval polling for '{crumb.name}' (interval: {interval}s)...\n"
1121
+ f"Poll prompt: {poll_prompt}\n"
1122
+ f"Press Ctrl+C to stop polling.\n",
1123
+ style="bold cyan",
1124
+ )
1125
+
1126
+ shutdown_event = asyncio.Event()
1127
+
1128
+ def signal_handler():
1129
+ console.print("\nStopping polling...", style="yellow")
1130
+ shutdown_event.set()
1131
+
1132
+ loop = asyncio.get_running_loop()
1133
+ loop.add_signal_handler(signal.SIGINT, signal_handler)
1134
+
1135
+ try:
1136
+ while not shutdown_event.is_set():
1137
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
1138
+ console.print(f"\n[{timestamp}] Polling {crumb.name}...", style="bold blue")
1139
+
1140
+ # Run crumb script
1141
+ result = await rubber_ducky.run_shell_command(str(crumb.path))
1142
+
1143
+ script_output = result.stdout if result.stdout.strip() else "(no output)"
1144
+ if result.stderr.strip():
1145
+ script_output += f"\n[stderr]\n{result.stderr}"
1146
+
1147
+ console.print(f"Script output: {len(result.stdout)} bytes\n", style="dim")
1148
+
1149
+ # Send to AI with prompt
1150
+ full_prompt = f"{poll_prompt}\n\nScript output:\n{script_output}"
1151
+ ai_result = await rubber_ducky.send_prompt(prompt=full_prompt, command_mode=False)
1152
+
1153
+ console.print(f"AI: {ai_result.content}", style="green", highlight=False)
1154
+
1155
+ # Wait for next interval
1156
+ await asyncio.sleep(interval)
1157
+ except asyncio.CancelledError:
1158
+ console.print("\nPolling stopped.", style="yellow")
1159
+ finally:
1160
+ loop.remove_signal_handler(signal.SIGINT)
1161
+
1162
+
1163
+ async def _continuous_polling(
1164
+ rubber_ducky: RubberDuck,
1165
+ crumb: Crumb,
1166
+ interval: int,
1167
+ poll_prompt: str,
1168
+ ) -> None:
1169
+ """Poll by running crumb continuously and analyzing output periodically."""
1170
+ console.print(
1171
+ f"\nStarting continuous polling for '{crumb.name}' (analysis interval: {interval}s)...\n"
1172
+ f"Poll prompt: {poll_prompt}\n"
1173
+ f"Press Ctrl+C to stop polling.\n",
1174
+ style="bold cyan",
1175
+ )
1176
+
1177
+ shutdown_event = asyncio.Event()
1178
+ accumulated_output: list[str] = []
1179
+
1180
+ def signal_handler():
1181
+ console.print("\nStopping polling...", style="yellow")
1182
+ shutdown_event.set()
1183
+
1184
+ loop = asyncio.get_running_loop()
1185
+ loop.add_signal_handler(signal.SIGINT, signal_handler)
1186
+
1187
+ # Start crumb process
1188
+ process = None
1189
+ try:
1190
+ process = await asyncio.create_subprocess_shell(
1191
+ str(crumb.path),
1192
+ stdout=asyncio.subprocess.PIPE,
1193
+ stderr=asyncio.subprocess.PIPE,
1194
+ )
1195
+
1196
+ async def read_stream(stream, name: str):
1197
+ """Read output from stream non-blocking."""
1198
+ while not shutdown_event.is_set():
1199
+ try:
1200
+ line = await asyncio.wait_for(stream.readline(), timeout=0.1)
1201
+ if not line:
1202
+ break
1203
+ line_text = line.decode(errors="replace")
1204
+ accumulated_output.append(line_text)
1205
+ except asyncio.TimeoutError:
1206
+ continue
1207
+ except Exception:
1208
+ break
1209
+
1210
+ # Read both stdout and stderr
1211
+ asyncio.create_task(read_stream(process.stdout, "stdout"))
1212
+ asyncio.create_task(read_stream(process.stderr, "stderr"))
1213
+
1214
+ # Main polling loop - analyze accumulated output
1215
+ last_analyzed_length = 0
1216
+
1217
+ while not shutdown_event.is_set():
1218
+ await asyncio.sleep(interval)
1219
+
1220
+ # Only analyze if there's new output
1221
+ current_length = len(accumulated_output)
1222
+ if current_length > last_analyzed_length:
1223
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
1224
+ console.print(f"\n[{timestamp}] Polling {crumb.name}...", style="bold blue")
1225
+
1226
+ # Get new output since last analysis
1227
+ new_output = "".join(accumulated_output[last_analyzed_length:])
1228
+
1229
+ console.print(f"New script output: {len(new_output)} bytes\n", style="dim")
1230
+
1231
+ # Send to AI with prompt
1232
+ full_prompt = f"{poll_prompt}\n\nScript output:\n{new_output}"
1233
+ ai_result = await rubber_ducky.send_prompt(prompt=full_prompt, command_mode=False)
1234
+
1235
+ console.print(f"AI: {ai_result.content}", style="green", highlight=False)
1236
+
1237
+ last_analyzed_length = current_length
1238
+
1239
+ except asyncio.CancelledError:
1240
+ console.print("\nPolling stopped.", style="yellow")
1241
+ finally:
1242
+ if process:
1243
+ process.kill()
1244
+ await process.wait()
1245
+ loop.remove_signal_handler(signal.SIGINT)
1246
+
1247
+
884
1248
  async def ducky() -> None:
885
1249
  parser = argparse.ArgumentParser()
886
1250
  parser.add_argument(
@@ -893,6 +1257,24 @@ async def ducky() -> None:
893
1257
  action="store_true",
894
1258
  help="Run DuckY offline using a local Ollama instance on localhost:11434",
895
1259
  )
1260
+ parser.add_argument(
1261
+ "--poll",
1262
+ help="Start polling mode for the specified crumb",
1263
+ default=None,
1264
+ )
1265
+ parser.add_argument(
1266
+ "--interval",
1267
+ "-i",
1268
+ type=int,
1269
+ help="Override crumb's polling interval in seconds",
1270
+ default=None,
1271
+ )
1272
+ parser.add_argument(
1273
+ "--prompt",
1274
+ "-p",
1275
+ help="Override crumb's polling prompt",
1276
+ default=None,
1277
+ )
896
1278
  args, _ = parser.parse_known_args()
897
1279
 
898
1280
  ensure_history_dir()
@@ -946,6 +1328,33 @@ async def ducky() -> None:
946
1328
  console.print("No input received from stdin.", style="yellow")
947
1329
  return
948
1330
 
1331
+ # Handle polling mode
1332
+ if args.poll:
1333
+ crumb_name = args.poll
1334
+ if crumb_name not in rubber_ducky.crumbs:
1335
+ console.print(f"Crumb '{crumb_name}' not found.", style="red")
1336
+ console.print(
1337
+ f"Available crumbs: {', '.join(rubber_ducky.crumbs.keys())}",
1338
+ style="yellow",
1339
+ )
1340
+ return
1341
+
1342
+ crumb = rubber_ducky.crumbs[crumb_name]
1343
+ if not crumb.poll:
1344
+ console.print(
1345
+ f"Warning: Crumb '{crumb_name}' doesn't have polling enabled.",
1346
+ style="yellow",
1347
+ )
1348
+ console.print("Proceeding anyway with default polling mode.", style="dim")
1349
+
1350
+ await polling_session(
1351
+ rubber_ducky,
1352
+ crumb,
1353
+ interval=args.interval,
1354
+ prompt_override=args.prompt,
1355
+ )
1356
+ return
1357
+
949
1358
  await interactive_session(rubber_ducky, logger=logger, code=code)
950
1359
 
951
1360