splitagent 0.0.3__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.
Files changed (56) hide show
  1. splitagent/__init__.py +8 -0
  2. splitagent/__main__.py +6 -0
  3. splitagent/agents/__init__.py +10 -0
  4. splitagent/agents/base.py +477 -0
  5. splitagent/agents/blue.py +57 -0
  6. splitagent/agents/chat.py +60 -0
  7. splitagent/agents/prompts.py +462 -0
  8. splitagent/agents/red.py +75 -0
  9. splitagent/cli.py +701 -0
  10. splitagent/config.py +697 -0
  11. splitagent/core/__init__.py +19 -0
  12. splitagent/core/bus.py +62 -0
  13. splitagent/core/context.py +587 -0
  14. splitagent/core/context_manager.py +381 -0
  15. splitagent/core/engine.py +424 -0
  16. splitagent/core/models.py +310 -0
  17. splitagent/core/proc.py +73 -0
  18. splitagent/core/sandbox.py +184 -0
  19. splitagent/core/toolbox.py +520 -0
  20. splitagent/core/workspace.py +420 -0
  21. splitagent/desktop/__init__.py +7 -0
  22. splitagent/desktop/api.py +525 -0
  23. splitagent/desktop/app.py +1131 -0
  24. splitagent/desktop/web/app.js +3067 -0
  25. splitagent/desktop/web/assets/Inter.ttf +0 -0
  26. splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  27. splitagent/desktop/web/index.html +760 -0
  28. splitagent/desktop/web/styles.css +1612 -0
  29. splitagent/errors.py +27 -0
  30. splitagent/llm/__init__.py +8 -0
  31. splitagent/llm/client.py +488 -0
  32. splitagent/llm/types.py +172 -0
  33. splitagent/report/__init__.py +9 -0
  34. splitagent/report/cvss.py +93 -0
  35. splitagent/report/generator.py +733 -0
  36. splitagent/tools/__init__.py +8 -0
  37. splitagent/tools/base.py +135 -0
  38. splitagent/tools/defense.py +475 -0
  39. splitagent/tools/exploit.py +318 -0
  40. splitagent/tools/http_pool.py +109 -0
  41. splitagent/tools/knowledge.py +376 -0
  42. splitagent/tools/recon.py +182 -0
  43. splitagent/tools/registry.py +62 -0
  44. splitagent/tools/validate.py +908 -0
  45. splitagent/tools/web.py +386 -0
  46. splitagent/tools/workspace_tools.py +411 -0
  47. splitagent/ui/__init__.py +5 -0
  48. splitagent/ui/app.py +389 -0
  49. splitagent/ui/stream.py +234 -0
  50. splitagent/ui/theme.py +72 -0
  51. splitagent-0.0.3.dist-info/METADATA +987 -0
  52. splitagent-0.0.3.dist-info/RECORD +56 -0
  53. splitagent-0.0.3.dist-info/WHEEL +5 -0
  54. splitagent-0.0.3.dist-info/entry_points.txt +2 -0
  55. splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
  56. splitagent-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,525 @@
