meshagent-cli 0.22.2__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.

Potentially problematic release.


This version of meshagent-cli might be problematic. Click here for more details.

Files changed (45) hide show
  1. meshagent/cli/__init__.py +3 -0
  2. meshagent/cli/agent.py +273 -0
  3. meshagent/cli/api_keys.py +102 -0
  4. meshagent/cli/async_typer.py +79 -0
  5. meshagent/cli/auth.py +30 -0
  6. meshagent/cli/auth_async.py +295 -0
  7. meshagent/cli/call.py +215 -0
  8. meshagent/cli/chatbot.py +1983 -0
  9. meshagent/cli/cli.py +187 -0
  10. meshagent/cli/cli_mcp.py +408 -0
  11. meshagent/cli/cli_secrets.py +414 -0
  12. meshagent/cli/common_options.py +47 -0
  13. meshagent/cli/containers.py +725 -0
  14. meshagent/cli/database.py +997 -0
  15. meshagent/cli/developer.py +70 -0
  16. meshagent/cli/exec.py +397 -0
  17. meshagent/cli/helper.py +236 -0
  18. meshagent/cli/helpers.py +185 -0
  19. meshagent/cli/host.py +41 -0
  20. meshagent/cli/mailbot.py +1295 -0
  21. meshagent/cli/mailboxes.py +223 -0
  22. meshagent/cli/meeting_transcriber.py +138 -0
  23. meshagent/cli/messaging.py +157 -0
  24. meshagent/cli/multi.py +357 -0
  25. meshagent/cli/oauth2.py +341 -0
  26. meshagent/cli/participant_token.py +63 -0
  27. meshagent/cli/port.py +70 -0
  28. meshagent/cli/projects.py +105 -0
  29. meshagent/cli/queue.py +91 -0
  30. meshagent/cli/room.py +26 -0
  31. meshagent/cli/rooms.py +214 -0
  32. meshagent/cli/services.py +722 -0
  33. meshagent/cli/sessions.py +26 -0
  34. meshagent/cli/storage.py +813 -0
  35. meshagent/cli/sync.py +434 -0
  36. meshagent/cli/task_runner.py +1317 -0
  37. meshagent/cli/version.py +1 -0
  38. meshagent/cli/voicebot.py +624 -0
  39. meshagent/cli/webhook.py +100 -0
  40. meshagent/cli/worker.py +1403 -0
  41. meshagent_cli-0.22.2.dist-info/METADATA +49 -0
  42. meshagent_cli-0.22.2.dist-info/RECORD +45 -0
  43. meshagent_cli-0.22.2.dist-info/WHEEL +5 -0
  44. meshagent_cli-0.22.2.dist-info/entry_points.txt +2 -0
  45. meshagent_cli-0.22.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,722 @@
