Add host bootstrap and repair helpers

Add a dedicated pyro host surface for supported chat hosts so Claude Code, Codex, and OpenCode users can connect or repair the canonical MCP setup without hand-writing raw commands or config edits.

Implement the shared host helper layer and wire it through the CLI with connect, print-config, doctor, and repair, all generated from the same canonical pyro mcp serve command shape and project-source flags. Update the docs, public contract, examples, changelog, and roadmap so the helper flow becomes the primary onramp while raw host-specific commands remain as reference material.

Harden the verification path that this milestone exposed: temp git repos in tests now disable commit signing, socket-based port tests skip cleanly when the sandbox forbids those primitives, and make test still uses multiple cores by default but caps xdist workers to a stable value so make check stays fast and deterministic here.

Validation:
- uv lock
- UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check
- UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check
This commit is contained in:
Thales Maciel 2026-03-13 16:46:10 -03:00
parent 535efc6919
commit 899a6760c4
25 changed files with 1658 additions and 58 deletions

View file

@ -8,12 +8,20 @@ import shlex
import sys
from pathlib import Path
from textwrap import dedent
from typing import Any
from typing import Any, cast
from pyro_mcp import __version__
from pyro_mcp.api import Pyro
from pyro_mcp.api import McpToolProfile, Pyro
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
from pyro_mcp.demo import run_demo
from pyro_mcp.host_helpers import (
HostDoctorEntry,
HostServerConfig,
connect_cli_host,
doctor_hosts,
print_or_write_opencode_config,
repair_host,
)
from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
@ -169,6 +177,62 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
print(f"- {issue}")
def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
return HostServerConfig(
installed_package=bool(getattr(args, "installed_package", False)),
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
project_path=getattr(args, "project_path", None),
repo_url=getattr(args, "repo_url", None),
repo_ref=getattr(args, "repo_ref", None),
no_project_source=bool(getattr(args, "no_project_source", False)),
)
def _print_host_connect_human(payload: dict[str, Any]) -> None:
host = str(payload.get("host", "unknown"))
server_command = payload.get("server_command")
verification_command = payload.get("verification_command")
print(f"Connected pyro to {host}.")
if isinstance(server_command, list):
print("Server command: " + shlex.join(str(item) for item in server_command))
if isinstance(verification_command, list):
print("Verify with: " + shlex.join(str(item) for item in verification_command))
def _print_host_print_config_human(payload: dict[str, Any]) -> None:
rendered_config = payload.get("rendered_config")
if isinstance(rendered_config, str):
_write_stream(rendered_config, stream=sys.stdout)
return
output_path = payload.get("output_path")
if isinstance(output_path, str):
print(f"Wrote OpenCode config to {output_path}")
def _print_host_repair_human(payload: dict[str, Any]) -> None:
host = str(payload.get("host", "unknown"))
if host == "opencode":
print(f"Repaired OpenCode config at {str(payload.get('config_path', 'unknown'))}.")
backup_path = payload.get("backup_path")
if isinstance(backup_path, str):
print(f"Backed up the previous config to {backup_path}.")
return
_print_host_connect_human(payload)
def _print_host_doctor_human(entries: list[HostDoctorEntry]) -> None:
for index, entry in enumerate(entries):
print(
f"{entry.host}: {entry.status} "
f"installed={'yes' if entry.installed else 'no'} "
f"configured={'yes' if entry.configured else 'no'}"
)
print(f" details: {entry.details}")
print(f" repair: {entry.repair_command}")
if index != len(entries) - 1:
print()
def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
name = payload.get("name")
@ -645,6 +709,38 @@ class _HelpFormatter(
return help_string
def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--installed-package",
action="store_true",
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.",
)
parser.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-core",
help="Server profile to configure for the host helper flow.",
)
source_group = parser.add_mutually_exclusive_group()
source_group.add_argument(
"--project-path",
help="Pin the server to this local project path instead of relying on host cwd.",
)
source_group.add_argument(
"--repo-url",
help="Seed default workspaces from a clean clone of this repository URL.",
)
source_group.add_argument(
"--no-project-source",
action="store_true",
help="Disable automatic Git checkout detection from the current working directory.",
)
parser.add_argument(
"--repo-ref",
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
@ -658,11 +754,12 @@ def _build_parser() -> argparse.ArgumentParser:
pyro env list
pyro env pull debian:12
pyro run debian:12 -- git --version
pyro mcp serve
pyro host connect claude-code
Connect a chat host after that:
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
pyro host connect claude-code
pyro host connect codex
pyro host print-config opencode
If you want terminal-level visibility into the workspace model:
pyro workspace create debian:12 --seed-path ./repo --id-only
@ -767,6 +864,129 @@ def _build_parser() -> argparse.ArgumentParser:
help="Print structured JSON instead of human-readable output.",
)
host_parser = subparsers.add_parser(
"host",
help="Bootstrap and repair supported chat-host configs.",
description=(
"Connect or repair the supported Claude Code, Codex, and OpenCode "
"host setups without hand-writing MCP commands or config."
),
epilog=dedent(
"""
Examples:
pyro host connect claude-code
pyro host connect codex --project-path /abs/path/to/repo
pyro host print-config opencode
pyro host repair opencode
pyro host doctor
"""
),
formatter_class=_HelpFormatter,
)
host_subparsers = host_parser.add_subparsers(dest="host_command", required=True, metavar="HOST")
host_connect_parser = host_subparsers.add_parser(
"connect",
help="Connect Claude Code or Codex in one step.",
description=(
"Ensure the supported host has a `pyro` MCP server entry that wraps "
"the canonical `pyro mcp serve` command."
),
epilog=dedent(
"""
Examples:
pyro host connect claude-code
pyro host connect codex --installed-package
pyro host connect codex --project-path /abs/path/to/repo
"""
),
formatter_class=_HelpFormatter,
)
host_connect_parser.add_argument(
"host",
choices=("claude-code", "codex"),
help="Chat host to connect and update in place.",
)
_add_host_server_source_args(host_connect_parser)
host_print_config_parser = host_subparsers.add_parser(
"print-config",
help="Print or write the canonical OpenCode config snippet.",
description=(
"Render the canonical OpenCode `mcp.pyro` config entry so it can be "
"copied into or written to `opencode.json`."
),
epilog=dedent(
"""
Examples:
pyro host print-config opencode
pyro host print-config opencode --output ./opencode.json
pyro host print-config opencode --project-path /abs/path/to/repo
"""
),
formatter_class=_HelpFormatter,
)
host_print_config_parser.add_argument(
"host",
choices=("opencode",),
help="Host config shape to render.",
)
_add_host_server_source_args(host_print_config_parser)
host_print_config_parser.add_argument(
"--output",
help="Write the rendered JSON to this path instead of printing it to stdout.",
)
host_doctor_parser = host_subparsers.add_parser(
"doctor",
help="Inspect supported host setup status.",
description=(
"Report whether Claude Code, Codex, and OpenCode are installed, "
"configured, missing, or drifted relative to the canonical `pyro` MCP setup."
),
epilog=dedent(
"""
Examples:
pyro host doctor
pyro host doctor --project-path /abs/path/to/repo
pyro host doctor --installed-package
"""
),
formatter_class=_HelpFormatter,
)
_add_host_server_source_args(host_doctor_parser)
host_doctor_parser.add_argument(
"--config-path",
help="Override the OpenCode config path when inspecting or repairing that host.",
)
host_repair_parser = host_subparsers.add_parser(
"repair",
help="Repair one supported host to the canonical `pyro` setup.",
description=(
"Repair a stale or broken host config by reapplying the canonical "
"`pyro mcp serve` setup for that host."
),
epilog=dedent(
"""
Examples:
pyro host repair claude-code
pyro host repair codex --project-path /abs/path/to/repo
pyro host repair opencode
"""
),
formatter_class=_HelpFormatter,
)
host_repair_parser.add_argument(
"host",
choices=("claude-code", "codex", "opencode"),
help="Host config to repair.",
)
_add_host_server_source_args(host_repair_parser)
host_repair_parser.add_argument(
"--config-path",
help="Override the OpenCode config path when repairing that host.",
)
mcp_parser = subparsers.add_parser(
"mcp",
help="Run the MCP server.",
@ -2350,6 +2570,57 @@ def main() -> None:
else:
_print_prune_human(prune_payload)
return
if args.command == "host":
config = _build_host_server_config(args)
if args.host_command == "connect":
try:
payload = connect_cli_host(args.host, config=config)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_connect_human(payload)
return
if args.host_command == "print-config":
try:
output_path = (
None if args.output is None else Path(args.output).expanduser().resolve()
)
payload = print_or_write_opencode_config(config=config, output_path=output_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_print_config_human(payload)
return
if args.host_command == "doctor":
try:
config_path = (
None
if args.config_path is None
else Path(args.config_path).expanduser().resolve()
)
entries = doctor_hosts(config=config, config_path=config_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_doctor_human(entries)
return
if args.host_command == "repair":
try:
if args.host != "opencode" and args.config_path is not None:
raise ValueError(
"--config-path is only supported for `pyro host repair opencode`"
)
config_path = (
None
if args.config_path is None
else Path(args.config_path).expanduser().resolve()
)
payload = repair_host(args.host, config=config, config_path=config_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_repair_human(payload)
return
if args.command == "mcp":
pyro.create_server(
profile=args.profile,
@ -2497,7 +2768,8 @@ def main() -> None:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_workspace_exec_human(payload)
exit_code = int(payload.get("exit_code", 1))
exit_code_raw = payload.get("exit_code", 1)
exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1
if exit_code != 0:
raise SystemExit(exit_code)
return

View file

@ -2,9 +2,22 @@
from __future__ import annotations
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "run", "workspace")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
PUBLIC_CLI_HOST_COMMON_FLAGS = (
"--installed-package",
"--profile",
"--project-path",
"--repo-url",
"--repo-ref",
"--no-project-source",
)
PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS
PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",)
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--profile",

View file

@ -0,0 +1,363 @@
"""Helpers for bootstrapping and repairing supported MCP chat hosts."""
from __future__ import annotations
import json
import shlex
import shutil
import subprocess
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal
from pyro_mcp.api import McpToolProfile
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",)
DEFAULT_HOST_SERVER_NAME = "pyro"
DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json"
HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
@dataclass(frozen=True)
class HostServerConfig:
installed_package: bool = False
profile: McpToolProfile = "workspace-core"
project_path: str | None = None
repo_url: str | None = None
repo_ref: str | None = None
no_project_source: bool = False
@dataclass(frozen=True)
class HostDoctorEntry:
host: str
installed: bool
configured: bool
status: HostStatus
details: str
repair_command: str
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603
command,
check=False,
capture_output=True,
text=True,
)
def _host_binary(host: str) -> str:
if host == "claude-code":
return "claude"
if host == "codex":
return "codex"
raise ValueError(f"unsupported CLI host {host!r}")
def _canonical_server_command(config: HostServerConfig) -> list[str]:
if config.project_path is not None and config.repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive")
if config.no_project_source and (
config.project_path is not None
or config.repo_url is not None
or config.repo_ref is not None
):
raise ValueError(
"--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref"
)
if config.repo_ref is not None and config.repo_url is None:
raise ValueError("--repo-ref requires --repo-url")
command = ["pyro", "mcp", "serve"]
if not config.installed_package:
command = ["uvx", "--from", "pyro-mcp", *command]
if config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])
elif config.repo_url is not None:
command.extend(["--repo-url", config.repo_url])
if config.repo_ref is not None:
command.extend(["--repo-ref", config.repo_ref])
elif config.no_project_source:
command.append("--no-project-source")
return command
def _render_cli_command(command: list[str]) -> str:
return shlex.join(command)
def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str:
command = ["pyro", "host", "repair", host]
if config.installed_package:
command.append("--installed-package")
if config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])
elif config.repo_url is not None:
command.extend(["--repo-url", config.repo_url])
if config.repo_ref is not None:
command.extend(["--repo-ref", config.repo_ref])
elif config.no_project_source:
command.append("--no-project-source")
if config_path is not None:
command.extend(["--config-path", str(config_path)])
return _render_cli_command(command)
def _command_matches(output: str, expected: list[str]) -> bool:
normalized_output = output.strip()
if ":" in normalized_output:
normalized_output = normalized_output.split(":", 1)[1].strip()
try:
parsed = shlex.split(normalized_output)
except ValueError:
parsed = normalized_output.split()
return parsed == expected
def _upsert_opencode_config(
*,
config_path: Path,
config: HostServerConfig,
) -> tuple[dict[str, object], Path | None]:
existing_payload: dict[str, object] = {}
backup_path: Path | None = None
if config_path.exists():
raw_text = config_path.read_text(encoding="utf-8")
try:
parsed = json.loads(raw_text)
except json.JSONDecodeError:
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
shutil.move(str(config_path), str(backup_path))
parsed = {}
if isinstance(parsed, dict):
existing_payload = parsed
else:
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
shutil.move(str(config_path), str(backup_path))
payload = dict(existing_payload)
mcp_payload = payload.get("mcp")
if not isinstance(mcp_payload, dict):
mcp_payload = {}
else:
mcp_payload = dict(mcp_payload)
mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config)
payload["mcp"] = mcp_payload
return payload, backup_path
def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]:
return {
"type": "local",
"enabled": True,
"command": _canonical_server_command(config),
}
def render_opencode_config(config: HostServerConfig) -> str:
return (
json.dumps(
{"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}},
indent=2,
)
+ "\n"
)
def print_or_write_opencode_config(
*,
config: HostServerConfig,
output_path: Path | None = None,
) -> dict[str, object]:
rendered = render_opencode_config(config)
if output_path is None:
return {
"host": "opencode",
"rendered_config": rendered,
"server_command": _canonical_server_command(config),
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(rendered, encoding="utf-8")
return {
"host": "opencode",
"output_path": str(output_path),
"server_command": _canonical_server_command(config),
}
def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]:
binary = _host_binary(host)
if shutil.which(binary) is None:
raise RuntimeError(f"{binary} CLI is not installed or not on PATH")
server_command = _canonical_server_command(config)
_run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME])
result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command])
if result.returncode != 0:
details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed"
raise RuntimeError(details)
return {
"host": host,
"server_command": server_command,
"verification_command": [binary, "mcp", "list"],
}
def repair_opencode_host(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> dict[str, object]:
resolved_path = (
DEFAULT_OPENCODE_CONFIG_PATH
if config_path is None
else config_path.expanduser().resolve()
)
resolved_path.parent.mkdir(parents=True, exist_ok=True)
payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config)
resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
result: dict[str, object] = {
"host": "opencode",
"config_path": str(resolved_path),
"server_command": _canonical_server_command(config),
}
if backup_path is not None:
result["backup_path"] = str(backup_path)
return result
def repair_host(
host: str,
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> dict[str, object]:
if host == "opencode":
return repair_opencode_host(config=config, config_path=config_path)
return connect_cli_host(host, config=config)
def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry:
binary = _host_binary(host)
repair_command = _repair_command(host, config)
if shutil.which(binary) is None:
return HostDoctorEntry(
host=host,
installed=False,
configured=False,
status="unavailable",
details=f"{binary} CLI was not found on PATH",
repair_command=repair_command,
)
expected_command = _canonical_server_command(config)
get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME])
combined_get_output = (get_result.stdout + get_result.stderr).strip()
if get_result.returncode == 0:
status: HostStatus = (
"ok" if _command_matches(combined_get_output, expected_command) else "drifted"
)
return HostDoctorEntry(
host=host,
installed=True,
configured=True,
status=status,
details=combined_get_output or f"{binary} MCP entry exists",
repair_command=repair_command,
)
list_result = _run_command([binary, "mcp", "list"])
combined_list_output = (list_result.stdout + list_result.stderr).strip()
configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split()
return HostDoctorEntry(
host=host,
installed=True,
configured=configured,
status="drifted" if configured else "missing",
details=combined_get_output or combined_list_output or f"{binary} MCP entry missing",
repair_command=repair_command,
)
def _doctor_opencode_host(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> HostDoctorEntry:
resolved_path = (
DEFAULT_OPENCODE_CONFIG_PATH
if config_path is None
else config_path.expanduser().resolve()
)
repair_command = _repair_command("opencode", config, config_path=config_path)
installed = shutil.which("opencode") is not None
if not resolved_path.exists():
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="missing" if installed else "unavailable",
details=f"OpenCode config missing at {resolved_path}",
repair_command=repair_command,
)
try:
payload = json.loads(resolved_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="drifted" if installed else "unavailable",
details=f"OpenCode config is invalid JSON: {exc}",
repair_command=repair_command,
)
if not isinstance(payload, dict):
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="drifted" if installed else "unavailable",
details="OpenCode config must be a JSON object",
repair_command=repair_command,
)
mcp_payload = payload.get("mcp")
if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload:
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="missing" if installed else "unavailable",
details=f"OpenCode config at {resolved_path} is missing mcp.pyro",
repair_command=repair_command,
)
configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME]
expected_entry = canonical_opencode_entry(config)
status: HostStatus = "ok" if configured_entry == expected_entry else "drifted"
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=True,
status=status,
details=f"OpenCode config path: {resolved_path}",
repair_command=repair_command,
)
def doctor_hosts(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> list[HostDoctorEntry]:
return [
_doctor_cli_host("claude-code", config=config),
_doctor_cli_host("codex", config=config),
_doctor_opencode_host(config=config, config_path=config_path),
]

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "4.1.0"
DEFAULT_CATALOG_VERSION = "4.2.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",
@ -48,7 +48,7 @@ class VmEnvironment:
oci_repository: str | None = None
oci_reference: str | None = None
source_digest: str | None = None
compatibility: str = ">=4.1.0,<5.0.0"
compatibility: str = ">=4.2.0,<5.0.0"
@dataclass(frozen=True)