hexdag 0.5.0.dev1__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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- hexdag_plugins/storage/vector/pgvector.py +502 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# type: ignore
|
|
2
|
+
"""SQLAlchemy adapter implementation for hexDAG."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import AsyncIterator, Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import MetaData, Table, inspect, select, text
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
9
|
+
|
|
10
|
+
from hexdag.core.ports.database import (
|
|
11
|
+
ColumnSchema,
|
|
12
|
+
DatabasePort,
|
|
13
|
+
SupportsIndexes,
|
|
14
|
+
SupportsRawSQL,
|
|
15
|
+
SupportsStatistics,
|
|
16
|
+
TableSchema,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SQLAlchemyAdapter(DatabasePort, SupportsRawSQL, SupportsIndexes, SupportsStatistics):
|
|
21
|
+
"""Adapter for SQLAlchemy-supported databases."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, dsn: str) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Initialize SQLAlchemy adapter.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
dsn: Database connection string (e.g., postgresql+asyncpg://user:pass@localhost/db)
|
|
29
|
+
"""
|
|
30
|
+
self.engine: AsyncEngine | None = None
|
|
31
|
+
self.dsn = dsn
|
|
32
|
+
self._metadata = MetaData()
|
|
33
|
+
|
|
34
|
+
async def connect(self) -> None:
|
|
35
|
+
"""Establish database connection."""
|
|
36
|
+
self.engine = create_async_engine(self.dsn)
|
|
37
|
+
async with self.engine.connect() as conn:
|
|
38
|
+
# Force reflection after any schema changes
|
|
39
|
+
await conn.run_sync(self._metadata.reflect)
|
|
40
|
+
|
|
41
|
+
async def disconnect(self) -> None:
|
|
42
|
+
"""Close database connection."""
|
|
43
|
+
if self.engine:
|
|
44
|
+
await self.engine.dispose()
|
|
45
|
+
self.engine = None
|
|
46
|
+
|
|
47
|
+
async def aget_table_schemas(self) -> dict[str, dict[str, Any]]:
|
|
48
|
+
"""
|
|
49
|
+
Get schema information for all tables in DatabasePort format.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Dictionary mapping table names to schema information.
|
|
53
|
+
"""
|
|
54
|
+
if not self.engine:
|
|
55
|
+
raise RuntimeError("Not connected to database")
|
|
56
|
+
|
|
57
|
+
schemas = {}
|
|
58
|
+
for table_name, table in self._metadata.tables.items():
|
|
59
|
+
columns = {}
|
|
60
|
+
primary_keys = []
|
|
61
|
+
foreign_keys = []
|
|
62
|
+
|
|
63
|
+
for col in table.columns:
|
|
64
|
+
columns[col.name] = str(col.type)
|
|
65
|
+
|
|
66
|
+
if col.primary_key:
|
|
67
|
+
primary_keys.append(col.name)
|
|
68
|
+
|
|
69
|
+
if col.foreign_keys:
|
|
70
|
+
fk = next(iter(col.foreign_keys))
|
|
71
|
+
foreign_keys.append({
|
|
72
|
+
"from_column": col.name,
|
|
73
|
+
"to_table": fk.column.table.name,
|
|
74
|
+
"to_column": fk.column.name,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
schemas[table_name] = {
|
|
78
|
+
"table_name": table_name,
|
|
79
|
+
"columns": columns,
|
|
80
|
+
"primary_keys": primary_keys,
|
|
81
|
+
"foreign_keys": foreign_keys,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return schemas
|
|
85
|
+
|
|
86
|
+
async def aexecute_query(
|
|
87
|
+
self, query: str, params: dict[str, Any] | None = None
|
|
88
|
+
) -> list[dict[str, Any]]:
|
|
89
|
+
"""Execute a SQL query and return results.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
query: SQL query string
|
|
93
|
+
params: Optional query parameters for safe parameterized queries
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
List of dictionaries representing query result rows
|
|
97
|
+
"""
|
|
98
|
+
if not self.engine:
|
|
99
|
+
raise RuntimeError("Not connected to database")
|
|
100
|
+
|
|
101
|
+
results = []
|
|
102
|
+
async with self.engine.connect() as conn:
|
|
103
|
+
result = await conn.execute(text(query), params or {})
|
|
104
|
+
results.extend(dict(row._mapping) for row in result)
|
|
105
|
+
return results
|
|
106
|
+
|
|
107
|
+
async def get_table_schemas(self) -> Sequence[TableSchema]:
|
|
108
|
+
"""
|
|
109
|
+
Get schema information for all tables.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Sequence[TableSchema]: List of table schemas
|
|
113
|
+
"""
|
|
114
|
+
if not self.engine:
|
|
115
|
+
raise RuntimeError("Not connected to database")
|
|
116
|
+
|
|
117
|
+
schemas = []
|
|
118
|
+
for table_name, table in self._metadata.tables.items():
|
|
119
|
+
columns = []
|
|
120
|
+
for col in table.columns:
|
|
121
|
+
foreign_key = None
|
|
122
|
+
if col.foreign_keys:
|
|
123
|
+
fk = next(iter(col.foreign_keys))
|
|
124
|
+
foreign_key = f"{fk.column.table.name}.{fk.column.name}"
|
|
125
|
+
|
|
126
|
+
nullable = bool(col.nullable) if col.nullable is not None else False
|
|
127
|
+
|
|
128
|
+
columns.append(
|
|
129
|
+
ColumnSchema(
|
|
130
|
+
name=col.name,
|
|
131
|
+
type=col.type.value,
|
|
132
|
+
nullable=nullable,
|
|
133
|
+
primary_key=col.primary_key,
|
|
134
|
+
foreign_key=foreign_key,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
schemas.append(TableSchema(name=table_name, columns=columns))
|
|
138
|
+
|
|
139
|
+
return schemas
|
|
140
|
+
|
|
141
|
+
def query(
|
|
142
|
+
self,
|
|
143
|
+
table: str,
|
|
144
|
+
filters: dict[str, Any] | None = None,
|
|
145
|
+
columns: list[str] | None = None,
|
|
146
|
+
limit: int | None = None,
|
|
147
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
148
|
+
"""
|
|
149
|
+
Query rows from a table with filtering and column selection.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
table: Table name
|
|
153
|
+
filters: Optional column-value pairs to filter by
|
|
154
|
+
columns: Optional list of columns to return (None = all)
|
|
155
|
+
limit: Optional maximum number of rows to return
|
|
156
|
+
Returns:
|
|
157
|
+
AsyncIterator[dict[str, Any]]: An async iterator over rows as dictionaries
|
|
158
|
+
"""
|
|
159
|
+
if not self.engine:
|
|
160
|
+
raise RuntimeError("Not connected to database")
|
|
161
|
+
|
|
162
|
+
table_obj = Table(table, self._metadata, extend_existing=True)
|
|
163
|
+
query = select(table_obj)
|
|
164
|
+
|
|
165
|
+
if columns:
|
|
166
|
+
query = select(*[table_obj.c[col] for col in columns])
|
|
167
|
+
|
|
168
|
+
if filters:
|
|
169
|
+
conditions = [table_obj.c[k] == v for k, v in filters.items()]
|
|
170
|
+
query = query.where(*conditions)
|
|
171
|
+
|
|
172
|
+
if limit:
|
|
173
|
+
query = query.limit(limit)
|
|
174
|
+
|
|
175
|
+
async def generate_rows() -> AsyncIterator[dict[str, Any]]:
|
|
176
|
+
if not self.engine: # Recheck engine in case it was closed
|
|
177
|
+
raise RuntimeError("Database connection lost")
|
|
178
|
+
async with self.engine.connect() as conn:
|
|
179
|
+
result = await conn.stream(query)
|
|
180
|
+
async for row in result:
|
|
181
|
+
yield dict(row._mapping)
|
|
182
|
+
|
|
183
|
+
return generate_rows()
|
|
184
|
+
|
|
185
|
+
def query_raw(
|
|
186
|
+
self, sql: str, params: dict[str, Any] | None = None
|
|
187
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
188
|
+
"""Execute a raw SQL query."""
|
|
189
|
+
if not self.engine:
|
|
190
|
+
raise RuntimeError("Not connected to database")
|
|
191
|
+
|
|
192
|
+
async def generate_rows() -> AsyncIterator[dict[str, Any]]:
|
|
193
|
+
if not self.engine:
|
|
194
|
+
raise RuntimeError("Database connection lost")
|
|
195
|
+
async with self.engine.connect() as conn:
|
|
196
|
+
result = await conn.stream(text(sql), params or {})
|
|
197
|
+
async for row in result:
|
|
198
|
+
yield dict(row._mapping)
|
|
199
|
+
|
|
200
|
+
return generate_rows()
|
|
201
|
+
|
|
202
|
+
async def execute_raw(self, sql: str, params: dict[str, Any] | None = None) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Execute a raw SQL statement without returning results.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
sql: SQL statement to execute
|
|
208
|
+
params: Optional parameters for the SQL statement
|
|
209
|
+
"""
|
|
210
|
+
if not self.engine:
|
|
211
|
+
raise RuntimeError("Not connected to database")
|
|
212
|
+
|
|
213
|
+
async with self.engine.connect() as conn:
|
|
214
|
+
await conn.execute(text(sql), params or {})
|
|
215
|
+
await conn.commit()
|
|
216
|
+
|
|
217
|
+
async def get_indexes(self, table: str) -> list[str]:
|
|
218
|
+
"""
|
|
219
|
+
Get index information for a table.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
table: Table name
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
list[str]: List of index names
|
|
226
|
+
"""
|
|
227
|
+
if not self.engine:
|
|
228
|
+
raise RuntimeError("Not connected to database")
|
|
229
|
+
|
|
230
|
+
async with self.engine.connect() as conn:
|
|
231
|
+
# Remove engine argument from inspect call
|
|
232
|
+
inspector = await conn.run_sync(inspect)
|
|
233
|
+
# Filter out None values to ensure list[str]
|
|
234
|
+
return [idx["name"] for idx in inspector.get_indexes(table) if idx["name"] is not None]
|
|
235
|
+
|
|
236
|
+
async def get_table_statistics(self, table: str) -> dict[str, int]:
|
|
237
|
+
"""
|
|
238
|
+
Get statistical information about a table.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
table: Table name
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
dict[str, int]: Statistics including row count
|
|
245
|
+
"""
|
|
246
|
+
if not self.engine:
|
|
247
|
+
raise RuntimeError("Not connected to database")
|
|
248
|
+
|
|
249
|
+
async with self.engine.connect() as conn:
|
|
250
|
+
result = await conn.execute(text(f"SELECT COUNT(*) as count FROM {table}")) # nosec
|
|
251
|
+
row = await result.fetchone() # type: ignore # type: ignore
|
|
252
|
+
return {"row_count": int(row[0]) if row else 0}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""SQLite database adapter implementation with async support."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aiosqlite
|
|
10
|
+
|
|
11
|
+
from hexdag.core.logging import get_logger
|
|
12
|
+
from hexdag.core.utils.sql_validation import validate_sql_identifier
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SQLiteAdapter:
|
|
18
|
+
"""Async SQLite adapter for database port.
|
|
19
|
+
|
|
20
|
+
Provides a lightweight, file-based database solution that implements
|
|
21
|
+
the DatabasePort interface for SQL execution and schema introspection.
|
|
22
|
+
All operations are fully async using aiosqlite.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
26
|
+
"""Initialize SQLite adapter.
|
|
27
|
+
|
|
28
|
+
Args
|
|
29
|
+
----
|
|
30
|
+
**kwargs: Configuration options (db_path, timeout, etc.)
|
|
31
|
+
"""
|
|
32
|
+
db_path = kwargs.get("db_path", ":memory:")
|
|
33
|
+
self.db_path = Path(db_path) if db_path != ":memory:" else db_path
|
|
34
|
+
self.check_same_thread = kwargs.get("check_same_thread", False)
|
|
35
|
+
self.timeout = kwargs.get("timeout", 5.0)
|
|
36
|
+
self.journal_mode = kwargs.get("journal_mode", "WAL")
|
|
37
|
+
self.foreign_keys = kwargs.get("foreign_keys", True)
|
|
38
|
+
self.read_only = kwargs.get("read_only", False)
|
|
39
|
+
|
|
40
|
+
self.connection: aiosqlite.Connection | None = None
|
|
41
|
+
|
|
42
|
+
async def _ensure_database(self) -> None:
|
|
43
|
+
"""Ensure database connection exists and is configured."""
|
|
44
|
+
if self.connection is not None:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
db_path = str(self.db_path) if isinstance(self.db_path, Path) else self.db_path
|
|
48
|
+
|
|
49
|
+
if self.read_only and db_path != ":memory:":
|
|
50
|
+
# Use URI mode for read-only access
|
|
51
|
+
db_uri = f"file:{db_path}?mode=ro"
|
|
52
|
+
self.connection = await aiosqlite.connect(
|
|
53
|
+
db_uri,
|
|
54
|
+
uri=True,
|
|
55
|
+
check_same_thread=self.check_same_thread,
|
|
56
|
+
timeout=self.timeout,
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
self.connection = await aiosqlite.connect(
|
|
60
|
+
db_path,
|
|
61
|
+
check_same_thread=self.check_same_thread,
|
|
62
|
+
timeout=self.timeout,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.connection.row_factory = aiosqlite.Row # Return rows as dictionaries
|
|
66
|
+
|
|
67
|
+
# Configure database settings
|
|
68
|
+
if self.connection:
|
|
69
|
+
async with self.connection.cursor() as cursor:
|
|
70
|
+
if self.journal_mode:
|
|
71
|
+
await cursor.execute(f"PRAGMA journal_mode = {self.journal_mode}")
|
|
72
|
+
if self.foreign_keys:
|
|
73
|
+
await cursor.execute("PRAGMA foreign_keys = ON")
|
|
74
|
+
await self.connection.commit()
|
|
75
|
+
|
|
76
|
+
@asynccontextmanager
|
|
77
|
+
async def _get_cursor(self) -> AsyncIterator[aiosqlite.Cursor]:
|
|
78
|
+
"""Context manager for database cursor.
|
|
79
|
+
|
|
80
|
+
Ensures proper cursor cleanup and error handling.
|
|
81
|
+
|
|
82
|
+
Yields
|
|
83
|
+
------
|
|
84
|
+
aiosqlite.Cursor
|
|
85
|
+
Async SQLite database cursor
|
|
86
|
+
|
|
87
|
+
Raises
|
|
88
|
+
------
|
|
89
|
+
RuntimeError
|
|
90
|
+
If database connection is not established
|
|
91
|
+
aiosqlite.Error
|
|
92
|
+
If database error occurs during operation
|
|
93
|
+
"""
|
|
94
|
+
await self._ensure_database()
|
|
95
|
+
if self.connection is None:
|
|
96
|
+
raise RuntimeError("Database connection not established")
|
|
97
|
+
|
|
98
|
+
async with self.connection.cursor() as cursor:
|
|
99
|
+
try:
|
|
100
|
+
yield cursor
|
|
101
|
+
except aiosqlite.Error as e:
|
|
102
|
+
logger.error(f"Database error: {e}")
|
|
103
|
+
await self.connection.rollback()
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
async def _get_all_tables(self) -> list[str]:
|
|
107
|
+
"""Get all non-system tables from the database.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
List of table names
|
|
112
|
+
"""
|
|
113
|
+
async with self._get_cursor() as cursor:
|
|
114
|
+
await cursor.execute(
|
|
115
|
+
"""
|
|
116
|
+
SELECT name FROM sqlite_master
|
|
117
|
+
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
118
|
+
"""
|
|
119
|
+
)
|
|
120
|
+
rows = await cursor.fetchall()
|
|
121
|
+
return [row[0] for row in rows]
|
|
122
|
+
|
|
123
|
+
def _validate_identifier(self, identifier: str, identifier_type: str = "table") -> bool:
|
|
124
|
+
"""Validate a database identifier to prevent injection.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
identifier : str
|
|
129
|
+
The identifier to validate (table name, column name, etc.)
|
|
130
|
+
identifier_type : str
|
|
131
|
+
Type of identifier for error messages
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
bool
|
|
136
|
+
True if valid, False otherwise
|
|
137
|
+
"""
|
|
138
|
+
return validate_sql_identifier(identifier, identifier_type=identifier_type)
|
|
139
|
+
|
|
140
|
+
async def aget_table_schemas(self) -> dict[str, dict[str, Any]]:
|
|
141
|
+
"""Get schema information for all tables.
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
Dictionary mapping table names to schema information
|
|
146
|
+
"""
|
|
147
|
+
tables = await self._get_all_tables()
|
|
148
|
+
schemas = {}
|
|
149
|
+
|
|
150
|
+
async with self._get_cursor() as cursor:
|
|
151
|
+
for table in tables:
|
|
152
|
+
if not self._validate_identifier(table):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
await cursor.execute(f'PRAGMA table_info("{table}")') # nosec B608 - validated
|
|
156
|
+
rows = await cursor.fetchall()
|
|
157
|
+
|
|
158
|
+
columns = {}
|
|
159
|
+
primary_keys = []
|
|
160
|
+
for row in rows:
|
|
161
|
+
col_name = row[1]
|
|
162
|
+
col_type = row[2]
|
|
163
|
+
is_pk = row[5]
|
|
164
|
+
|
|
165
|
+
columns[col_name] = col_type
|
|
166
|
+
if is_pk:
|
|
167
|
+
primary_keys.append(col_name)
|
|
168
|
+
|
|
169
|
+
# nosec B608 - validated
|
|
170
|
+
await cursor.execute(f'PRAGMA foreign_key_list("{table}")')
|
|
171
|
+
fk_rows = await cursor.fetchall()
|
|
172
|
+
foreign_keys = [
|
|
173
|
+
{
|
|
174
|
+
"from_column": fk_row[3],
|
|
175
|
+
"to_table": fk_row[2],
|
|
176
|
+
"to_column": fk_row[4],
|
|
177
|
+
}
|
|
178
|
+
for fk_row in fk_rows
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
schemas[table] = {
|
|
182
|
+
"table_name": table,
|
|
183
|
+
"columns": columns,
|
|
184
|
+
"primary_keys": primary_keys,
|
|
185
|
+
"foreign_keys": foreign_keys,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return schemas
|
|
189
|
+
|
|
190
|
+
async def aexecute_query(
|
|
191
|
+
self, query: str, params: dict[str, Any] | None = None
|
|
192
|
+
) -> list[dict[str, Any]]:
|
|
193
|
+
"""Execute a SQL query and return results.
|
|
194
|
+
|
|
195
|
+
Args
|
|
196
|
+
----
|
|
197
|
+
query: SQL query to execute
|
|
198
|
+
params: Optional query parameters for safe parameterized queries
|
|
199
|
+
|
|
200
|
+
Returns
|
|
201
|
+
-------
|
|
202
|
+
List of dictionaries representing query result rows.
|
|
203
|
+
For non-SELECT queries (INSERT/UPDATE/DELETE), returns an empty list
|
|
204
|
+
and commits the transaction.
|
|
205
|
+
|
|
206
|
+
Notes
|
|
207
|
+
-----
|
|
208
|
+
- SELECT/PRAGMA/WITH queries return result rows
|
|
209
|
+
- INSERT/UPDATE/DELETE queries are committed automatically and return []
|
|
210
|
+
- Use parameters to prevent SQL injection with :name placeholders
|
|
211
|
+
|
|
212
|
+
Raises
|
|
213
|
+
------
|
|
214
|
+
ValueError
|
|
215
|
+
If a required parameter is missing
|
|
216
|
+
aiosqlite.Error
|
|
217
|
+
If query execution fails
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
async with self._get_cursor() as cursor:
|
|
221
|
+
if params:
|
|
222
|
+
# SQLite uses ? placeholders, but we support :name format
|
|
223
|
+
param_names = []
|
|
224
|
+
|
|
225
|
+
def replacer(match: re.Match[str]) -> str:
|
|
226
|
+
param_names.append(match.group(1))
|
|
227
|
+
return "?"
|
|
228
|
+
|
|
229
|
+
converted_query = re.sub(r":(\w+)", replacer, query)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
param_values = [params[name] for name in param_names]
|
|
233
|
+
await cursor.execute(converted_query, param_values)
|
|
234
|
+
except KeyError as e:
|
|
235
|
+
raise ValueError(f"Missing parameter: {e}") from e
|
|
236
|
+
else:
|
|
237
|
+
await cursor.execute(query)
|
|
238
|
+
|
|
239
|
+
# Determine query type
|
|
240
|
+
query_type = query.strip().upper().split()[0] if query.strip() else ""
|
|
241
|
+
|
|
242
|
+
# For SELECT queries, fetch results
|
|
243
|
+
if query_type in ("SELECT", "PRAGMA", "WITH"):
|
|
244
|
+
rows = await cursor.fetchall()
|
|
245
|
+
return [dict(row) for row in rows]
|
|
246
|
+
# For INSERT/UPDATE/DELETE operations
|
|
247
|
+
# Note: If database is opened in read-only mode, SQLite will
|
|
248
|
+
# automatically raise an OperationalError for write operations
|
|
249
|
+
affected_rows = cursor.rowcount
|
|
250
|
+
if self.connection is not None:
|
|
251
|
+
await self.connection.commit()
|
|
252
|
+
|
|
253
|
+
# Log the operation
|
|
254
|
+
logger.info(f"Executed {query_type} query, affected rows: {affected_rows}")
|
|
255
|
+
return []
|
|
256
|
+
except aiosqlite.Error as e:
|
|
257
|
+
logger.error(f"Query execution failed: {e}")
|
|
258
|
+
logger.error(f"Query: {query[:100]}...") # Log first 100 chars of query
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
async def aget_relationships(self) -> list[dict[str, Any]]:
|
|
262
|
+
"""Get foreign key relationships between tables.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
List of relationship dictionaries
|
|
267
|
+
"""
|
|
268
|
+
tables = await self._get_all_tables()
|
|
269
|
+
relationships: list[dict[str, Any]] = []
|
|
270
|
+
|
|
271
|
+
async with self._get_cursor() as cursor:
|
|
272
|
+
for table in tables:
|
|
273
|
+
if not self._validate_identifier(table):
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
# nosec B608 - validated
|
|
277
|
+
await cursor.execute(f'PRAGMA foreign_key_list("{table}")')
|
|
278
|
+
fk_rows = await cursor.fetchall()
|
|
279
|
+
|
|
280
|
+
relationships.extend(
|
|
281
|
+
{
|
|
282
|
+
"from_table": table,
|
|
283
|
+
"from_column": fk_row[3],
|
|
284
|
+
"to_table": fk_row[2],
|
|
285
|
+
"to_column": fk_row[4],
|
|
286
|
+
"relationship_type": "many_to_one", # SQLite doesn't store this info
|
|
287
|
+
}
|
|
288
|
+
for fk_row in fk_rows
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return relationships
|
|
292
|
+
|
|
293
|
+
async def aget_indexes(self) -> list[dict[str, Any]]:
|
|
294
|
+
"""Get index information for performance optimization.
|
|
295
|
+
|
|
296
|
+
Returns
|
|
297
|
+
-------
|
|
298
|
+
List of index dictionaries
|
|
299
|
+
"""
|
|
300
|
+
indexes = []
|
|
301
|
+
|
|
302
|
+
async with self._get_cursor() as cursor:
|
|
303
|
+
await cursor.execute(
|
|
304
|
+
"""
|
|
305
|
+
SELECT name, tbl_name, sql FROM sqlite_master
|
|
306
|
+
WHERE type='index' AND sql IS NOT NULL
|
|
307
|
+
"""
|
|
308
|
+
)
|
|
309
|
+
index_rows = await cursor.fetchall()
|
|
310
|
+
|
|
311
|
+
for row in index_rows:
|
|
312
|
+
index_name = row[0]
|
|
313
|
+
table_name = row[1]
|
|
314
|
+
|
|
315
|
+
if not self._validate_identifier(index_name, "index"):
|
|
316
|
+
continue
|
|
317
|
+
if not self._validate_identifier(table_name, "table"):
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
await cursor.execute(f'PRAGMA index_info("{index_name}")') # nosec B608 - validated
|
|
321
|
+
col_rows = await cursor.fetchall()
|
|
322
|
+
columns = [col_row[2] for col_row in col_rows]
|
|
323
|
+
|
|
324
|
+
await cursor.execute(f'PRAGMA index_list("{table_name}")') # nosec B608 - validated
|
|
325
|
+
idx_list = await cursor.fetchall()
|
|
326
|
+
is_unique = False
|
|
327
|
+
for idx in idx_list:
|
|
328
|
+
if idx[1] == index_name:
|
|
329
|
+
is_unique = bool(idx[2])
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
indexes.append({
|
|
333
|
+
"index_name": index_name,
|
|
334
|
+
"table_name": table_name,
|
|
335
|
+
"columns": columns,
|
|
336
|
+
"index_type": "btree", # SQLite primarily uses B-tree
|
|
337
|
+
"is_unique": is_unique,
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
return indexes
|
|
341
|
+
|
|
342
|
+
async def aget_table_statistics(self) -> dict[str, dict[str, Any]]:
|
|
343
|
+
"""Get table statistics for query optimization.
|
|
344
|
+
|
|
345
|
+
Returns
|
|
346
|
+
-------
|
|
347
|
+
Dictionary mapping table names to statistics
|
|
348
|
+
"""
|
|
349
|
+
tables = await self._get_all_tables()
|
|
350
|
+
stats = {}
|
|
351
|
+
|
|
352
|
+
async with self._get_cursor() as cursor:
|
|
353
|
+
for table in tables:
|
|
354
|
+
if not self._validate_identifier(table):
|
|
355
|
+
continue # Skip invalid table names
|
|
356
|
+
|
|
357
|
+
# SQLite uses double quotes for identifiers
|
|
358
|
+
await cursor.execute(f'SELECT COUNT(*) FROM "{table}"') # nosec B608 - validated
|
|
359
|
+
result = await cursor.fetchone()
|
|
360
|
+
row_count = result[0] if result else 0
|
|
361
|
+
|
|
362
|
+
await cursor.execute(
|
|
363
|
+
"SELECT SUM(LENGTH(sql)) FROM sqlite_master WHERE tbl_name = ?",
|
|
364
|
+
(table,),
|
|
365
|
+
)
|
|
366
|
+
size_result = await cursor.fetchone()
|
|
367
|
+
size_bytes = size_result[0] if size_result and size_result[0] else 0
|
|
368
|
+
|
|
369
|
+
stats[table] = {
|
|
370
|
+
"row_count": row_count,
|
|
371
|
+
"size_bytes": size_bytes,
|
|
372
|
+
"last_updated": None, # SQLite doesn't track this
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return stats
|
|
376
|
+
|
|
377
|
+
async def close(self) -> None:
|
|
378
|
+
"""Close database connection."""
|
|
379
|
+
if self.connection:
|
|
380
|
+
await self.connection.close()
|
|
381
|
+
self.connection = None
|
|
382
|
+
|
|
383
|
+
async def __aenter__(self) -> "SQLiteAdapter":
|
|
384
|
+
"""Async context manager entry."""
|
|
385
|
+
await self._ensure_database()
|
|
386
|
+
return self
|
|
387
|
+
|
|
388
|
+
async def __aexit__(
|
|
389
|
+
self,
|
|
390
|
+
_exc_type: Any, # noqa: ARG002
|
|
391
|
+
_exc_val: Any, # noqa: ARG002
|
|
392
|
+
_exc_tb: Any, # noqa: ARG002
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Async context manager exit."""
|
|
395
|
+
await self.close()
|
|
396
|
+
|
|
397
|
+
def __repr__(self) -> str:
|
|
398
|
+
"""Return string representation."""
|
|
399
|
+
mode = "read-only" if self.read_only else "read-write"
|
|
400
|
+
return f"SQLiteAdapter(db_path='{self.db_path}', mode='{mode}')"
|
|
401
|
+
|
|
402
|
+
# SupportsReadOnly protocol method
|
|
403
|
+
async def is_read_only(self) -> bool:
|
|
404
|
+
"""Check if the adapter is in read-only mode.
|
|
405
|
+
|
|
406
|
+
Returns
|
|
407
|
+
-------
|
|
408
|
+
True if adapter is read-only, False otherwise
|
|
409
|
+
"""
|
|
410
|
+
return bool(self.read_only)
|