Conflict resolutions and semantic fixups: - utils.py / hermes_yaml.py: main widened ruamel's round-trip emitter so a long double-quoted scalar is never folded after an escaped backslash. pm-clean builds every rt emitter through hermes_yaml.roundtrip_yaml(), so the width lives there (ROUNDTRIP_YAML_WIDTH moves with it); xai_retirement imports it from hermes_yaml. - hermes_cli/banner.py: keep pm-clean's removal of the banner update check. Main's GIT_NO_LAZY_FETCH fix for it applies to its replacement, source_check: every read-only probe (source_git_env) now refuses promisor lazy fetches, and the partial-clone test targets that probe (red without the flag). - .github/workflows/tests.yml: keep setup-pm; main's uv pin bump does not apply. Main's WAL-capable SQLite gates are kept, run against $HERMES_PYTHON (the PM-pinned interpreter, SQLite 3.53.1). The e2e step takes main's --include-integration invocation. - apps/desktop: package.json has no build block here, so main's macOS locale-marker restore joins the darwin branch of the existing after-pack.mjs, and its test loads the hook from electron-builder.config.cjs and imports PlatformPackager from app-builder-lib's root (electron-builder 27 exports no ./out paths). The win32 row is dropped: this hook sanitizes and signs PE trees on win32 by design. - reconciliation.ts: main's rowId hydration (#119326) was merged into the first of pm-clean's split helpers only; the resolver is now one helper both halves use. - en.ts: both sides' keys kept. tests/tools/test_lazy_deps.py stays deleted. - Tests main added with `import yaml` use hermes_yaml, like the rest of the tree.
470 lines
18 KiB
Python
470 lines
18 KiB
Python
"""Shared fixture HERMES_HOME + invariant checks for the entrypoint-parity suite.
|
|
|
|
One fixture home carries every feature that has historically been wired into
|
|
some entrypoints and forgotten in others (issue class C19): a shell hook and a
|
|
Python-plugin hook on ``pre_llm_call`` (both write a marker AND inject a
|
|
context canary), an ``AGENTS.md`` context file in the working directory, a
|
|
skill, a memory entry, a stdio MCP server (issue class C15) whose tool returns a
|
|
canary, the custom provider pointed at the recording fake, and a toolset
|
|
restriction (``agent.disabled_toolsets``).
|
|
|
|
Every entrypoint driver runs ONE turn against the scripted fake provider
|
|
(turn 1: call the MCP tool; turn 2: answer) and hands the recorded requests to
|
|
:func:`collect_observation`; the test then asserts the same invariants for
|
|
every entrypoint, so any red cell is a wiring-parity bug.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable
|
|
|
|
import hermes_yaml as yaml
|
|
|
|
from tests.fakes.fake_llm_provider import FakeLLMServer, Text, ToolCall, write_hermes_home
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
FIXTURE_MCP_SERVER = Path(__file__).with_name("fixture_mcp_server.py")
|
|
|
|
MCP_SERVER_NAME = "parity"
|
|
MCP_TOOL_NAME = "mcp__parity__parity_canary"
|
|
FINAL_ANSWER = "PARITY-TURN-COMPLETE"
|
|
PLUGIN_NAME = "parity-plugin"
|
|
# Always-available (no credential/check_fn gate) toolset the fixture disables.
|
|
DISABLED_TOOLSET = "todo"
|
|
|
|
_SECRET_ENV_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_ACCESS_KEY")
|
|
_PASSTHROUGH_ENV = frozenset({
|
|
"PATH", "LANG", "LANGUAGE", "USER", "LOGNAME", "SHELL", "TMPDIR", "TZ",
|
|
"SYSTEMROOT", "SystemRoot", "COMSPEC", "PATHEXT", "WINDIR", "TEMP", "TMP",
|
|
})
|
|
|
|
|
|
TURN_TIMEOUT = 240.0
|
|
|
|
|
|
@dataclass
|
|
class DriveResult:
|
|
"""What an entrypoint driver reports back (see ``_drive_cli`` for the contract)."""
|
|
|
|
final_text: str | None
|
|
toolset: str # the platform toolset this surface documents (a toolsets.py key)
|
|
cwd_channel: str = "launch dir"
|
|
graceful_exit: bool = True
|
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class ParityHome:
|
|
"""Filesystem layout + canaries of one fixture home."""
|
|
|
|
root: Path
|
|
home: Path
|
|
hermes_home: Path
|
|
project: Path
|
|
markers: Path
|
|
pid_log: Path
|
|
tag: str
|
|
canaries: dict[str, str] = field(default_factory=dict)
|
|
|
|
def env(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
"""Hermetic env for a subprocess Hermes: fake HOME, no real credentials."""
|
|
import pwd # POSIX-only; the suite is Linux-gated
|
|
|
|
# Refuse only a home the real install would read as live state (its root or a profile).
|
|
# A tmp_path under ``~/.hermes/cache/scratch`` (TMPDIR when Hermes itself runs the suite)
|
|
# is fine: the child's HOME is the fixture home, so its ``~/.hermes`` never resolves there.
|
|
real_root = Path(pwd.getpwuid(os.getuid()).pw_dir, ".hermes").resolve()
|
|
fixture = self.hermes_home.resolve()
|
|
assert fixture != real_root and fixture.parent != real_root / "profiles", (
|
|
f"fixture HERMES_HOME {self.hermes_home} is the real install's live home")
|
|
assert fixture == (self.home / ".hermes").resolve(), (
|
|
f"fixture HERMES_HOME {self.hermes_home} is not <fixture HOME>/.hermes")
|
|
# Allowlist, not denylist: the runner may itself be a Hermes process whose
|
|
# TERMINAL_CWD / HERMES_* / credential env would silently reroute the child.
|
|
env = {
|
|
k: v for k, v in os.environ.items()
|
|
if (k in _PASSTHROUGH_ENV or k.startswith("LC_")) and not k.endswith(_SECRET_ENV_SUFFIXES)
|
|
}
|
|
env.update({
|
|
"HOME": str(self.home),
|
|
"HERMES_HOME": str(self.hermes_home),
|
|
"PYTHONPATH": str(REPO_ROOT),
|
|
"PYTHONUNBUFFERED": "1",
|
|
"NO_COLOR": "1",
|
|
"TERM": "dumb",
|
|
# Orphan-scan tag: every process in the spawned tree inherits it.
|
|
"PARITY_TREE_TAG": self.tag,
|
|
# The child's HOME is the fixture home, so its ``~/.hermes/state.db`` IS
|
|
# the tmp HERMES_HOME's db; under a pytest ancestor the live-DB guard
|
|
# (hermes_state_guard) would refuse it. This is the guard's documented
|
|
# child-process escape hatch; the path is tmp_path by construction.
|
|
"HERMES_STATE_DB_GUARD_BYPASS": "1",
|
|
})
|
|
env.update(extra or {})
|
|
return env
|
|
|
|
def update_config(self, mutate: Callable[[dict], None]) -> None:
|
|
"""Apply ``mutate`` to config.yaml (for surfaces with a documented config-only channel)."""
|
|
path = self.hermes_home / "config.yaml"
|
|
cfg = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
mutate(cfg)
|
|
path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
|
|
|
|
def pin_terminal_cwd(self) -> None:
|
|
"""Documented cwd channel for daemon surfaces (gateway, api_server): ``terminal.cwd``."""
|
|
self.update_config(lambda cfg: cfg.setdefault("terminal", {}).__setitem__("cwd", str(self.project)))
|
|
|
|
|
|
def build_parity_home(root: Path, base_url: str, *, grandchild: bool = True,
|
|
death_tool: bool = False, mcp_timeout: int = 60) -> ParityHome:
|
|
"""Write the full fixture home under ``root`` (a tmp_path)."""
|
|
home = root / "home"
|
|
hermes_home = home / ".hermes"
|
|
project = root / "project"
|
|
markers = root / "markers"
|
|
for d in (hermes_home, project, markers):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
tag = uuid.uuid4().hex
|
|
c = {name: f"{name.upper()}-{uuid.uuid4().hex[:12]}" for name in (
|
|
"context", "skill", "memory", "shell_hook", "plugin_hook", "mcp")}
|
|
ph = ParityHome(root=root, home=home, hermes_home=hermes_home, project=project,
|
|
markers=markers, pid_log=root / "mcp_pids.log", tag=tag, canaries=c)
|
|
|
|
write_hermes_home(hermes_home, base_url)
|
|
cfg = yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8"))
|
|
cfg["agent"]["disabled_toolsets"] = [DISABLED_TOOLSET]
|
|
cfg["hooks"] = {"pre_llm_call": [{
|
|
"command": f"{sys.executable} {hermes_home / 'agent-hooks' / 'shell_hook.py'}",
|
|
"timeout": 30,
|
|
}]}
|
|
cfg["hooks_auto_accept"] = True
|
|
cfg["plugins"] = {"enabled": [PLUGIN_NAME]}
|
|
cfg["mcp_servers"] = {MCP_SERVER_NAME: {
|
|
"command": sys.executable,
|
|
"args": [str(FIXTURE_MCP_SERVER)],
|
|
"env": {
|
|
"PARITY_MCP_CANARY": c["mcp"],
|
|
"PARITY_MCP_PID_LOG": str(ph.pid_log),
|
|
"PARITY_MCP_SPAWN_GRANDCHILD": "1" if grandchild else "0",
|
|
"PARITY_MCP_DEATH_TOOL": "1" if death_tool else "0",
|
|
"PARITY_TREE_TAG": tag,
|
|
# Only the MCP server and its descendants carry this one.
|
|
"PARITY_MCP_TREE_TAG": tag,
|
|
"PYTHONPATH": str(REPO_ROOT),
|
|
},
|
|
"connect_timeout": 60,
|
|
"timeout": mcp_timeout,
|
|
}}
|
|
# Interactive surfaces wait only ~1.5 s (mcp_discovery_timeout) for MCP discovery
|
|
# before the first agent build, BY DESIGN (a slow server must not block the
|
|
# shell; late tools arrive via refresh). Under a loaded box the fixture server's
|
|
# import alone can exceed that, so pin the documented knob high: the join returns
|
|
# the instant discovery finishes, and the first turn is deterministically complete.
|
|
cfg["mcp_discovery_timeout"] = 120
|
|
cfg["mcp_single_query_discovery_timeout"] = 120
|
|
# Keep turns hermetic and short: no title/aux model chatter decides anything here.
|
|
cfg.setdefault("display", {})["compact"] = True
|
|
cfg["updates"] = {"check": False} # offline: no GitHub round-trip or git lazy fetch
|
|
(hermes_home / "config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
|
|
|
|
hooks_dir = hermes_home / "agent-hooks"
|
|
hooks_dir.mkdir()
|
|
(hooks_dir / "shell_hook.py").write_text(
|
|
"import json, os, sys\n"
|
|
"payload = json.load(sys.stdin)\n"
|
|
f"with open({str(markers / 'shell_hook.log')!r}, 'a') as fh:\n"
|
|
" fh.write(json.dumps({'event': payload.get('hook_event_name'),\n"
|
|
" 'session_id': payload.get('session_id')}) + '\\n')\n"
|
|
f"print(json.dumps({{'context': {c['shell_hook']!r}}}))\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
plugin_dir = hermes_home / "plugins" / PLUGIN_NAME
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
f"name: {PLUGIN_NAME}\nversion: 1.0.0\ndescription: parity suite marker plugin\n",
|
|
encoding="utf-8",
|
|
)
|
|
(plugin_dir / "__init__.py").write_text(
|
|
"import json\n"
|
|
"def register(ctx):\n"
|
|
" def _pre_llm_call(**kwargs):\n"
|
|
f" with open({str(markers / 'plugin_hook.log')!r}, 'a') as fh:\n"
|
|
" fh.write(json.dumps({'platform': kwargs.get('platform'),\n"
|
|
" 'session_id': kwargs.get('session_id')}) + '\\n')\n"
|
|
f" return {{'context': {c['plugin_hook']!r}}}\n"
|
|
" ctx.register_hook('pre_llm_call', _pre_llm_call)\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
skill_dir = hermes_home / "skills" / "parity-skill"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\nname: parity-skill\n"
|
|
f"description: Use when checking entrypoint parity {c['skill']}.\n---\n\n"
|
|
"# Parity skill\n\nNothing to do.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
mem_dir = hermes_home / "memories"
|
|
mem_dir.mkdir()
|
|
(mem_dir / "MEMORY.md").write_text(f"The parity memory canary is {c['memory']}.", encoding="utf-8")
|
|
|
|
(project / "AGENTS.md").write_text(
|
|
f"# Project rules\n\nThe parity context canary is {c['context']}.\n", encoding="utf-8")
|
|
return ph
|
|
|
|
|
|
# Scripted provider -----------------------------------------------------------
|
|
|
|
|
|
def parity_responder(nonce: str, tool: str = MCP_TOOL_NAME) -> Callable[[dict[str, Any]], Any]:
|
|
"""Stateless script: call the MCP tool until a tool result exists, then answer.
|
|
|
|
Stateless so a retried request (or a second concurrent entrypoint request)
|
|
cannot desynchronise the script.
|
|
"""
|
|
|
|
def respond(record: dict[str, Any]):
|
|
body = record["body"]
|
|
if any(m.get("role") == "tool" for m in body.get("messages") or []):
|
|
return Text(FINAL_ANSWER)
|
|
args = {"nonce": nonce}
|
|
if tool in _tool_names(body) or "tool_call" not in _tool_names(body):
|
|
return ToolCall(tool, args)
|
|
# Tool Search active: MCP tools sit in the deferred catalog behind the bridge.
|
|
return ToolCall("tool_call", {"calls": [{"name": tool, "arguments": args}]})
|
|
|
|
return respond
|
|
|
|
|
|
def start_provider(nonce: str, tool: str = MCP_TOOL_NAME) -> FakeLLMServer:
|
|
srv = FakeLLMServer(parity_responder(nonce, tool))
|
|
srv.start()
|
|
return srv
|
|
|
|
|
|
# Observation + invariants ----------------------------------------------------
|
|
|
|
|
|
def _text(content: Any) -> str:
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
return "\n".join(p.get("text", "") for p in content if isinstance(p, dict))
|
|
return ""
|
|
|
|
|
|
def _tool_names(body: dict[str, Any]) -> set[str]:
|
|
return {(t.get("function") or {}).get("name") or t.get("name") for t in body.get("tools") or []}
|
|
|
|
|
|
_CATALOG_LINE = re.compile(r"^- ([A-Za-z0-9_.:-]+): ", re.M)
|
|
|
|
|
|
def offered_tool_names(body: dict[str, Any]) -> set[str]:
|
|
"""Direct tool schemas plus the Tool Search deferred catalog (names the model may invoke)."""
|
|
names = _tool_names(body)
|
|
for t in body.get("tools") or []:
|
|
fn = t.get("function") or {}
|
|
if fn.get("name") == "tool_search":
|
|
names |= set(_CATALOG_LINE.findall(fn.get("description") or ""))
|
|
return names
|
|
|
|
|
|
@dataclass
|
|
class Observation:
|
|
entrypoint: str
|
|
system: str
|
|
first_user: str
|
|
tool_names: set[str]
|
|
tool_results: list[str]
|
|
main_requests: int
|
|
shell_hook_events: list[dict]
|
|
plugin_hook_events: list[dict]
|
|
final_text: str | None = None
|
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
def _read_jsonl(path: Path) -> list[dict]:
|
|
if not path.exists():
|
|
return []
|
|
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
|
|
|
|
def collect_observation(entrypoint: str, ph: ParityHome, srv: FakeLLMServer,
|
|
final_text: str | None = None) -> Observation:
|
|
mains = srv.main_requests()
|
|
assert mains, f"{entrypoint}: no main-turn request reached the provider"
|
|
first = mains[0]
|
|
msgs = first.get("messages") or []
|
|
system = "\n".join(_text(m.get("content")) for m in msgs if m.get("role") == "system")
|
|
users = [_text(m.get("content")) for m in msgs if m.get("role") == "user"]
|
|
tool_results = [
|
|
_text(m.get("content")) for body in mains for m in body.get("messages") or []
|
|
if m.get("role") == "tool"
|
|
]
|
|
return Observation(
|
|
entrypoint=entrypoint, system=system, first_user=users[-1] if users else "",
|
|
tool_names=offered_tool_names(first), tool_results=tool_results, main_requests=len(mains),
|
|
shell_hook_events=_read_jsonl(ph.markers / "shell_hook.log"),
|
|
plugin_hook_events=_read_jsonl(ph.markers / "plugin_hook.log"),
|
|
final_text=final_text,
|
|
)
|
|
|
|
|
|
def disabled_tool_names() -> set[str]:
|
|
from toolsets import resolve_toolset
|
|
|
|
return set(resolve_toolset(DISABLED_TOOLSET))
|
|
|
|
|
|
# Agent features every surface must carry when its documented toolset includes them.
|
|
_FEATURE_TOOLS = ("terminal", "read_file", "write_file", "patch", "search_files", "memory",
|
|
"skills_list", "skill_view")
|
|
|
|
|
|
def required_tool_names(toolset: str) -> set[str]:
|
|
"""The core feature tools the surface's documented toolset promises, plus the MCP tool."""
|
|
from toolsets import resolve_toolset
|
|
|
|
promised = set(resolve_toolset(toolset))
|
|
return {t for t in _FEATURE_TOOLS if t in promised} | {MCP_TOOL_NAME}
|
|
|
|
|
|
def check_invariants(obs: Observation, ph: ParityHome, *, toolset: str = "hermes-cli",
|
|
context_file: bool = True) -> dict[str, bool]:
|
|
"""Evaluate every parity invariant; returns {cell: ok} (all must be True)."""
|
|
c = ph.canaries
|
|
cells = {
|
|
"context_file": c["context"] in obs.system,
|
|
"skill_index": c["skill"] in obs.system,
|
|
"memory": c["memory"] in obs.system,
|
|
"shell_hook_fired": bool(obs.shell_hook_events),
|
|
"shell_hook_injected": c["shell_hook"] in obs.first_user,
|
|
"plugin_hook_fired": bool(obs.plugin_hook_events),
|
|
"plugin_hook_injected": c["plugin_hook"] in obs.first_user,
|
|
"mcp_tool_offered": MCP_TOOL_NAME in obs.tool_names,
|
|
"mcp_call_returned_canary": any(c["mcp"] in r for r in obs.tool_results),
|
|
"toolset_restriction": not (obs.tool_names & disabled_tool_names()),
|
|
"feature_tools": required_tool_names(toolset) <= obs.tool_names,
|
|
"turn_completed": obs.main_requests >= 2,
|
|
}
|
|
if not context_file:
|
|
cells.pop("context_file")
|
|
return cells
|
|
|
|
|
|
# Process-tree hygiene --------------------------------------------------------
|
|
|
|
|
|
def tagged_pids(tag: str, var: str = "PARITY_TREE_TAG") -> set[int]:
|
|
"""Live PIDs whose environment carries ``<var>=<tag>`` (reparented orphans included)."""
|
|
needle = f"{var}={tag}".encode()
|
|
found: set[int] = set()
|
|
me = os.getpid()
|
|
for entry in os.listdir("/proc"):
|
|
if not entry.isdigit() or int(entry) == me:
|
|
continue
|
|
try:
|
|
with open(f"/proc/{entry}/environ", "rb") as fh:
|
|
env = fh.read()
|
|
with open(f"/proc/{entry}/stat", "rb") as fh:
|
|
state = fh.read().rsplit(b")", 1)[1].split()[0]
|
|
except OSError:
|
|
continue
|
|
if state != b"Z" and needle in env.split(b"\0"):
|
|
found.add(int(entry))
|
|
return found
|
|
|
|
|
|
def mcp_pids(ph: ParityHome) -> dict[str, list[int]]:
|
|
out: dict[str, list[int]] = {}
|
|
if ph.pid_log.exists():
|
|
for line in ph.pid_log.read_text(encoding="utf-8").splitlines():
|
|
kind, pid = line.split()
|
|
out.setdefault(kind, []).append(int(pid))
|
|
return out
|
|
|
|
|
|
def wait_until(pred: Callable[[], Any], timeout: float, what: str, interval: float = 0.05):
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
value = pred()
|
|
if value:
|
|
return value
|
|
if time.monotonic() >= deadline:
|
|
raise AssertionError(f"timed out after {timeout}s waiting for {what}")
|
|
time.sleep(interval)
|
|
|
|
|
|
def wait_no_orphans(ph: ParityHome, timeout: float = 30.0, *, mcp_only: bool = True) -> set[int]:
|
|
"""Poll until no MCP-tree process (or, ``mcp_only=False``, no tagged process at all)
|
|
survives; returns the survivors (empty = clean)."""
|
|
var = "PARITY_MCP_TREE_TAG" if mcp_only else "PARITY_TREE_TAG"
|
|
deadline = time.monotonic() + timeout
|
|
survivors = tagged_pids(ph.tag, var)
|
|
while survivors and time.monotonic() < deadline:
|
|
time.sleep(0.1)
|
|
survivors = tagged_pids(ph.tag, var)
|
|
return survivors
|
|
|
|
|
|
def describe_pids(pids: Iterable[int]) -> list[str]:
|
|
out = []
|
|
for pid in sorted(pids):
|
|
try:
|
|
with open(f"/proc/{pid}/cmdline", "rb") as fh:
|
|
cmd = fh.read().replace(b"\0", b" ").decode(errors="replace")
|
|
out.append(f"{pid}: {cmd[:160]}")
|
|
except OSError:
|
|
out.append(f"{pid}: <gone>")
|
|
return out
|
|
|
|
|
|
def kill_tagged(ph: ParityHome) -> None:
|
|
"""Test cleanup: SIGKILL anything this fixture spawned that is still alive."""
|
|
for pid in tagged_pids(ph.tag):
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def hermes_argv(*args: str) -> list[str]:
|
|
return [sys.executable, "-m", "hermes_cli.main", *args]
|
|
|
|
|
|
def terminate(proc: subprocess.Popen, timeout: float = 30.0) -> int | None:
|
|
"""Graceful SIGTERM (the normal stop), SIGKILL only if it overstays."""
|
|
if proc.poll() is None:
|
|
proc.send_signal(signal.SIGTERM)
|
|
try:
|
|
proc.wait(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
return proc.returncode
|
|
|
|
|
|
def format_cells(results: Iterable[tuple[str, dict[str, bool]]]) -> str:
|
|
rows = list(results)
|
|
cols = sorted({k for _, cells in rows for k in cells})
|
|
head = "| entrypoint | " + " | ".join(cols) + " |"
|
|
sep = "|---" * (len(cols) + 1) + "|"
|
|
body = [
|
|
f"| {ep} | " + " | ".join(("✅" if cells.get(k) else ("—" if k not in cells else "❌")) for k in cols) + " |"
|
|
for ep, cells in rows
|
|
]
|
|
return "\n".join([head, sep, *body])
|