1
+ """Python <-> JavaScript bridge for the desktop app.
2
+
3
+ Every method here is callable from the front-end as
4
+ ``window.pywebview.api.<method>(...)`` and must return JSON-serialisable data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import dataclasses
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from splitagent import __version__
16
+ from splitagent.config import (
17
+ PROVIDER_PRESETS,
18
+ LLMSettings,
19
+ ensure_provider,
20
+ global_config_path,
21
+ load_project_config,
22
+ project_config_path,
23
+ save_global_config,
24
+ save_project_config,
25
+ )
26
+ from splitagent.core.sandbox import TARGET_PRESETS
27
+
28
+
29
+ def _project_dict(project: Any) -> dict[str, Any]:
30
+ """The project as JSON, with auth secrets masked before they reach the UI."""
31
+ data = dataclasses.asdict(project)
32
+ auth = getattr(project, "auth", None)
33
+ if auth is not None and "auth" in data:
34
+ data["auth"] = auth.redacted()
35
+ return data
36
+
37
+
38
+ class JsApi:
39
+ """Exposed to the WebView as ``window.pywebview.api``."""
40
+
41
+ def __init__(self, app: Any) -> None:
42
+ self._app = app
43
+
44
+ # -- bootstrap --------------------------------------------------------- #
45
+ def bootstrap(self) -> dict[str, Any]:
46
+ config = self._app.global_config
47
+ project = self._app.project
48
+ return {
49
+ "version": __version__,
50
+ "configured": config.configured,
51
+ "config": {
52
+ "provider": config.llm.provider,
53
+ "protocol": config.llm.protocol,
54
+ "base_url": config.llm.base_url,
55
+ "model": config.llm.model,
56
+ "temperature": config.llm.temperature,
57
+ "max_tokens": config.llm.max_tokens,
58
+ "api_key_set": bool(config.llm.resolved_api_key()),
59
+ },
60
+ "config_path": str(global_config_path()),
61
+ "providers": PROVIDER_PRESETS,
62
+ "project": _project_dict(project),
63
+ "project_path": str(project_config_path()),
64
+ "target_presets": {
65
+ name: {"image": preset["image"], "url": preset["url"]}
66
+ for name, preset in TARGET_PRESETS.items()
67
+ },
68
+ "running": self._app.is_running(),
69
+ "chatting": self._app.is_chatting(),
70
+ "sessions": self._app.list_sessions(),
71
+ "execution": dataclasses.asdict(project.run.execution),
72
+ "ui": {
73
+ "audits_completed": config.ui.audits_completed,
74
+ "show_thinking": config.ui.show_thinking,
75
+ },
76
+ }
77
+
78
+ # -- configuration ----------------------------------------------------- #
79
+ def save_llm_config(self, payload: dict[str, Any]) -> dict[str, Any]:
80
+ config = self._app.global_config
81
+ provider = str(payload.get("provider") or config.llm.provider)
82
+ preset = PROVIDER_PRESETS.get(provider, {})
83
+ provider_cfg = ensure_provider(config, provider)
84
+
85
+ if payload.get("base_url"):
86
+ provider_cfg.base_url = str(payload["base_url"]).strip()
87
+ elif preset.get("base_url"):
88
+ provider_cfg.base_url = preset["base_url"]
89
+ provider_cfg.protocol = preset.get("protocol", provider_cfg.protocol or "openai")
90
+ key = str(payload.get("api_key") or "").strip()
91
+ if key:
92
+ provider_cfg.api_key = key
93
+ provider_cfg.enabled = True
94
+
95
+ if payload.get("temperature") is not None:
96
+ try:
97
+ config.llm.temperature = float(payload["temperature"])
98
+ except (TypeError, ValueError):
99
+ pass
100
+ if payload.get("max_tokens"):
101
+ try:
102
+ config.llm.max_tokens = int(payload["max_tokens"])
103
+ except (TypeError, ValueError):
104
+ pass
105
+
106
+ model = str(payload.get("model") or "").strip()
107
+ if model:
108
+ provider_cfg.models.setdefault(model, model)
109
+ self._app.activate_model(provider, model)
110
+
111
+ config.authorized = True
112
+ path = save_global_config(config)
113
+ return {"ok": True, "path": str(path), "configured": config.configured}
114
+
115
+ # -- providers & model visibility -------------------------------------- #
116
+ # -- interface preferences --------------------------------------------- #
117
+ def set_ui_preference(self, payload: dict[str, Any]) -> dict[str, Any]:
118
+ """Persist a single UI preference (currently ``show_thinking``)."""
119
+ config = self._app.global_config
120
+ if "show_thinking" in (payload or {}):
121
+ config.ui.show_thinking = bool(payload["show_thinking"])
122
+ save_global_config(config)
123
+ return {"ok": True, "ui": dataclasses.asdict(config.ui)}
124
+
125
+ # -- toolbox ----------------------------------------------------------- #
126
+ def toolbox_status(self) -> dict[str, Any]:
127
+ return self._app.toolbox_status()
128
+
129
+ def toolbox_setup(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
130
+ payload = payload or {}
131
+ return self._app.toolbox_setup(
132
+ edition=str(payload.get("edition") or ""),
133
+ build=payload.get("build", True),
134
+ )
135
+
136
+ def toolbox_action(self, action: str, edition: str = "") -> dict[str, Any]:
137
+ return self._app.toolbox_action(action, edition)
138
+
139
+ def toolbox_install(self, payload: dict[str, Any]) -> dict[str, Any]:
140
+ return self._app.toolbox_install(
141
+ str(payload.get("manager") or "apt"), str(payload.get("package") or "")
142
+ )
143
+
144
+ # -- workspace --------------------------------------------------------- #
145
+ def workspace_info(self) -> dict[str, Any]:
146
+ from splitagent.core.workspace import available_installers, workspace_for
147
+
148
+ workspace = workspace_for(self._app.project, base=Path.cwd())
149
+ workspace.ensure()
150
+ return {
151
+ "ok": True,
152
+ "root": str(workspace.root),
153
+ "installers": available_installers(),
154
+ "allow_install": self._app.project.workspace.allow_install,
155
+ "allow_external_tools": self._app.project.workspace.allow_external_tools,
156
+ "stats": workspace.stats(),
157
+ "inventory": workspace.inventory()[:80],
158
+ "instructions": workspace.instructions(self._app.project)[:8000],
159
+ }
160
+
161
+ def workspace_open(self) -> dict[str, Any]:
162
+ info = self.workspace_info()
163
+ return self.open_path(info["root"])
164
+
165
+ def list_providers(self) -> dict[str, Any]:
166
+ return self._app.list_providers()
167
+
168
+ def connect_provider(self, payload: dict[str, Any]) -> dict[str, Any]:
169
+ return self._app.connect_provider(payload or {})
170
+
171
+ def disconnect_provider(self, provider_id: str) -> dict[str, Any]:
172
+ return self._app.disconnect_provider(provider_id)
173
+
174
+ def refresh_provider(self, provider_id: str) -> dict[str, Any]:
175
+ return self._app.refresh_provider(provider_id)
176
+
177
+ def activate_model(self, payload: dict[str, Any]) -> dict[str, Any]:
178
+ return self._app.activate_model(
179
+ str(payload.get("provider") or ""), str(payload.get("model") or "")
180
+ )
181
+
182
+ def set_model_visibility(self, payload: dict[str, Any]) -> dict[str, Any]:
183
+ return self._app.set_model_visibility(
184
+ str(payload.get("provider") or ""),
185
+ str(payload.get("model") or ""),
186
+ bool(payload.get("visible")),
187
+ )
188
+
189
+ def set_provider_visibility(self, payload: dict[str, Any]) -> dict[str, Any]:
190
+ return self._app.set_provider_visibility(
191
+ str(payload.get("provider") or ""), bool(payload.get("visible"))
192
+ )
193
+
194
+ def add_custom_model(self, payload: dict[str, Any]) -> dict[str, Any]:
195
+ return self._app.add_custom_model(
196
+ str(payload.get("provider") or ""),
197
+ str(payload.get("model") or ""),
198
+ str(payload.get("name") or ""),
199
+ )
200
+
201
+ def remove_model(self, payload: dict[str, Any]) -> dict[str, Any]:
202
+ return self._app.remove_model(
203
+ str(payload.get("provider") or ""), str(payload.get("model") or "")
204
+ )
205
+
206
+ def test_connection(self, payload: dict[str, Any]) -> dict[str, Any]:
207
+ from splitagent.llm.client import LLMClient
208
+
209
+ settings = LLMSettings(**vars(self._app.global_config.llm))
210
+ if payload:
211
+ settings.provider = str(payload.get("provider") or settings.provider)
212
+ settings.protocol = PROVIDER_PRESETS.get(settings.provider, {}).get(
213
+ "protocol", settings.protocol
214
+ )
215
+ if payload.get("base_url"):
216
+ settings.base_url = str(payload["base_url"]).strip()
217
+ if payload.get("model"):
218
+ settings.model = str(payload["model"]).strip()
219
+ if payload.get("api_key"):
220
+ settings.api_key = str(payload["api_key"]).strip()
221
+
222
+ async def _probe() -> tuple[bool, str]:
223
+ async with LLMClient(settings) as client:
224
+ return await client.test_connection()
225
+
226
+ try:
227
+ ok, message = asyncio.run(_probe())
228
+ except Exception as exc:
229
+ return {"ok": False, "message": f"{type(exc).__name__}: {exc}"}
230
+ return {"ok": ok, "message": message}
231
+
232
+ # -- project ----------------------------------------------------------- #
233
+ def save_project(self, payload: dict[str, Any]) -> dict[str, Any]:
234
+ project = self._app.project
235
+ target = payload.get("target") or {}
236
+ run = payload.get("run") or {}
237
+ if target.get("kind"):
238
+ project.target.kind = str(target["kind"])
239
+ if "url" in target:
240
+ project.target.url = str(target.get("url") or "")
241
+ if "hosts" in target:
242
+ project.target.hosts = _as_list(target["hosts"])
243
+ if "ports" in target:
244
+ project.target.ports = _as_int_list(target["ports"])
245
+ if "scope" in target:
246
+ project.target.scope = _as_list(target["scope"])
247
+ if "out_of_scope" in target:
248
+ project.target.out_of_scope = _as_list(target["out_of_scope"])
249
+ if "hosts" in target:
250
+ project.target.hosts = _as_list(target["hosts"])
251
+ if run.get("rounds") is not None:
252
+ try:
253
+ project.run.rounds = max(1, int(run["rounds"]))
254
+ except (TypeError, ValueError):
255
+ pass
256
+ if run.get("max_steps") is not None:
257
+ try:
258
+ project.run.max_steps = max(1, int(run["max_steps"]))
259
+ except (TypeError, ValueError):
260
+ pass
261
+ if run.get("safe_mode") is not None:
262
+ project.run.safe_mode = bool(run["safe_mode"])
263
+ if run.get("allow_network") is not None:
264
+ project.run.allow_network = bool(run["allow_network"])
265
+ if run.get("tool_concurrency") is not None:
266
+ try:
267
+ project.run.tool_concurrency = max(1, int(run["tool_concurrency"]))
268
+ except (TypeError, ValueError):
269
+ pass
270
+ if run.get("max_duration_minutes") is not None:
271
+ try:
272
+ project.run.max_duration_minutes = max(0, int(run["max_duration_minutes"]))
273
+ except (TypeError, ValueError):
274
+ pass
275
+ sandbox = run.get("sandbox") or {}
276
+ if sandbox:
277
+ if "enabled" in sandbox:
278
+ project.run.sandbox.enabled = bool(sandbox["enabled"])
279
+ if sandbox.get("image"):
280
+ project.run.sandbox.image = str(sandbox["image"])
281
+ if sandbox.get("port_map"):
282
+ port_map: dict[str, int] = {}
283
+ for key, value in sandbox["port_map"].items():
284
+ try:
285
+ port_map[str(key)] = int(value)
286
+ except (TypeError, ValueError):
287
+ continue # ignore a non-numeric port instead of crashing
288
+ project.run.sandbox.port_map = port_map
289
+ workspace = payload.get("workspace") or {}
290
+ if workspace:
291
+ if "path" in workspace:
292
+ project.workspace.path = str(workspace.get("path") or "")
293
+ for key in ("allow_install", "allow_external_tools"):
294
+ if workspace.get(key) is not None:
295
+ setattr(project.workspace, key, bool(workspace[key]))
296
+ if workspace.get("max_install_seconds") is not None:
297
+ try:
298
+ project.workspace.max_install_seconds = max(
299
+ 30, int(workspace["max_install_seconds"])
300
+ )
301
+ except (TypeError, ValueError):
302
+ pass
303
+ if "instructions" in workspace:
304
+ project.workspace.instructions = str(workspace.get("instructions") or "")
305
+ if "instruction_files" in workspace:
306
+ project.workspace.instruction_files = _as_list(workspace["instruction_files"])
307
+ auth = payload.get("auth") or {}
308
+ if auth:
309
+ if "username" in auth:
310
+ project.auth.username = str(auth.get("username") or "")
311
+ if "password" in auth:
312
+ project.auth.password = str(auth.get("password") or "")
313
+ if "token" in auth:
314
+ project.auth.token = str(auth.get("token") or "")
315
+ if "cookies" in auth:
316
+ project.auth.cookies = str(auth.get("cookies") or "")
317
+ if isinstance(auth.get("headers"), dict):
318
+ project.auth.headers = {str(k): str(v) for k, v in auth["headers"].items() if k}
319
+ if payload.get("name"):
320
+ project.name = str(payload["name"])
321
+
322
+ path = project_config_path()
323
+ if payload.get("path"):
324
+ path = Path(str(payload["path"]))
325
+ save_project_config(project, path)
326
+ return {"ok": True, "path": str(path), "project": _project_dict(project)}
327
+
328
+ def load_project(self, path: str = "") -> dict[str, Any]:
329
+ candidate = Path(path) if path else project_config_path()
330
+ if not candidate.exists():
331
+ return {"ok": False, "error": f"not found: {candidate}"}
332
+ project = load_project_config(candidate)
333
+ self._app.project = project
334
+ return {"ok": True, "path": str(candidate), "project": _project_dict(project)}
335
+
336
+ def choose_project_file(self) -> dict[str, Any]:
337
+ import webview
338
+
339
+ result = self._app.window.create_file_dialog(
340
+ webview.FileDialog.OPEN,
341
+ allow_multiple=False,
342
+ file_types=("SplitAgent config (*.yaml;*.yml)", "All files (*.*)"),
343
+ )
344
+ if not result:
345
+ return {"ok": False}
346
+ return self.load_project(str(result[0]))
347
+
348
+ def pick_scope_document(self) -> dict[str, Any]:
349
+ """Let the operator pick a scope/objectives document for the planner.
350
+
351
+ Reads text formats only (md, txt, json, yaml, csv). The content is
352
+ returned to the UI, which stores it in the brief; it never executes.
353
+ """
354
+ import webview
355
+
356
+ if self._app.window is None:
357
+ return {"ok": False, "error": "window not ready"}
358
+ result = self._app.window.create_file_dialog(
359
+ webview.FileDialog.OPEN,
360
+ allow_multiple=False,
361
+ file_types=(
362
+ "Documents (*.md;*.txt;*.json;*.yaml;*.yml;*.csv)",
363
+ "All files (*.*)",
364
+ ),
365
+ )
366
+ if not result:
367
+ return {"ok": False}
368
+ path = Path(str(result[0]))
369
+ if not path.is_file():
370
+ return {"ok": False, "error": "not a file"}
371
+ try:
372
+ content = path.read_text(encoding="utf-8", errors="replace")
373
+ except OSError as exc:
374
+ return {"ok": False, "error": f"could not read: {exc}"}
375
+ return {
376
+ "ok": True,
377
+ "name": path.name,
378
+ "path": str(path),
379
+ "content": content[:20000],
380
+ "truncated": len(content) > 20000,
381
+ }
382
+
383
+ def apply_target_preset(self, name: str) -> dict[str, Any]:
384
+ preset = TARGET_PRESETS.get(name)
385
+ if not preset:
386
+ return {"ok": False, "error": f"unknown preset '{name}'"}
387
+ project = self._app.project
388
+ project.target.url = preset["url"]
389
+ project.target.ports = [int(p) for p in preset["port_map"]]
390
+ project.run.sandbox.image = preset["image"]
391
+ project.run.sandbox.port_map = preset["port_map"]
392
+ return {"ok": True, "project": _project_dict(project)}
393
+
394
+ # -- audit ------------------------------------------------------------- #
395
+ def start_audit(self, payload: dict[str, Any]) -> dict[str, Any]:
396
+ return self._app.start_audit(payload or {})
397
+
398
+ def stop_audit(self) -> dict[str, Any]:
399
+ return self._app.stop_audit()
400
+
401
+ # -- copilot ----------------------------------------------------------- #
402
+ def chat_send(self, message: str) -> dict[str, Any]:
403
+ return self._app.chat_send(message)
404
+
405
+ def chat_reset(self) -> dict[str, Any]:
406
+ return self._app.chat_reset()
407
+
408
+ def chat_stop(self) -> dict[str, Any]:
409
+ return self._app.chat_stop()
410
+
411
+ def chat_state(self, since: int = 0) -> dict[str, Any]:
412
+ return self._app.chat_state(since)
413
+
414
+ # -- engagement planner ------------------------------------------------- #
415
+ def build_audit_plan(self, brief: dict[str, Any] | None = None) -> dict[str, Any]:
416
+ return self._app.build_audit_plan(brief or {})
417
+
418
+ def brief_chat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
419
+ return self._app.brief_chat(payload or {})
420
+
421
+ def list_models(self) -> dict[str, Any]:
422
+ return self._app.list_models()
423
+
424
+ def list_sessions(self) -> list[dict[str, Any]]:
425
+ return self._app.list_sessions()
426
+
427
+ def load_session(self, session_id: str) -> dict[str, Any]:
428
+ return self._app.load_session(session_id)
429
+
430
+ def export_report(self, formats: list[str] | None = None) -> dict[str, Any]:
431
+ return self._app.export_report(formats)
432
+
433
+ def save_trace(self, payload: str) -> dict[str, Any]:
434
+ """Persist the full client-side replay trace next to the sessions."""
435
+ from datetime import datetime
436
+
437
+ from splitagent.config import config_home
438
+
439
+ try:
440
+ directory = config_home() / "traces"
441
+ directory.mkdir(parents=True, exist_ok=True)
442
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
443
+ path = directory / f"trace-{stamp}.json"
444
+ path.write_text(str(payload), encoding="utf-8")
445
+ except Exception as exc:
446
+ return {"ok": False, "error": str(exc)}
447
+ return {"ok": True, "path": str(path)}
448
+
449
+ # -- diagnostics ------------------------------------------------------- #
450
+ def log_js_error(self, message: str) -> dict[str, Any]:
451
+ from datetime import datetime
452
+
453
+ from splitagent.config import config_home
454
+
455
+ try:
456
+ home = config_home()
457
+ home.mkdir(parents=True, exist_ok=True)
458
+ with (home / "desktop-js.log").open("a", encoding="utf-8") as handle:
459
+ handle.write(f"{datetime.now().isoformat(timespec='seconds')} {message}\n")
460
+ except Exception: # pragma: no cover - best effort
461
+ pass
462
+ return {"ok": True}
463
+
464
+ # -- shell ------------------------------------------------------------- #
465
+ def window_action(self, action: str) -> dict[str, Any]:
466
+ window = self._app.window
467
+ try:
468
+ if action == "minimize":
469
+ window.minimize()
470
+ elif action == "maximize":
471
+ if getattr(self._app, "_maximized", False):
472
+ window.restore()
473
+ self._app._maximized = False
474
+ else:
475
+ window.maximize()
476
+ self._app._maximized = True
477
+ elif action == "close":
478
+ window.destroy()
479
+ elif action == "fullscreen":
480
+ window.toggle_fullscreen()
481
+ except Exception as exc:
482
+ return {"ok": False, "error": str(exc)}
483
+ return {"ok": True}
484
+
485
+ def open_path(self, path: str) -> dict[str, Any]:
486
+ target = Path(path)
487
+ if not target.exists():
488
+ return {"ok": False, "error": "path not found"}
489
+ try:
490
+ os.startfile(str(target)) # type: ignore[attr-defined]
491
+ except Exception as exc:
492
+ return {"ok": False, "error": str(exc)}
493
+ return {"ok": True}
494
+
495
+ def reveal(self, path: str) -> dict[str, Any]:
496
+ target = Path(path)
497
+ try:
498
+ os.startfile(str(target.parent if target.is_file() else target)) # type: ignore[attr-defined]
499
+ except Exception as exc:
500
+ return {"ok": False, "error": str(exc)}
501
+ return {"ok": True}
502
+
503
+ def get_session_state(self) -> dict[str, Any]:
504
+ context = self._app.session
505
+ if context is None:
506
+ return {"ok": False}
507
+ return {"ok": True, "state": context.state.to_dict()}
508
+
509
+
510
+ def _as_list(value: Any) -> list[str]:
511
+ if isinstance(value, list):
512
+ return [str(v).strip() for v in value if str(v).strip()]
513
+ if isinstance(value, str):
514
+ return [part.strip() for part in value.split(",") if part.strip()]
515
+ return []
516
+
517
+
518
+ def _as_int_list(value: Any) -> list[int]:
519
+ result: list[int] = []
520
+ for item in _as_list(value):
521
+ try:
522
+ result.append(int(item))
523
+ except ValueError:
524
+ continue
525
+ return result