loom-code 0.1.1__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 (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. loom_code-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,522 @@
1
+ """User-extensibility discovery for loom-code (the ``.loom`` folder).
2
+
3
+ loom-code mirrors Claude Code's ``.claude`` folder: users drop in
4
+ their own skills, subagents, and hooks and loom-code picks them up.
5
+ There are THREE layers, lowest priority first:
6
+
7
+ bundled ``loom_code/skills/`` (shipped)
8
+ user ``~/.loom-code/{skills,agents}/`` (your global config)
9
+ project ``<repo>/.loom/{skills,agents}/`` (this repo only)
10
+
11
+ The user + project layers also carry a ``settings.toml`` declaring
12
+ hooks.
13
+
14
+ :func:`discover` scans the user + project layers and returns one
15
+ :class:`Extensions` bundle. ``build_agent`` calls it once and threads
16
+ the three results into the existing wiring:
17
+
18
+ * **skills** — appended to the bundled skill list passed to every
19
+ agent. The framework's ``SkillRegistry`` does last-source-wins by
20
+ name, so we append user *then* project (project wins on collision).
21
+ * **agents** — parsed into :class:`AgentSpec` and merged into the
22
+ ``Team.supervisor`` worker roster (project wins on name collision).
23
+ * **hooks** — parsed into :class:`HookSpec`. Tool-lifecycle hooks
24
+ (PreToolUse/PostToolUse/Stop) become framework hooks on the
25
+ tool-executing agents; REPL-lifecycle hooks (UserPromptSubmit/
26
+ SessionStart/SessionEnd) the REPL fires itself. Hooks are
27
+ **additive** across scopes — a project cannot disable your personal
28
+ hooks.
29
+
30
+ Parsing is dependency-free on purpose: loom-code does not depend on
31
+ pyyaml, so frontmatter is split by a tiny in-house parser
32
+ (:func:`_parse_frontmatter`) that handles the handful of fields a
33
+ subagent declares, and ``settings.toml`` is read with stdlib
34
+ ``tomllib``.
35
+
36
+ This module is pure discovery + parsing. It does NOT execute hooks,
37
+ construct Agents, or prompt for trust — those live with the code that
38
+ consumes the specs (``workers.py`` for agents, ``hooks.py`` for the
39
+ shim + trust gate).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import re
45
+ import tomllib
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import TYPE_CHECKING
49
+
50
+ if TYPE_CHECKING:
51
+ # Type-only import: the annotation is a string under
52
+ # ``from __future__ import annotations``, so this never loads the
53
+ # ``mcp`` extra at runtime. The actual spec is constructed lazily in
54
+ # ``_discover_mcp`` where the import is genuinely needed.
55
+ from loomflow.mcp import MCPServerSpec
56
+
57
+ # The two scope roots. ``.loom`` (project) matches ``agent.LOOM_DIR``;
58
+ # ``.loom-code`` (user, with the hyphen) matches
59
+ # ``credentials._CREDENTIALS_DIR`` — the hyphen is the deliberate
60
+ # "this is GLOBAL config, not a project" differentiator.
61
+ PROJECT_DIRNAME = ".loom"
62
+ USER_DIRNAME = ".loom-code"
63
+
64
+ # Hook events loom-code recognises. Tool-lifecycle events map onto the
65
+ # framework's HookRegistry / stop_hooks; REPL-lifecycle events the REPL
66
+ # fires directly (the framework has no hook point for them).
67
+ TOOL_HOOK_EVENTS = frozenset({"PreToolUse", "PostToolUse"})
68
+ STOP_HOOK_EVENTS = frozenset({"Stop"})
69
+ REPL_HOOK_EVENTS = frozenset(
70
+ {"UserPromptSubmit", "SessionStart", "SessionEnd"}
71
+ )
72
+ KNOWN_HOOK_EVENTS = TOOL_HOOK_EVENTS | STOP_HOOK_EVENTS | REPL_HOOK_EVENTS
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class AgentSpec:
77
+ """A user-authored subagent parsed from ``<scope>/agents/<name>.md``.
78
+
79
+ The markdown body is the subagent's system prompt; the frontmatter
80
+ carries the routing contract (``name`` + ``description`` — the
81
+ supervisor delegates by description) and optional ``model`` /
82
+ ``tools`` overrides.
83
+
84
+ ``tools`` is the list of builtin tool names the subagent may use
85
+ (``read``/``write``/``edit``/``multi_edit``/``grep``/``find``/``ls``/
86
+ ``bash``/``web_fetch``). Empty means "unspecified" — the wiring
87
+ applies a read-only default rather than handing a stranger's spec
88
+ write access implicitly.
89
+ """
90
+
91
+ name: str
92
+ description: str
93
+ system_prompt: str
94
+ model: str | None = None
95
+ tools: tuple[str, ...] = ()
96
+ source: str = "project" # "user" | "project"
97
+ path: Path | None = None
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class HookSpec:
102
+ """A hook parsed from a ``[[hooks]]`` entry in ``settings.toml``.
103
+
104
+ ``event`` is one of :data:`KNOWN_HOOK_EVENTS`. ``matcher`` is a
105
+ tool-name pattern (only meaningful for tool-lifecycle events): the
106
+ literal ``"*"`` / ``""`` matches all, a pipe-separated list like
107
+ ``"bash|edit"`` matches any of those tools, anything else is a
108
+ regex. ``command`` is the shell command run with the event's JSON
109
+ on stdin. ``source`` records which scope declared it — user-scope
110
+ hooks are trusted; project-scope hooks are trust-gated.
111
+ """
112
+
113
+ event: str
114
+ command: str
115
+ matcher: str = "*"
116
+ timeout: float = 60.0
117
+ source: str = "project" # "user" | "project"
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class McpEntry:
122
+ """An MCP server declared in a ``[[mcp]]`` block of ``settings.toml``.
123
+
124
+ ``source`` is "user" or "project"; the trust gate keys off it exactly
125
+ as it does for :class:`HookSpec` — a project-declared server from an
126
+ untrusted repo is dropped, a user-scope server is your own config and
127
+ always kept (connecting an MCP server runs external code / hits an
128
+ external endpoint). ``spec`` is the framework's ``MCPServerSpec``,
129
+ built lazily in :func:`_discover_mcp` so importing this module never
130
+ requires the ``mcp`` extra.
131
+ """
132
+
133
+ source: str
134
+ spec: MCPServerSpec
135
+
136
+
137
+ @dataclass
138
+ class Extensions:
139
+ """Everything discovered across the user + project layers.
140
+
141
+ ``skill_paths`` are individual skill *directories* (each holds a
142
+ ``SKILL.md``), ordered user-then-project so the framework's
143
+ last-source-wins resolution gives project skills priority. The
144
+ caller prepends the bundled skills.
145
+ """
146
+
147
+ skill_paths: list[Path] = field(default_factory=list)
148
+ agent_specs: list[AgentSpec] = field(default_factory=list)
149
+ hook_specs: list[HookSpec] = field(default_factory=list)
150
+ # MCP servers declared in settings.toml [[mcp]] blocks, each tagged
151
+ # with its source ("user" | "project") so the trust gate can drop
152
+ # project-declared servers from an untrusted repo — same posture as
153
+ # project hooks (connecting an MCP server runs external code / hits
154
+ # an external endpoint, so a cloned repo must not auto-connect one).
155
+ mcp_specs: list[McpEntry] = field(default_factory=list)
156
+
157
+ def has_any(self) -> bool:
158
+ return bool(
159
+ self.skill_paths
160
+ or self.agent_specs
161
+ or self.hook_specs
162
+ or self.mcp_specs
163
+ )
164
+
165
+
166
+ def safe_role_name(name: str) -> str:
167
+ """Map a subagent's authored ``name`` to a valid worker-role id.
168
+
169
+ loomflow's worker registry requires role names to be Python
170
+ identifiers (no hyphens/spaces), but Claude-Code-style subagent
171
+ names are lowercase-with-hyphens. We translate any run of
172
+ non-identifier characters to a single underscore so
173
+ ``security-auditor.md`` becomes the delegate role
174
+ ``security_auditor``. A name that would start with a digit is
175
+ prefixed; an empty result falls back to ``"subagent"``."""
176
+ safe = re.sub(r"\W+", "_", name.strip()).strip("_")
177
+ if not safe:
178
+ return "subagent"
179
+ if safe[0].isdigit():
180
+ safe = f"a_{safe}"
181
+ return safe
182
+
183
+
184
+ def discover(
185
+ project_root: Path,
186
+ *,
187
+ user_dir: Path | None = None,
188
+ ) -> Extensions:
189
+ """Scan the user + project layers and return the merged bundle.
190
+
191
+ ``project_root`` is the repo root (``<root>/.loom/`` is scanned).
192
+ ``user_dir`` overrides the user scope root (``~/.loom-code/`` by
193
+ default) — tests pass a tmp dir here.
194
+
195
+ Merge rules differ by type:
196
+
197
+ * skills — user dirs then project dirs (caller prepends bundled);
198
+ collisions resolved later by the framework (project wins).
199
+ * agents — project overrides user on duplicate ``name``.
200
+ * hooks — additive; every scope's hooks are kept (a project must
201
+ not be able to silently drop your personal hooks).
202
+ """
203
+ user_base = (
204
+ user_dir if user_dir is not None else (Path.home() / USER_DIRNAME)
205
+ )
206
+ project_base = project_root / PROJECT_DIRNAME
207
+
208
+ ext = Extensions()
209
+ ext.skill_paths = _discover_skill_dirs(user_base) + _discover_skill_dirs(
210
+ project_base
211
+ )
212
+
213
+ merged: dict[str, AgentSpec] = {}
214
+ for spec in _discover_agents(user_base, "user"):
215
+ merged[spec.name] = spec
216
+ for spec in _discover_agents(project_base, "project"):
217
+ merged[spec.name] = spec # project wins
218
+ ext.agent_specs = list(merged.values())
219
+
220
+ ext.hook_specs = _discover_hooks(user_base, "user") + _discover_hooks(
221
+ project_base, "project"
222
+ )
223
+ # MCP servers — additive across scopes like hooks. A project's
224
+ # [[mcp]] servers ride the same trust gate (see loom_code.trust).
225
+ ext.mcp_specs = _discover_mcp(user_base, "user") + _discover_mcp(
226
+ project_base, "project"
227
+ )
228
+ return ext
229
+
230
+
231
+ # ---- skills ---------------------------------------------------------
232
+
233
+
234
+ def _discover_skill_dirs(base: Path) -> list[Path]:
235
+ """Return each ``<base>/skills/<name>/`` dir that has a SKILL.md.
236
+
237
+ Same shape as ``agent._bundled_skill_paths`` so the result drops
238
+ straight into the ``skills=`` list every agent builder already
239
+ accepts."""
240
+ skills_dir = base / "skills"
241
+ if not skills_dir.is_dir():
242
+ return []
243
+ out: list[Path] = []
244
+ for entry in sorted(skills_dir.iterdir()):
245
+ if entry.is_dir() and (entry / "SKILL.md").is_file():
246
+ out.append(entry)
247
+ return out
248
+
249
+
250
+ # ---- agents ---------------------------------------------------------
251
+
252
+
253
+ def _discover_agents(base: Path, source: str) -> list[AgentSpec]:
254
+ """Parse every ``<base>/agents/*.md`` into an :class:`AgentSpec`.
255
+
256
+ A file missing either ``name`` or ``description`` frontmatter is
257
+ skipped — those two fields are the delegation contract (the
258
+ supervisor can't route to an agent it can't describe). Unreadable
259
+ or malformed files are skipped rather than aborting discovery, so
260
+ one bad file doesn't break the whole session."""
261
+ agents_dir = base / "agents"
262
+ if not agents_dir.is_dir():
263
+ return []
264
+ out: list[AgentSpec] = []
265
+ for path in sorted(agents_dir.glob("*.md")):
266
+ try:
267
+ text = path.read_text(encoding="utf-8")
268
+ except OSError:
269
+ continue
270
+ fm, body = _parse_frontmatter(text)
271
+ name = str(fm.get("name") or path.stem).strip()
272
+ description = str(fm.get("description") or "").strip()
273
+ if not name or not description:
274
+ continue
275
+ model_raw = fm.get("model")
276
+ model = str(model_raw).strip() if model_raw else None
277
+ tools = _normalize_tools(fm.get("tools", ()))
278
+ out.append(
279
+ AgentSpec(
280
+ name=name,
281
+ description=description,
282
+ system_prompt=body,
283
+ model=model,
284
+ tools=tools,
285
+ source=source,
286
+ path=path,
287
+ )
288
+ )
289
+ return out
290
+
291
+
292
+ def _normalize_tools(value: object) -> tuple[str, ...]:
293
+ """Coerce a ``tools`` frontmatter value into a tuple of names.
294
+
295
+ Accepts a YAML-ish list (already split by :func:`_parse_frontmatter`)
296
+ or a single string like ``"read, edit, bash"`` / ``"read edit"``."""
297
+ if isinstance(value, (list, tuple)):
298
+ return tuple(str(v).strip() for v in value if str(v).strip())
299
+ if isinstance(value, str):
300
+ parts = re.split(r"[,\s]+", value.strip())
301
+ return tuple(p for p in parts if p)
302
+ return ()
303
+
304
+
305
+ # ---- hooks ----------------------------------------------------------
306
+
307
+
308
+ def _discover_hooks(base: Path, source: str) -> list[HookSpec]:
309
+ """Parse ``[[hooks]]`` entries from ``<base>/settings.toml``.
310
+
311
+ Expected shape::
312
+
313
+ [[hooks]]
314
+ event = "PreToolUse"
315
+ matcher = "bash" # optional, defaults to "*"
316
+ command = "./scripts/check.sh"
317
+ timeout = 30 # optional, seconds
318
+
319
+ Entries with an unknown ``event`` or a missing ``event``/``command``
320
+ are skipped. A malformed TOML file is skipped wholesale (returns
321
+ ``[]``) rather than aborting the session."""
322
+ settings = base / "settings.toml"
323
+ if not settings.is_file():
324
+ return []
325
+ try:
326
+ data = tomllib.loads(settings.read_text(encoding="utf-8"))
327
+ except (OSError, tomllib.TOMLDecodeError):
328
+ return []
329
+ raw = data.get("hooks")
330
+ if not isinstance(raw, list):
331
+ return []
332
+ out: list[HookSpec] = []
333
+ for entry in raw:
334
+ if not isinstance(entry, dict):
335
+ continue
336
+ event = str(entry.get("event", "")).strip()
337
+ command = str(entry.get("command", "")).strip()
338
+ if event not in KNOWN_HOOK_EVENTS or not command:
339
+ continue
340
+ matcher = str(entry.get("matcher", "*")).strip() or "*"
341
+ try:
342
+ timeout = float(entry.get("timeout", 60.0))
343
+ except (TypeError, ValueError):
344
+ timeout = 60.0
345
+ out.append(
346
+ HookSpec(
347
+ event=event,
348
+ command=command,
349
+ matcher=matcher,
350
+ timeout=timeout,
351
+ source=source,
352
+ )
353
+ )
354
+ return out
355
+
356
+
357
+ def _discover_mcp(base: Path, source: str) -> list[McpEntry]:
358
+ """Parse ``[[mcp]]`` blocks from ``<base>/settings.toml`` into
359
+ :class:`McpEntry` (source-tagged :class:`MCPServerSpec`).
360
+
361
+ Each block declares one MCP server. Recognised keys::
362
+
363
+ [[mcp]]
364
+ name = "linear" # required, unique
365
+ transport = "stdio" # "stdio" (default) or "http"
366
+ command = "npx" # stdio: the server binary
367
+ args = ["-y", "linear-mcp"]
368
+ env = { LINEAR_API_KEY = "..." }
369
+ # or, for http:
370
+ # transport = "http"
371
+ # url = "https://mcp.example.com"
372
+ # headers = { Authorization = "Bearer ..." }
373
+
374
+ Bad entries are skipped (missing name, or stdio without a command /
375
+ http without a url) rather than aborting the session — matches the
376
+ lenient, never-crash posture of :func:`_discover_hooks`. Returns
377
+ ``[]`` when the ``mcp`` extra isn't installed (the lazy import
378
+ fails) so loom-code runs fine without it.
379
+ """
380
+ settings = base / "settings.toml"
381
+ if not settings.is_file():
382
+ return []
383
+ try:
384
+ data = tomllib.loads(settings.read_text(encoding="utf-8"))
385
+ except (OSError, tomllib.TOMLDecodeError):
386
+ return []
387
+ raw = data.get("mcp")
388
+ if not isinstance(raw, list):
389
+ return []
390
+ try:
391
+ from loomflow.mcp import MCPServerSpec
392
+ except ImportError:
393
+ # ``mcp`` extra not installed — silently skip MCP discovery.
394
+ return []
395
+ out: list[McpEntry] = []
396
+ for entry in raw:
397
+ if not isinstance(entry, dict):
398
+ continue
399
+ name = str(entry.get("name", "")).strip()
400
+ if not name:
401
+ continue
402
+ transport = str(entry.get("transport", "stdio")).strip() or "stdio"
403
+ command = str(entry.get("command", "")).strip() or None
404
+ url = str(entry.get("url", "")).strip() or None
405
+ # Skip specs that can't possibly connect — same "bad entry is
406
+ # dropped, not fatal" rule as hooks.
407
+ if transport == "stdio" and not command:
408
+ continue
409
+ if transport == "http" and not url:
410
+ continue
411
+ # MCPServerSpec is a frozen dataclass with hashable (tuple)
412
+ # fields, so coerce the TOML list/dict into tuples here.
413
+ args_raw = entry.get("args", [])
414
+ args = (
415
+ tuple(str(a) for a in args_raw)
416
+ if isinstance(args_raw, list)
417
+ else ()
418
+ )
419
+ env_raw = entry.get("env", {})
420
+ env = (
421
+ tuple((str(k), str(v)) for k, v in env_raw.items())
422
+ if isinstance(env_raw, dict)
423
+ else ()
424
+ )
425
+ headers_raw = entry.get("headers", {})
426
+ headers = (
427
+ tuple((str(k), str(v)) for k, v in headers_raw.items())
428
+ if isinstance(headers_raw, dict)
429
+ else ()
430
+ )
431
+ description = str(entry.get("description", "")).strip()
432
+ spec = MCPServerSpec(
433
+ name=name,
434
+ transport=transport, # type: ignore[arg-type]
435
+ command=command,
436
+ args=args,
437
+ env=env,
438
+ url=url,
439
+ headers=headers,
440
+ description=description,
441
+ )
442
+ out.append(McpEntry(source=source, spec=spec))
443
+ return out
444
+
445
+
446
+ # ---- frontmatter ----------------------------------------------------
447
+
448
+ _FENCE_RE = re.compile(
449
+ r"\A---[ \t]*\n(.*?)\n---[ \t]*(?:\n(.*))?\Z", re.DOTALL
450
+ )
451
+
452
+
453
+ def _parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
454
+ """Split ``---\\n<frontmatter>\\n---\\n<body>`` into ``(fields, body)``.
455
+
456
+ A deliberately tiny parser — loom-code doesn't depend on pyyaml and
457
+ subagent frontmatter only needs ``key: value`` scalars plus a list
458
+ value for ``tools``. Supported list forms::
459
+
460
+ tools: [read, edit] # flow
461
+ tools: # block
462
+ - read
463
+ - edit
464
+
465
+ Scalars are returned as strings (surrounding quotes stripped);
466
+ flow/block lists are returned as ``list[str]``. Comma-splitting of
467
+ bare scalars is intentionally NOT done here — descriptions contain
468
+ commas — so ``tools: read, edit`` arrives as the string
469
+ ``"read, edit"`` and :func:`_normalize_tools` splits it. Returns
470
+ ``({}, text)`` when there's no frontmatter fence."""
471
+ m = _FENCE_RE.match(text)
472
+ if not m:
473
+ return {}, text.strip()
474
+ raw = m.group(1)
475
+ body = (m.group(2) or "").strip()
476
+
477
+ fields: dict[str, object] = {}
478
+ lines = raw.splitlines()
479
+ i = 0
480
+ while i < len(lines):
481
+ line = lines[i]
482
+ stripped = line.strip()
483
+ if not stripped or stripped.startswith("#") or ":" not in line:
484
+ i += 1
485
+ continue
486
+ key, _, val = line.partition(":")
487
+ key = key.strip()
488
+ val = val.strip()
489
+ if not val:
490
+ # Possible block list on the following indented "- " lines.
491
+ items: list[str] = []
492
+ j = i + 1
493
+ while j < len(lines) and lines[j].lstrip().startswith("- "):
494
+ items.append(_strip_quotes(lines[j].lstrip()[2:].strip()))
495
+ j += 1
496
+ if items:
497
+ fields[key] = items
498
+ i = j
499
+ continue
500
+ fields[key] = ""
501
+ i += 1
502
+ continue
503
+ fields[key] = _coerce_value(val)
504
+ i += 1
505
+ return fields, body
506
+
507
+
508
+ def _coerce_value(val: str) -> object:
509
+ """Turn a frontmatter scalar into a flow list (``[a, b]``) or a
510
+ quote-stripped string."""
511
+ if val.startswith("[") and val.endswith("]"):
512
+ inner = val[1:-1]
513
+ return [
514
+ _strip_quotes(p.strip()) for p in inner.split(",") if p.strip()
515
+ ]
516
+ return _strip_quotes(val)
517
+
518
+
519
+ def _strip_quotes(val: str) -> str:
520
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in {'"', "'"}:
521
+ return val[1:-1]
522
+ return val