fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
504 lines
22 KiB
Python
504 lines
22 KiB
Python
"""Tests for application.agents.default_tools — the default chat tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from application.agents import default_tools
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_tool_cache():
|
|
"""Drop the module caches so settings overrides take effect."""
|
|
def _clear():
|
|
default_tools._tool_cache.clear()
|
|
default_tools._ids_cache.clear()
|
|
default_tools._id_set_cache.clear()
|
|
default_tools._loaded_cache.clear()
|
|
default_tools._builtin_ids_cache.clear()
|
|
default_tools._builtin_id_set_cache.clear()
|
|
default_tools._builtin_loaded_cache.clear()
|
|
|
|
_clear()
|
|
yield
|
|
_clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Synthetic ids
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestSyntheticIds:
|
|
def test_default_tool_id_is_a_valid_uuid(self):
|
|
tool_id = default_tools.default_tool_id("memory")
|
|
assert str(uuid.UUID(tool_id)) == tool_id
|
|
|
|
def test_default_tool_id_is_deterministic(self):
|
|
assert default_tools.default_tool_id("memory") == default_tools.default_tool_id(
|
|
"memory"
|
|
)
|
|
|
|
def test_distinct_names_get_distinct_ids(self):
|
|
assert default_tools.default_tool_id("memory") != default_tools.default_tool_id(
|
|
"read_webpage"
|
|
)
|
|
|
|
def test_default_tool_ids_covers_configured_set(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["memory", "scheduler"]
|
|
)
|
|
ids = default_tools.default_tool_ids()
|
|
assert set(ids) == {"memory", "scheduler"}
|
|
|
|
def test_default_tool_ids_is_memoized(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["memory", "scheduler"]
|
|
)
|
|
first = default_tools.default_tool_ids()
|
|
assert default_tools.default_tool_ids() is first
|
|
|
|
def test_default_tool_ids_rebuilds_when_setting_changes(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["memory"]
|
|
)
|
|
assert set(default_tools.default_tool_ids()) == {"memory"}
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["memory", "read_webpage"]
|
|
)
|
|
assert set(default_tools.default_tool_ids()) == {"memory", "read_webpage"}
|
|
|
|
def test_is_default_tool_id_recognises_synthetic_ids(self):
|
|
assert default_tools.is_default_tool_id(
|
|
default_tools.default_tool_id("memory")
|
|
)
|
|
|
|
def test_is_default_tool_id_rejects_random_uuid(self):
|
|
assert not default_tools.is_default_tool_id(str(uuid.uuid4()))
|
|
|
|
def test_is_default_tool_id_rejects_empty(self):
|
|
assert not default_tools.is_default_tool_id(None)
|
|
assert not default_tools.is_default_tool_id("")
|
|
|
|
def test_name_for_id_round_trip(self):
|
|
tool_id = default_tools.default_tool_id("read_webpage")
|
|
assert default_tools.default_tool_name_for_id(tool_id) == "read_webpage"
|
|
|
|
def test_name_for_id_unknown_returns_none(self):
|
|
assert default_tools.default_tool_name_for_id(str(uuid.uuid4())) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Startup validation
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestValidation:
|
|
def test_unimplemented_tool_is_skipped_not_an_error(self, monkeypatch, caplog):
|
|
monkeypatch.setattr(
|
|
default_tools.settings,
|
|
"DEFAULT_CHAT_TOOLS",
|
|
["memory", "read_webpage", "future_tool_x"],
|
|
)
|
|
with caplog.at_level("DEBUG", logger="application.agents.default_tools"):
|
|
usable = default_tools.validate_default_chat_tools()
|
|
assert "future_tool_x" not in usable
|
|
assert "memory" in usable and "read_webpage" in usable
|
|
assert any(
|
|
"future_tool_x" in rec.message and rec.levelname == "DEBUG"
|
|
for rec in caplog.records
|
|
)
|
|
assert not any(rec.levelname == "WARNING" for rec in caplog.records)
|
|
|
|
def test_loaded_default_tools_is_silent(self, monkeypatch, caplog):
|
|
# Runs per request — must never log.
|
|
monkeypatch.setattr(
|
|
default_tools.settings,
|
|
"DEFAULT_CHAT_TOOLS",
|
|
["memory", "read_webpage", "future_tool_x"],
|
|
)
|
|
with caplog.at_level("DEBUG", logger="application.agents.default_tools"):
|
|
default_tools.loaded_default_tools()
|
|
assert caplog.records == []
|
|
|
|
def test_fk_bound_tool_is_rejected(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["memory", "notes"]
|
|
)
|
|
with pytest.raises(ValueError, match="notes"):
|
|
default_tools.validate_default_chat_tools()
|
|
|
|
def test_fk_bound_todo_list_is_rejected(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["memory", "todo_list"]
|
|
)
|
|
with pytest.raises(ValueError, match="todo_list"):
|
|
default_tools.validate_default_chat_tools()
|
|
|
|
def test_fully_unknown_name_is_skipped(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
default_tools.settings,
|
|
"DEFAULT_CHAT_TOOLS",
|
|
["memory", "definitely_not_a_real_tool"],
|
|
)
|
|
usable = default_tools.validate_default_chat_tools()
|
|
assert usable == ["memory"]
|
|
|
|
def test_config_free_tools_pass(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["memory", "read_webpage"]
|
|
)
|
|
assert default_tools.validate_default_chat_tools() == [
|
|
"memory",
|
|
"read_webpage",
|
|
]
|
|
|
|
def test_scheduler_is_config_free(self, monkeypatch):
|
|
# Dual-registration only works if scheduler passes the config-free
|
|
# assertion — otherwise startup would reject DEFAULT_CHAT_TOOLS.
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["scheduler"]
|
|
)
|
|
assert default_tools.validate_default_chat_tools() == ["scheduler"]
|
|
|
|
def test_sandbox_tools_are_defaultable(self, monkeypatch):
|
|
# code_executor / artifact_generator persist artifacts (no user_tools
|
|
# FK) and have no REQUIRED config field, so they validate as defaults.
|
|
monkeypatch.setattr(
|
|
default_tools.settings,
|
|
"DEFAULT_CHAT_TOOLS",
|
|
["code_executor", "artifact_generator"],
|
|
)
|
|
assert default_tools.validate_default_chat_tools() == [
|
|
"code_executor",
|
|
"artifact_generator",
|
|
]
|
|
|
|
def test_shipped_defaults_validate(self):
|
|
# The real shipped DEFAULT_CHAT_TOOLS must pass startup validation. Neither
|
|
# sandbox-backed tool is shipped default-on (both need a running runner).
|
|
usable = default_tools.validate_default_chat_tools()
|
|
assert "code_executor" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
|
assert "artifact_generator" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
|
assert usable # the remaining defaults (memory/read_webpage/scheduler) validate
|
|
|
|
def test_sandbox_tools_not_shipped_defaults(self):
|
|
# code_executor and artifact_generator both render/execute through the
|
|
# sandbox runner, an opt-in service; a fresh deploy without a runner must
|
|
# not advertise tools that hard-fail on every call. Enable per-agent.
|
|
assert "code_executor" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
|
assert "artifact_generator" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
|
names = {r["name"] for r in default_tools.synthesized_default_tools(None)}
|
|
assert "code_executor" not in names
|
|
assert "artifact_generator" not in names
|
|
|
|
def test_tool_with_required_config_is_rejected(self, monkeypatch):
|
|
# ``brave`` needs an API key.
|
|
monkeypatch.setattr(
|
|
default_tools.settings, "DEFAULT_CHAT_TOOLS", ["memory", "brave"]
|
|
)
|
|
with pytest.raises(ValueError, match="brave"):
|
|
default_tools.validate_default_chat_tools()
|
|
|
|
def test_loaded_default_tools_filters_unimplemented(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
default_tools.settings,
|
|
"DEFAULT_CHAT_TOOLS",
|
|
["memory", "read_webpage", "future_tool_x"],
|
|
)
|
|
assert default_tools.loaded_default_tools() == ["memory", "read_webpage"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Synthesized rows
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestSynthesize:
|
|
def test_synthesize_returns_row_shaped_entry(self):
|
|
row = default_tools.synthesize_default_tool("memory")
|
|
assert row is not None
|
|
assert row["name"] == "memory"
|
|
assert row["id"] == default_tools.default_tool_id("memory")
|
|
assert row["id"] == row["_id"]
|
|
assert row["config"] == {}
|
|
assert row["config_requirements"] == {}
|
|
assert row["status"] is True
|
|
assert row["default"] is True
|
|
assert isinstance(row["actions"], list) and row["actions"]
|
|
|
|
def test_synthesize_unknown_tool_returns_none(self):
|
|
assert default_tools.synthesize_default_tool("future_tool_x") is None
|
|
assert default_tools.synthesize_default_tool("nope") is None
|
|
|
|
def test_synthesize_includes_display_name(self):
|
|
row = default_tools.synthesize_default_tool("read_webpage")
|
|
assert row["display_name"]
|
|
assert isinstance(row["description"], str)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Opt-out list
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestDisabledList:
|
|
def test_none_user_doc_yields_empty(self):
|
|
assert default_tools.disabled_default_tools(None) == []
|
|
|
|
def test_missing_preferences_yields_empty(self):
|
|
assert default_tools.disabled_default_tools({"user_id": "u"}) == []
|
|
|
|
def test_reads_disabled_list(self):
|
|
doc = {"tool_preferences": {"disabled_default_tools": ["read_webpage"]}}
|
|
assert default_tools.disabled_default_tools(doc) == ["read_webpage"]
|
|
|
|
def test_malformed_preferences_yields_empty(self):
|
|
assert default_tools.disabled_default_tools(
|
|
{"tool_preferences": "not-a-dict"}
|
|
) == []
|
|
assert default_tools.disabled_default_tools(
|
|
{"tool_preferences": {"disabled_default_tools": "x"}}
|
|
) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chat resolver — synthesized defaults
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestSynthesizedDefaults:
|
|
def test_all_defaults_present_when_nothing_disabled(self):
|
|
rows = default_tools.synthesized_default_tools(None)
|
|
names = {r["name"] for r in rows}
|
|
assert names == set(default_tools.loaded_default_tools())
|
|
|
|
def test_opt_out_removes_a_tool(self):
|
|
doc = {"tool_preferences": {"disabled_default_tools": ["read_webpage"]}}
|
|
rows = default_tools.synthesized_default_tools(doc)
|
|
names = {r["name"] for r in rows}
|
|
assert "read_webpage" not in names
|
|
assert "memory" in names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# default_tools_for_management — the tool-management listing
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestDefaultToolsForManagement:
|
|
def test_lists_every_loaded_default(self):
|
|
rows = default_tools.default_tools_for_management(None)
|
|
assert {r["name"] for r in rows} == set(
|
|
default_tools.loaded_default_tools()
|
|
)
|
|
|
|
def test_all_enabled_when_nothing_disabled(self):
|
|
rows = default_tools.default_tools_for_management(None)
|
|
assert all(r["status"] is True for r in rows)
|
|
|
|
def test_disabled_default_still_listed_with_status_false(self):
|
|
doc = {"tool_preferences": {"disabled_default_tools": ["read_webpage"]}}
|
|
rows = default_tools.default_tools_for_management(doc)
|
|
by_name = {r["name"]: r for r in rows}
|
|
assert "read_webpage" in by_name
|
|
assert by_name["read_webpage"]["status"] is False
|
|
assert by_name["memory"]["status"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_tool_by_id
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestResolveToolById:
|
|
def test_synthetic_id_resolves_in_memory(self):
|
|
tool_id = default_tools.default_tool_id("memory")
|
|
row = default_tools.resolve_tool_by_id(tool_id, "user-x")
|
|
assert row is not None
|
|
assert row["name"] == "memory"
|
|
assert row["id"] == tool_id
|
|
|
|
def test_non_default_id_delegates_to_repo(self):
|
|
sentinel = {"id": "real", "name": "brave"}
|
|
|
|
class _Repo:
|
|
def get_any(self, tool_id, user):
|
|
assert user == "user-x"
|
|
return sentinel
|
|
|
|
row = default_tools.resolve_tool_by_id(
|
|
str(uuid.uuid4()), "user-x", user_tools_repo=_Repo()
|
|
)
|
|
assert row is sentinel
|
|
|
|
def test_non_default_id_without_repo_returns_none(self):
|
|
assert default_tools.resolve_tool_by_id(str(uuid.uuid4()), "user-x") is None
|
|
|
|
def test_builtin_agent_tool_id_resolves_in_memory(self):
|
|
"""Dual-registered scheduler resolves with BOTH ``default`` and
|
|
``builtin`` flags so either path can branch on the discriminator."""
|
|
tool_id = default_tools.default_tool_id("scheduler")
|
|
row = default_tools.resolve_tool_by_id(tool_id, "user-x")
|
|
assert row is not None
|
|
assert row["name"] == "scheduler"
|
|
assert row["builtin"] is True
|
|
assert row["default"] is True
|
|
|
|
@pytest.mark.parametrize("name", ["code_executor", "artifact_generator"])
|
|
def test_sandbox_tool_id_resolves_in_memory(self, name):
|
|
# Both sandbox tools are opt-in per agent (removed from DEFAULT_CHAT_TOOLS)
|
|
# but stay registered as agent-selectable builtins, so their synthetic id
|
|
# still resolves to an in-memory row (loaded user-scoped at execute time)
|
|
# — an agent that enabled one never silently loses it.
|
|
assert name not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
|
assert name in default_tools.BUILTIN_AGENT_TOOLS
|
|
tool_id = default_tools.default_tool_id(name)
|
|
row = default_tools.resolve_tool_by_id(tool_id, "user-x")
|
|
assert row is not None
|
|
assert row["name"] == name
|
|
assert row["id"] == tool_id
|
|
assert row["builtin"] is True
|
|
|
|
def test_read_document_builtin_id_resolves_workflow_only(self):
|
|
# read_document is a workflow-only builtin: its synthetic id resolves
|
|
# to a row flagged workflow_only for the frontend to gate visibility.
|
|
tool_id = default_tools.default_tool_id("read_document")
|
|
row = default_tools.resolve_tool_by_id(tool_id, "user-x")
|
|
assert row is not None
|
|
assert row["name"] == "read_document"
|
|
assert row["builtin"] is True
|
|
assert row["workflow_only"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent-selectable builtins (scheduler) — synthesized like defaults but
|
|
# hidden from agentless-chat synthesis and from /api/available_tools.
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestBuiltinAgentTools:
|
|
def test_scheduler_is_a_builtin(self):
|
|
assert "scheduler" in default_tools.BUILTIN_AGENT_TOOLS
|
|
|
|
def test_scheduler_dual_registered_in_default_chat_tools(self):
|
|
# Revised decision #8: scheduler is dual-registered as a default
|
|
# chat tool (auto-on in agentless chats) AND a builtin agent tool
|
|
# (opt-in via the agent picker). Both registries share the same
|
|
# ``_DEFAULT_TOOL_NAMESPACE`` so the synthetic id is one stable uuid5.
|
|
assert "scheduler" in default_tools.settings.DEFAULT_CHAT_TOOLS
|
|
|
|
def test_dual_registration_produces_one_synthetic_id(self):
|
|
# Same uuid5 namespace → same id whether reached via defaults or builtins.
|
|
as_default = default_tools.default_tool_id("scheduler")
|
|
assert default_tools.is_default_tool_id(as_default)
|
|
assert default_tools.is_builtin_agent_tool_id(as_default)
|
|
|
|
def test_builtin_id_is_recognised(self):
|
|
tool_id = default_tools.default_tool_id("scheduler")
|
|
assert default_tools.is_builtin_agent_tool_id(tool_id)
|
|
assert default_tools.builtin_agent_tool_name_for_id(tool_id) == "scheduler"
|
|
|
|
def test_synthesize_builtin_marks_flags_correctly(self):
|
|
row = default_tools.synthesize_builtin_agent_tool("scheduler")
|
|
assert row is not None
|
|
assert row["name"] == "scheduler"
|
|
assert row["default"] is False
|
|
assert row["builtin"] is True
|
|
assert isinstance(row["actions"], list) and row["actions"]
|
|
|
|
def test_builtin_agent_tools_for_management_lists_scheduler(self):
|
|
rows = default_tools.builtin_agent_tools_for_management()
|
|
names = {r["name"] for r in rows}
|
|
assert "scheduler" in names
|
|
for row in rows:
|
|
assert row["builtin"] is True
|
|
assert row["default"] is False
|
|
|
|
def test_synthesized_default_chat_now_includes_scheduler(self):
|
|
# Revised decision #8: scheduler is dual-registered → it appears in
|
|
# ``synthesized_default_tools`` so agentless chats can use it.
|
|
rows = default_tools.synthesized_default_tools(None)
|
|
assert "scheduler" in {r["name"] for r in rows}
|
|
|
|
def test_read_document_is_a_builtin(self):
|
|
assert "read_document" in default_tools.BUILTIN_AGENT_TOOLS
|
|
assert "read_document" in default_tools.WORKFLOW_ONLY_BUILTINS
|
|
|
|
def test_read_document_not_a_default_chat_tool(self):
|
|
# read_document is a builtin only — never a default agentless chat tool.
|
|
assert "read_document" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
|
rows = default_tools.synthesized_default_tools(None)
|
|
assert "read_document" not in {r["name"] for r in rows}
|
|
|
|
def test_synthesize_read_document_flags_workflow_only(self):
|
|
row = default_tools.synthesize_builtin_agent_tool("read_document")
|
|
assert row is not None
|
|
assert row["builtin"] is True
|
|
assert row["default"] is False
|
|
assert row["workflow_only"] is True
|
|
assert isinstance(row["actions"], list) and row["actions"]
|
|
|
|
def test_scheduler_builtin_is_not_workflow_only(self):
|
|
row = default_tools.synthesize_builtin_agent_tool("scheduler")
|
|
assert row["workflow_only"] is False
|
|
|
|
def test_code_executor_is_agent_selectable_builtin(self):
|
|
# Off by default (removed from DEFAULT_CHAT_TOOLS) but still reachable: a
|
|
# non-workflow-only builtin, so an agent can enable it, it stays in the
|
|
# picker, and its synthetic id resolves (no silent drop for agents that
|
|
# already had it enabled).
|
|
assert "code_executor" not in default_tools.settings.DEFAULT_CHAT_TOOLS
|
|
assert "code_executor" in default_tools.BUILTIN_AGENT_TOOLS
|
|
assert "code_executor" not in default_tools.WORKFLOW_ONLY_BUILTINS
|
|
row = default_tools.synthesize_builtin_agent_tool("code_executor")
|
|
assert row is not None
|
|
assert row["builtin"] is True and row["default"] is False
|
|
assert row["workflow_only"] is False
|
|
names = {r["name"] for r in default_tools.builtin_agent_tools_for_management()}
|
|
assert "code_executor" in names
|
|
resolved = default_tools.resolve_tool_by_id(
|
|
default_tools.default_tool_id("code_executor"), "user-1"
|
|
)
|
|
assert resolved is not None and resolved["name"] == "code_executor"
|
|
|
|
def test_builtin_management_marks_workflow_only(self):
|
|
rows = default_tools.builtin_agent_tools_for_management()
|
|
by_name = {r["name"]: r for r in rows}
|
|
assert by_name["read_document"]["workflow_only"] is True
|
|
assert by_name["scheduler"]["workflow_only"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _FK_BOUND_TOOLS — schema introspection guard against rot
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
class TestFkBoundToolsIsInSync:
|
|
# Table name -> tool module name (``application/agents/tools/<name>``).
|
|
_TABLE_TO_TOOL = {
|
|
"notes": "notes",
|
|
"todos": "todo_list",
|
|
}
|
|
|
|
def test_fk_bound_tools_matches_metadata(self):
|
|
from application.storage.db.models import metadata
|
|
|
|
fk_bound_tables = set()
|
|
for tbl in metadata.tables.values():
|
|
tool_id_col = tbl.columns.get("tool_id")
|
|
if tool_id_col is None:
|
|
continue
|
|
for fk in tool_id_col.foreign_keys:
|
|
if fk.target_fullname == "user_tools.id":
|
|
fk_bound_tables.add(tbl.name)
|
|
break
|
|
|
|
unmapped = fk_bound_tables - set(self._TABLE_TO_TOOL)
|
|
assert not unmapped, (
|
|
f"New FK-bound table(s) without a tool mapping: {sorted(unmapped)}. "
|
|
"Add an entry to _TABLE_TO_TOOL here AND to "
|
|
"application.agents.default_tools._FK_BOUND_TOOLS."
|
|
)
|
|
derived_names = {
|
|
self._TABLE_TO_TOOL[name] for name in fk_bound_tables
|
|
}
|
|
assert derived_names == set(default_tools._FK_BOUND_TOOLS), (
|
|
"_FK_BOUND_TOOLS is out of sync with schema-derived names: "
|
|
f"derived={sorted(derived_names)} "
|
|
f"declared={sorted(default_tools._FK_BOUND_TOOLS)}"
|
|
)
|