1
+ # ---------------------------------------------------------------------------
2
+ # Imports
3
+ # ---------------------------------------------------------------------------
4
+ import typer
5
+ from rich import print
6
+ from typing import Annotated, Optional
7
+ from meshagent.cli.common_options import ProjectIdOption
8
+ from aiohttp import ClientResponseError
9
+ import pathlib
10
+ from meshagent.cli import async_typer
11
+ from meshagent.api.services import well_known_service_path
12
+ from meshagent.api.specs.service import ServiceSpec, ServiceTemplateSpec
13
+ from meshagent.api.keys import parse_api_key
14
+
15
+ import asyncio
16
+ import shlex
17
+
18
+ import os
19
+ import signal
20
+ import atexit
21
+ import ctypes
22
+ import sys
23
+
24
+
25
+ from meshagent.cli.helper import (
26
+ get_client,
27
+ print_json_table,
28
+ resolve_project_id,
29
+ resolve_room,
30
+ resolve_key,
31
+ )
32
+ from meshagent.api import (
33
+ ParticipantToken,
34
+ ApiScope,
35
+ )
36
+ from meshagent.cli.common_options import OutputFormatOption
37
+
38
+ from pydantic import RootModel
39
+ from pydantic_yaml import parse_yaml_raw_as
40
+
41
+
42
+ from meshagent.cli.call import _make_call
43
+
44
+
45
+ app = async_typer.AsyncTyper(help="Manage services for your project")
46
+
47
+
48
+ class ServiceTemplateValues(RootModel[dict[str, str]]):
49
+ pass
50
+
51
+
52
+ def _load_template_values(
53
+ values_file: Optional[str],
54
+ values: Optional[list[str]] = None,
55
+ ) -> dict[str, str]:
56
+ template_values: dict[str, str] = {}
57
+
58
+ if values_file is not None:
59
+ with open(str(pathlib.Path(values_file).expanduser().resolve()), "rb") as f:
60
+ template_values = parse_yaml_raw_as(ServiceTemplateValues, f.read()).root
61
+
62
+ if values:
63
+ for item in values:
64
+ if "=" not in item:
65
+ raise typer.BadParameter("Template values must be key=value")
66
+ key, value = item.split("=", 1)
67
+ if not key:
68
+ raise typer.BadParameter("Template values must include a key")
69
+ template_values[key] = value
70
+
71
+ return template_values
72
+
73
+
74
+ @app.async_command("create")
75
+ async def service_create(
76
+ *,
77
+ project_id: ProjectIdOption,
78
+ file: Annotated[
79
+ str,
80
+ typer.Option("--file", "-f", help="File path to a service definition"),
81
+ ],
82
+ room: Annotated[
83
+ Optional[str],
84
+ typer.Option("--room", help="The name of a room to create the service for"),
85
+ ] = None,
86
+ ):
87
+ """Create a service attached to the project."""
88
+ client = await get_client()
89
+ try:
90
+ project_id = await resolve_project_id(project_id)
91
+
92
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
93
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
94
+
95
+ if spec.id is not None:
96
+ print("[red]id cannot be set when creating a service[/red]")
97
+ raise typer.Exit(code=1)
98
+
99
+ try:
100
+ if room is None:
101
+ new_id = await client.create_service(
102
+ project_id=project_id, service=spec
103
+ )
104
+ else:
105
+ new_id = await client.create_room_service(
106
+ project_id=project_id, service=spec, room_name=room
107
+ )
108
+ except ClientResponseError as exc:
109
+ if exc.status == 409:
110
+ print(f"[red]Service name already in use: {spec.metadata.name}[/red]")
111
+ raise typer.Exit(code=1)
112
+ raise
113
+ else:
114
+ print(f"[green]Created service:[/] {new_id}")
115
+
116
+ finally:
117
+ await client.close()
118
+
119
+
120
+ @app.async_command("update")
121
+ async def service_update(
122
+ *,
123
+ project_id: ProjectIdOption,
124
+ id: Optional[str] = None,
125
+ file: Annotated[
126
+ str,
127
+ typer.Option("--file", "-f", help="File path to a service definition"),
128
+ ],
129
+ create: Annotated[
130
+ Optional[bool],
131
+ typer.Option(
132
+ help="create the service if it does not exist",
133
+ ),
134
+ ] = False,
135
+ room: Annotated[
136
+ Optional[str],
137
+ typer.Option("--room", help="The name of a room to update the service for"),
138
+ ] = None,
139
+ ):
140
+ """Create a service attached to the project."""
141
+ client = await get_client()
142
+ try:
143
+ project_id = await resolve_project_id(project_id)
144
+
145
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
146
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
147
+ if spec.id is not None:
148
+ id = spec.id
149
+
150
+ try:
151
+ if id is None:
152
+ if room is None:
153
+ services = await client.list_services(project_id=project_id)
154
+ else:
155
+ services = await client.list_room_services(
156
+ project_id=project_id, room_name=room
157
+ )
158
+
159
+ for s in services:
160
+ if s.metadata.name == spec.metadata.name:
161
+ id = s.id
162
+
163
+ if id is None and not create:
164
+ print("[red]pass a service id or specify --create[/red]")
165
+ raise typer.Exit(code=1)
166
+
167
+ if id is None:
168
+ if room is None:
169
+ id = await client.create_service(
170
+ project_id=project_id, service=spec
171
+ )
172
+ else:
173
+ id = await client.create_room_service(
174
+ project_id=project_id, service=spec, room_name=room
175
+ )
176
+
177
+ else:
178
+ spec.id = id
179
+ if room is None:
180
+ await client.update_service(
181
+ project_id=project_id, service_id=id, service=spec
182
+ )
183
+ else:
184
+ await client.update_room_service(
185
+ project_id=project_id,
186
+ service_id=id,
187
+ service=spec,
188
+ room_name=room,
189
+ )
190
+
191
+ except ClientResponseError as exc:
192
+ if exc.status == 409:
193
+ print(f"[red]Service name already in use: {spec.metadata.name}[/red]")
194
+ raise typer.Exit(code=1)
195
+ raise
196
+ else:
197
+ print(f"[green]Updated service:[/] {id}")
198
+
199
+ finally:
200
+ await client.close()
201
+
202
+
203
+ @app.async_command("validate")
204
+ async def service_validate(
205
+ *,
206
+ file: Annotated[
207
+ str,
208
+ typer.Option("--file", "-f", help="File path to a service definition"),
209
+ ],
210
+ ):
211
+ """Validate a service spec from a YAML file."""
212
+ try:
213
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
214
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
215
+ except Exception as exc:
216
+ print(f"[red]Invalid service spec: {exc}[/red]")
217
+ raise typer.Exit(code=1)
218
+
219
+ print(f"[green]Service spec is valid:[/] {spec.metadata.name}")
220
+
221
+
222
+ @app.async_command("create-template")
223
+ async def service_create_template(
224
+ *,
225
+ project_id: ProjectIdOption,
226
+ file: Annotated[
227
+ str,
228
+ typer.Option("--file", "-f", help="File path to a service template"),
229
+ ],
230
+ values: Annotated[
231
+ Optional[str],
232
+ typer.Option("--values-file", help="File path to template values"),
233
+ ] = None,
234
+ value: Annotated[
235
+ Optional[list[str]],
236
+ typer.Option(
237
+ "--value",
238
+ "-v",
239
+ help="Template value override (key=value)",
240
+ ),
241
+ ] = None,
242
+ room: Annotated[
243
+ Optional[str],
244
+ typer.Option("--room", help="The name of a room to create the service for"),
245
+ ] = None,
246
+ ):
247
+ """Create a service from a ServiceTemplate spec."""
248
+ client = await get_client()
249
+ try:
250
+ project_id = await resolve_project_id(project_id)
251
+
252
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
253
+ template = parse_yaml_raw_as(ServiceTemplateSpec, f.read())
254
+
255
+ template_values = _load_template_values(values, value)
256
+
257
+ try:
258
+ if room is None:
259
+ service = await client.create_service_from_template(
260
+ project_id=project_id, template=template, values=template_values
261
+ )
262
+ else:
263
+ service = await client.create_room_service_from_template(
264
+ project_id=project_id,
265
+ template=template,
266
+ values=template_values,
267
+ room_name=room,
268
+ )
269
+ except ClientResponseError as exc:
270
+ if exc.status == 409:
271
+ print(
272
+ f"[red]Service name already in use: {template.metadata.name}[/red]"
273
+ )
274
+ raise typer.Exit(code=1)
275
+ raise
276
+ else:
277
+ service_id = service.id or ""
278
+ print(f"[green]Created service:[/] {service_id}")
279
+
280
+ finally:
281
+ await client.close()
282
+
283
+
284
+ @app.async_command("update-template")
285
+ async def service_update_template(
286
+ *,
287
+ project_id: ProjectIdOption,
288
+ id: Optional[str] = None,
289
+ file: Annotated[
290
+ str,
291
+ typer.Option("--file", "-f", help="File path to a service template"),
292
+ ],
293
+ values: Annotated[
294
+ Optional[str],
295
+ typer.Option("--values-file", help="File path to template values"),
296
+ ] = None,
297
+ value: Annotated[
298
+ Optional[list[str]],
299
+ typer.Option(
300
+ "--value",
301
+ "-v",
302
+ help="Template value override (key=value)",
303
+ ),
304
+ ] = None,
305
+ create: Annotated[
306
+ Optional[bool],
307
+ typer.Option(
308
+ help="create the service if it does not exist",
309
+ ),
310
+ ] = False,
311
+ room: Annotated[
312
+ Optional[str],
313
+ typer.Option("--room", help="The name of a room to update the service for"),
314
+ ] = None,
315
+ ):
316
+ """Update a service using a ServiceTemplate spec."""
317
+ client = await get_client()
318
+ try:
319
+ project_id = await resolve_project_id(project_id)
320
+
321
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
322
+ template = parse_yaml_raw_as(ServiceTemplateSpec, f.read())
323
+
324
+ template_values = _load_template_values(values, value)
325
+
326
+ try:
327
+ if id is None:
328
+ if room is None:
329
+ services = await client.list_services(project_id=project_id)
330
+ else:
331
+ services = await client.list_room_services(
332
+ project_id=project_id, room_name=room
333
+ )
334
+
335
+ for s in services:
336
+ if s.metadata.name == template.metadata.name:
337
+ id = s.id
338
+
339
+ if id is None and not create:
340
+ print("[red]pass a service id or specify --create[/red]")
341
+ raise typer.Exit(code=1)
342
+
343
+ if id is None:
344
+ if room is None:
345
+ service = await client.create_service_from_template(
346
+ project_id=project_id,
347
+ template=template,
348
+ values=template_values,
349
+ )
350
+ else:
351
+ service = await client.create_room_service_from_template(
352
+ project_id=project_id,
353
+ template=template,
354
+ values=template_values,
355
+ room_name=room,
356
+ )
357
+ id = service.id
358
+ else:
359
+ if room is None:
360
+ service = await client.update_service_from_template(
361
+ project_id=project_id,
362
+ service_id=id,
363
+ template=template,
364
+ values=template_values,
365
+ )
366
+ else:
367
+ service = await client.update_room_service_from_template(
368
+ project_id=project_id,
369
+ service_id=id,
370
+ template=template,
371
+ values=template_values,
372
+ room_name=room,
373
+ )
374
+ if service.id is not None:
375
+ id = service.id
376
+
377
+ except ClientResponseError as exc:
378
+ if exc.status == 409:
379
+ print(
380
+ f"[red]Service name already in use: {template.metadata.name}[/red]"
381
+ )
382
+ raise typer.Exit(code=1)
383
+ raise
384
+ else:
385
+ print(f"[green]Updated service:[/] {id}")
386
+
387
+ finally:
388
+ await client.close()
389
+
390
+
391
+ @app.async_command("validate-template")
392
+ async def service_validate_template(
393
+ *,
394
+ file: Annotated[
395
+ str,
396
+ typer.Option("--file", "-f", help="File path to a service template"),
397
+ ],
398
+ ):
399
+ """Validate a service template from a YAML file."""
400
+ try:
401
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
402
+ template = parse_yaml_raw_as(ServiceTemplateSpec, f.read())
403
+ except Exception as exc:
404
+ print(f"[red]Invalid service template: {exc}[/red]")
405
+ raise typer.Exit(code=1)
406
+
407
+ print(f"[green]Service template is valid:[/] {template.metadata.name}")
408
+
409
+
410
+ @app.async_command("run")
411
+ async def service_run(
412
+ *,
413
+ project_id: ProjectIdOption,
414
+ command: str,
415
+ port: Annotated[
416
+ int,
417
+ typer.Option(
418
+ "--port",
419
+ "-p",
420
+ help=(
421
+ "a port number to run the agent on (will set MESHAGENT_PORT environment variable when launching the service)"
422
+ ),
423
+ ),
424
+ ] = None,
425
+ room: Annotated[
426
+ Optional[str],
427
+ typer.Option(
428
+ help="A room name to test the service in (must not be currently running)"
429
+ ),
430
+ ] = None,
431
+ key: Annotated[
432
+ str,
433
+ typer.Option("--key", help="an api key to sign the token with"),
434
+ ] = None,
435
+ ):
436
+ key = await resolve_key(project_id=project_id, key=key)
437
+
438
+ if port is None:
439
+ import socket
440
+
441
+ def find_free_port():
442
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
443
+ s.bind(("", 0)) # Bind to a free port provided by the host.
444
+ s.listen(1)
445
+ return s.getsockname()[1]
446
+
447
+ port = find_free_port()
448
+
449
+ my_client = await get_client()
450
+ try:
451
+ project_id = await resolve_project_id(project_id)
452
+ room = resolve_room(room)
453
+
454
+ if room is None:
455
+ print("[bold red]Room was not set[/bold red]")
456
+ raise typer.Exit(1)
457
+
458
+ try:
459
+ parsed_key = parse_api_key(key)
460
+ token = ParticipantToken(
461
+ name="cli", project_id=project_id, api_key_id=parsed_key.id
462
+ )
463
+ token.add_api_grant(ApiScope.agent_default())
464
+ token.add_role_grant("user")
465
+ token.add_room_grant(room)
466
+
467
+ print("[bold green]Connecting to room...[/bold green]")
468
+
469
+ run_tasks = []
470
+
471
+ async def run_service(port: int):
472
+ if command.endswith(".py"):
473
+ code, output = await _run_process(
474
+ cmd=shlex.split("python3 " + command),
475
+ log=True,
476
+ env={**os.environ, "MESHAGENT_PORT": str(port)},
477
+ )
478
+
479
+ elif command.endswith(".dart"):
480
+ code, output = await _run_process(
481
+ cmd=shlex.split("dart run " + command),
482
+ log=True,
483
+ env={**os.environ, "MESHAGENT_PORT": str(port)},
484
+ )
485
+
486
+ else:
487
+ code, output = await _run_process(
488
+ cmd=shlex.split(command),
489
+ log=True,
490
+ env={**os.environ, "MESHAGENT_PORT": str(port)},
491
+ )
492
+
493
+ if code != 0:
494
+ print(f"[red]{output}[/red]")
495
+
496
+ run_tasks.append(asyncio.create_task(run_service(port)))
497
+
498
+ async def get_spec(port: int, attempt=0) -> ServiceSpec:
499
+ import aiohttp
500
+
501
+ max_attempts = 10
502
+
503
+ url = f"http://localhost:{port}{well_known_service_path}"
504
+
505
+ async with aiohttp.ClientSession() as session:
506
+ try:
507
+ res = await session.get(url=url)
508
+ res.raise_for_status()
509
+
510
+ spec_json = await res.json()
511
+
512
+ return ServiceSpec.model_validate(spec_json)
513
+
514
+ except Exception:
515
+ if attempt < max_attempts:
516
+ backoff = 0.1 * pow(2, attempt)
517
+ await asyncio.sleep(backoff)
518
+ return await get_spec(port, attempt + 1)
519
+ else:
520
+ print("[red]unable to read service spec[/red]")
521
+ raise typer.Exit(-1)
522
+
523
+ print(f"getting spec {port}", flush=True)
524
+ spec = await get_spec(port)
525
+
526
+ sys.stdout.write("\n")
527
+
528
+ for p in spec.ports:
529
+ print(f"[bold green]Connecting port {p.num}...[/bold green]")
530
+
531
+ for endpoint in p.endpoints:
532
+ print(
533
+ f"[bold green]Connecting endpoint {endpoint.path}...[/bold green]"
534
+ )
535
+
536
+ run_tasks.append(
537
+ asyncio.create_task(
538
+ _make_call(
539
+ room=room,
540
+ project_id=project_id,
541
+ participant_name=endpoint.meshagent.identity,
542
+ url=f"http://localhost:{p.num}{endpoint.path}",
543
+ arguments={},
544
+ key=key,
545
+ permissions=endpoint.meshagent.api,
546
+ )
547
+ )
548
+ )
549
+
550
+ await asyncio.gather(*run_tasks)
551
+
552
+ except ClientResponseError as exc:
553
+ if exc.status == 409:
554
+ print(f"[red]Room already in use: {room}[/red]")
555
+ raise typer.Exit(code=1)
556
+ raise
557
+
558
+ except Exception as e:
559
+ print(f"[red]{e}[/red]")
560
+ raise typer.Exit(code=1)
561
+
562
+ finally:
563
+ await my_client.close()
564
+
565
+
566
+ @app.async_command("show")
567
+ async def service_show(
568
+ *,
569
+ project_id: ProjectIdOption,
570
+ service_id: Annotated[str, typer.Argument(help="ID of the service to show")],
571
+ ):
572
+ """Show a services for the project."""
573
+ client = await get_client()
574
+ try:
575
+ project_id = await resolve_project_id(project_id)
576
+ service = await client.get_service(
577
+ project_id=project_id, service_id=service_id
578
+ ) # → List[Service]
579
+ print(service.model_dump(mode="json"))
580
+ finally:
581
+ await client.close()
582
+
583
+
584
+ @app.async_command("list")
585
+ async def service_list(
586
+ *,
587
+ project_id: ProjectIdOption,
588
+ o: OutputFormatOption = "table",
589
+ room: Annotated[
590
+ Optional[str],
591
+ typer.Option("--room", help="The name of a room to list the services for"),
592
+ ] = None,
593
+ ):
594
+ """List all services for the project."""
595
+ client = await get_client()
596
+ try:
597
+ project_id = await resolve_project_id(project_id)
598
+ services: list[ServiceSpec] = (
599
+ (await client.list_services(project_id=project_id))
600
+ if room is None
601
+ else (
602
+ await client.list_room_services(project_id=project_id, room_name=room)
603
+ )
604
+ )
605
+
606
+ if o == "json":
607
+ print({"services": [svc.model_dump(mode="json") for svc in services]})
608
+ else:
609
+ print_json_table(
610
+ [
611
+ {
612
+ "id": svc.id,
613
+ "name": svc.metadata.name,
614
+ "image": svc.container.image
615
+ if svc.container is not None
616
+ else None,
617
+ }
618
+ for svc in services
619
+ ],
620
+ "id",
621
+ "name",
622
+ "image",
623
+ )
624
+ finally:
625
+ await client.close()
626
+
627
+
628
+ @app.async_command("delete")
629
+ async def service_delete(
630
+ *,
631
+ project_id: ProjectIdOption,
632
+ service_id: Annotated[str, typer.Argument(help="ID of the service to delete")],
633
+ ):
634
+ """Delete a service."""
635
+ client = await get_client()
636
+ try:
637
+ project_id = await resolve_project_id(project_id)
638
+ await client.delete_service(project_id=project_id, service_id=service_id)
639
+ print(f"[green]Service {service_id} deleted.[/]")
640
+ finally:
641
+ await client.close()
642
+
643
+
644
+ async def _run_process(
645
+ cmd: list[str], cwd=None, env=None, timeout: float | None = None, log: bool = False
646
+ ) -> tuple[int, str]:
647
+ """
648
+ Spawn a process, stream its output line-by-line as it runs, and return its exit code.
649
+ stdout+stderr are merged to preserve ordering.
650
+ """
651
+ proc = await asyncio.create_subprocess_exec(
652
+ *cmd,
653
+ cwd=cwd,
654
+ env=env,
655
+ stdout=asyncio.subprocess.PIPE,
656
+ stderr=asyncio.subprocess.STDOUT,
657
+ preexec_fn=_preexec_fn,
658
+ )
659
+
660
+ _spawned.append(proc)
661
+
662
+ output = []
663
+ try:
664
+ # Stream lines as they appear
665
+ assert proc.stdout is not None
666
+ while True:
667
+ line = (
668
+ await asyncio.wait_for(proc.stdout.readline(), timeout=timeout)
669
+ if timeout
670
+ else await proc.stdout.readline()
671
+ )
672
+ if not line:
673
+ break
674
+ ln = line.decode(errors="replace").rstrip()
675
+ if log:
676
+ print(ln, flush=True)
677
+ output.append(ln) # or send to a logger/queue
678
+
679
+ return await proc.wait(), "".join(output)
680
+ except asyncio.TimeoutError:
681
+ # Graceful shutdown on timeout
682
+ proc.terminate()
683
+ try:
684
+ await asyncio.wait_for(proc.wait(), 5)
685
+ except asyncio.TimeoutError:
686
+ proc.kill()
687
+ await proc.wait()
688
+ raise
689
+
690
+
691
+ # Linux-only: send SIGTERM to child if parent dies
692
+ _PRCTL_AVAILABLE = sys.platform.startswith("linux")
693
+ if _PRCTL_AVAILABLE:
694
+ libc = ctypes.CDLL("libc.so.6", use_errno=True)
695
+ PR_SET_PDEATHSIG = 1
696
+
697
+
698
+ def _preexec_fn():
699
+ # Make child the leader of a new session/process group
700
+ os.setsid()
701
+ # On Linux, ensure child gets SIGTERM if parent dies unexpectedly
702
+ if _PRCTL_AVAILABLE:
703
+ if libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM) != 0:
704
+ err = ctypes.get_errno()
705
+ raise OSError(err, "prctl(PR_SET_PDEATHSIG) failed")
706
+
707
+
708
+ _spawned = []
709
+
710
+
711
+ def _cleanup():
712
+ # Kill each child's process group (created by setsid)
713
+ for p in _spawned:
714
+ try:
715
+ os.killpg(p.pid, signal.SIGTERM)
716
+ except ProcessLookupError:
717
+ pass
718
+ except Exception:
719
+ pass
720
+
721
+
722
+ atexit.register(_cleanup)