mcp-read-only-sql 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/AGENTS.md +5 -3
  2. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/CHANGELOG.md +16 -0
  3. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/PKG-INFO +2 -1
  4. mcp_read_only_sql-0.2.2/RELEASING.md +76 -0
  5. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/pyproject.toml +5 -1
  6. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/connection.py +1 -1
  7. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/dbeaver_import.py +54 -31
  8. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/loader.py +7 -5
  9. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/clickhouse/cli.py +74 -48
  10. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/clickhouse/python.py +3 -3
  11. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/postgresql/cli.py +52 -36
  12. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/postgresql/python.py +6 -3
  13. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/tools/test_ssh_tunnel.py +2 -0
  14. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/json_serializer.py +3 -3
  15. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/ssh_tunnel.py +5 -2
  16. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/timeout_wrapper.py +9 -8
  17. mcp_read_only_sql-0.2.1/RELEASING.md +0 -109
  18. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/.github/workflows/publish.yml +0 -0
  19. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/.github/workflows/test.yml +0 -0
  20. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/.gitignore +0 -0
  21. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/.mcp.json +0 -0
  22. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/LICENSE +0 -0
  23. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/README.md +0 -0
  24. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/READ_ONLY_ENFORCEMENT_MATRIX.md +0 -0
  25. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/conftest.py +0 -0
  26. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/connections.yaml.sample +0 -0
  27. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker/clickhouse/init/01_init.sql +0 -0
  28. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker/postgres/init/01_schema.sql +0 -0
  29. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker/postgres/init/02_data.sql +0 -0
  30. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker/ssh/Dockerfile +0 -0
  31. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/docker-compose.yml +0 -0
  32. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/justfile +0 -0
  33. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/pytest.ini +0 -0
  34. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/run_tests.sh +0 -0
  35. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/__init__.py +0 -0
  36. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/__init__.py +0 -0
  37. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/config/parser.py +0 -0
  38. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connections.yaml.sample +0 -0
  39. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/__init__.py +0 -0
  40. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/base.py +0 -0
  41. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/base_cli.py +0 -0
  42. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/clickhouse/__init__.py +0 -0
  43. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/connectors/postgresql/__init__.py +0 -0
  44. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/runtime_paths.py +0 -0
  45. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/server.py +0 -0
  46. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/tools/__init__.py +0 -0
  47. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/tools/test_connection.py +0 -0
  48. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/tools/validate_config.py +0 -0
  49. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/__init__.py +0 -0
  50. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/connection_utils.py +0 -0
  51. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/sql_guard.py +0 -0
  52. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/ssh_tunnel_cli.py +0 -0
  53. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/src/mcp_read_only_sql/utils/tsv_formatter.py +0 -0
  54. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/KNOWN_ISSUES.md +0 -0
  55. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/README.md +0 -0
  56. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/__init__.py +0 -0
  57. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/conftest.py +0 -0
  58. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/conftest_new.py +0 -0
  59. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/connections-test.yaml +0 -0
  60. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/docker_test_config.py +0 -0
  61. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/pytest_plugins.py +0 -0
  62. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/sql_statement_lists.py +0 -0
  63. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_cli_ssh_tunnels.py +0 -0
  64. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_cli_system_ssh.py +0 -0
  65. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_cli_versions.py +0 -0
  66. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_clickhouse_cli_fallback.py +0 -0
  67. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_config_connection.py +0 -0
  68. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_config_parser.py +0 -0
  69. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_connection_utils.py +0 -0
  70. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_connector_implementations.py +0 -0
  71. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_dbeaver_import.py +0 -0
  72. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_docker_connectivity.py +0 -0
  73. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_docker_test_config.py +0 -0
  74. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_error_handling.py +0 -0
  75. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_host_mapping_flow.py +0 -0
  76. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_limits.py +0 -0
  77. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_mcp_protocol.py +0 -0
  78. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_mcp_server.py +0 -0
  79. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_postgresql_cli_fallback.py +0 -0
  80. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_process_cleanup.py +0 -0
  81. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_result_serialization.py +0 -0
  82. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_run_query_file_output.py +0 -0
  83. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_runtime_paths.py +0 -0
  84. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_security_layers.py +0 -0
  85. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_security_readonly.py +0 -0
  86. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_security_readonly_integration.py +0 -0
  87. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_serialization_fallback.py +0 -0
  88. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_server.py +0 -0
  89. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_server_selection.py +0 -0
  90. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_ssh_timeout.py +0 -0
  91. {mcp_read_only_sql-0.2.1 → mcp_read_only_sql-0.2.2}/tests/test_ssh_tunnels.py +0 -0
@@ -1,7 +1,7 @@
1
1
  # Repository Guidelines
2
2
 
3
3
  ## Project Structure & Module Organization
4
- `src/mcp_read_only_sql/server.py` exposes the MCP entry point, while `src/mcp_read_only_sql/connectors/` hosts the PostgreSQL and ClickHouse adapters. Cross-cutting helpers (`ssh_tunnel.py`, `sql_guard.py`, TSV formatting) live in `src/mcp_read_only_sql/utils/`. Configuration tooling, including DBeaver import and manifest validation, sits in `src/mcp_read_only_sql/config/` and `src/mcp_read_only_sql/tools/`. Tests are grouped in `tests/`, and Docker fixtures for integration runs reside in `docker/`. The package ships a sample `connections.yaml`, and runtime config lives under `~/.config/lukleh/mcp-read-only-sql/`.
4
+ `src/mcp_read_only_sql/server.py` exposes the MCP entry point, while `src/mcp_read_only_sql/connectors/` hosts the PostgreSQL and ClickHouse adapters for both CLI and Python implementations. Cross-cutting helpers (`ssh_tunnel.py`, `ssh_tunnel_cli.py`, `sql_guard.py`, TSV formatting, timeout handling) live in `src/mcp_read_only_sql/utils/`. Configuration tooling, including DBeaver import and manifest validation, sits in `src/mcp_read_only_sql/config/` and `src/mcp_read_only_sql/tools/`. Tests are grouped in `tests/`, and Docker fixtures for integration runs reside in `docker/`. The package ships a sample `connections.yaml`, and runtime config lives under `~/.config/lukleh/mcp-read-only-sql/`.
5
5
 
6
6
  ## Build, Test, and Development Commands
7
7
  - `uv sync --extra dev` — install runtime and development dependencies.
@@ -11,9 +11,11 @@
11
11
  - `just validate` — lint `connections.yaml` against the schema and safety checks.
12
12
  - `just test` — spin up Dockerized fixtures and execute the full pytest suite via `./run_tests.sh`.
13
13
  - `uv run python -m pytest tests/test_sql_guard.py` — run an individual module when iterating quickly.
14
+ - `uv run ruff check .` and `uv run black .` — run linting and formatting for the Python tree.
15
+ - `uv run ty check` — type-check the full `src/` tree; there are no remaining package excludes.
14
16
 
15
17
  ## Coding Style & Naming Conventions
16
- Target Python 3.11+, four-space indentation, and Unix newlines. Format with `uv run black .` and lint via `uv run ruff check .`; CI mirrors these checks. Modules and callables use snake_case, classes PascalCase (e.g., `TestReadOnlyGuards`), and immutable settings uppercase (`DEFAULT_QUERY_TIMEOUT`). Apply type hints on public surfaces and keep docstrings brief, emphasising read-only guarantees.
18
+ Target Python 3.11+, four-space indentation, and Unix newlines. Format with `uv run black .`, lint via `uv run ruff check .`, and keep `uv run ty check` passing for `src/`. Modules and callables use snake_case, classes PascalCase (e.g., `TestReadOnlyGuards`), and immutable settings uppercase (`DEFAULT_QUERY_TIMEOUT`). Apply type hints on public surfaces and keep docstrings brief, emphasizing read-only guarantees and connector behavior.
17
19
 
18
20
  ## Testing Guidelines
19
21
  Pytest discovers `test_*.py` modules, `Test*` classes, and `test_*` functions per `pytest.ini`. Use the built-in markers (`security`, `cli`, `python`, `ssh`, `slow`) to scope runs, e.g. `pytest -m "security and not slow"`. A 30s default timeout applies, so tear down tunnels and subprocesses explicitly. `just test` emits JUnit XML at `test-results/pytest.xml` for CI uploads.
@@ -22,4 +24,4 @@ Pytest discovers `test_*.py` modules, `Test*` classes, and `test_*` functions pe
22
24
  Recent commits favour imperative, concise subjects (“Refactor PostgreSQL read-only guard into shared utility”). Keep changes focused and explain security or connector impacts in the body when needed. Pull requests should describe motivation, list affected modules or scripts, link issues, and attach logs or screenshots for operational updates.
23
25
 
24
26
  ## Security & Configuration Tips
25
- Do not relax safeguards in `src/mcp_read_only_sql/utils/sql_guard.py` or connector factories without matching tests. Treat `connections.yaml` as sensitive; the server stores database and SSH passwords directly in that file, so keep it private and user-readable only. Use the SSH tunnel helpers instead of bespoke subprocesses to preserve timeout and read-only enforcement.
27
+ Do not relax safeguards in `src/mcp_read_only_sql/utils/sql_guard.py` or connector factories without matching tests. Treat `connections.yaml` as sensitive; the server stores database and SSH passwords directly in that file, so keep it private and user-readable only. Use the shared SSH tunnel and timeout helpers instead of bespoke subprocesses so read-only enforcement, cleanup, and managed result-file behavior stay consistent.
@@ -7,6 +7,22 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.2] - 2026-04-03
11
+
12
+ ### Added
13
+
14
+ - Added `ty` as a supported development check for the full packaged `src/` tree.
15
+
16
+ ### Changed
17
+
18
+ - Added repo-specific `AGENTS.md` guidance covering connector layout, shared timeout and SSH helpers, and the typed development workflow.
19
+ - Reworked `RELEASING.md` into an evergreen release checklist with explicit validation, tagging, and publish steps.
20
+
21
+ ### Fixed
22
+
23
+ - Flushed the final buffered TSV line when PostgreSQL and ClickHouse CLI queries stream results to an output file.
24
+ - Hardened DBeaver credential import so missing or non-dictionary decrypted sections are ignored cleanly instead of being treated as valid connection data.
25
+
10
26
  ## [0.2.1] - 2026-04-02
11
27
 
12
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-read-only-sql
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: MCP server for read-only SQL queries supporting PostgreSQL and ClickHouse
5
5
  Project-URL: Homepage, https://github.com/lukleh/mcp-read-only-sql
6
6
  Project-URL: Repository, https://github.com/lukleh/mcp-read-only-sql
@@ -29,6 +29,7 @@ Requires-Dist: black>=23.0.0; extra == 'dev'
29
29
  Requires-Dist: pytest-timeout>=2.2.0; extra == 'dev'
30
30
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
31
31
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
32
+ Requires-Dist: ty>=0.0.28; extra == 'dev'
32
33
  Description-Content-Type: text/markdown
33
34
 
34
35
  # MCP Read-Only SQL Server
@@ -0,0 +1,76 @@
1
+ # Releasing `mcp-read-only-sql`
2
+
3
+ This repository publishes to PyPI from Git tags through GitHub Actions.
4
+
5
+ Release automation lives in:
6
+ - `.github/workflows/publish.yml`
7
+ - `.github/workflows/test.yml`
8
+ - the GitHub environment named `pypi`
9
+ - the PyPI trusted publisher for `lukleh/mcp-read-only-sql`
10
+
11
+ ## What To Change For A Release
12
+
13
+ Update these files in the release commit:
14
+
15
+ 1. `CHANGELOG.md`
16
+ Move the user-visible items from `## [Unreleased]` into a new section:
17
+ `## [X.Y.Z] - YYYY-MM-DD`
18
+ 2. `pyproject.toml`
19
+ Update `[project].version` to `X.Y.Z`
20
+
21
+ Do not expect a tracked `uv.lock` change in this repository. `uv.lock` is
22
+ gitignored here, so it is not part of the release diff.
23
+
24
+ `RELEASING.md` should stay evergreen. It should explain the process, not carry a
25
+ release-specific version number.
26
+
27
+ ## How To Update The Version
28
+
29
+ 1. Edit `pyproject.toml`
30
+ 2. Refresh the local environment if needed:
31
+
32
+ ```bash
33
+ uv sync --extra dev
34
+ ```
35
+
36
+ 3. Confirm the installed package metadata and CLI version output match:
37
+
38
+ ```bash
39
+ uv run --extra dev pytest tests/test_server.py tests/test_cli_versions.py -q -k 'package_version_matches_distribution_metadata or support_version'
40
+ ```
41
+
42
+ ## Pre-Release Validation
43
+
44
+ Run the normal local checks before tagging:
45
+
46
+ ```bash
47
+ uv run --extra dev ruff check .
48
+ uv run --extra dev ty check
49
+ ./run_tests.sh
50
+ ```
51
+
52
+ If you are iterating on release metadata only, the focused version tests above
53
+ are the minimum sanity check.
54
+
55
+ ## How To Publish
56
+
57
+ 1. Make the release commit on `main`
58
+ 2. Create and push the matching tag:
59
+
60
+ ```bash
61
+ git tag vX.Y.Z
62
+ git push origin main
63
+ git push origin vX.Y.Z
64
+ ```
65
+
66
+ 3. GitHub Actions starts `.github/workflows/publish.yml`
67
+ 4. The workflow runs the test matrix, builds the wheel and sdist, and smoke-tests the built artifacts
68
+ 5. The final publish job pauses on the GitHub `pypi` environment
69
+ 6. Approve the deployment in GitHub Actions
70
+ 7. GitHub publishes the package to PyPI
71
+
72
+ ## Notes
73
+
74
+ - Keep both `implementation: cli` and `implementation: python` clearly supported in release notes and docs
75
+ - `uvx` installs the Python package only; it does not install `psql`, `clickhouse-client`, or `sshpass`
76
+ - If the public CLI or result-file behavior changes, keep the package smoke tests and README aligned with `mcp-read-only-sql`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-read-only-sql"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "MCP server for read-only SQL queries supporting PostgreSQL and ClickHouse"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -39,6 +39,7 @@ dev = [
39
39
  "pytest-timeout>=2.2.0",
40
40
  "black>=23.0.0",
41
41
  "ruff>=0.1.0",
42
+ "ty>=0.0.28",
42
43
  ]
43
44
 
44
45
  [build-system]
@@ -50,3 +51,6 @@ mcp-read-only-sql = "mcp_read_only_sql.server:main"
50
51
 
51
52
  [tool.hatch.build.targets.wheel]
52
53
  packages = ["src/mcp_read_only_sql"]
54
+
55
+ [tool.ty.src]
56
+ include = ["src"]
@@ -109,7 +109,7 @@ class SSHTunnelConfig:
109
109
  raise ValueError("SSH tunnel timeout must be a positive integer")
110
110
 
111
111
  @classmethod
112
- def from_dict(cls, data: Dict[str, Any]) -> "SSHTunnelConfig":
112
+ def from_dict(cls, data: Dict[str, Any]) -> Optional["SSHTunnelConfig"]:
113
113
  """Create SSHTunnelConfig from dict with validation."""
114
114
  if not data.get("enabled", True):
115
115
  return None
@@ -31,10 +31,12 @@ class DBeaverImporter:
31
31
  self.last_requested_names: List[str] = []
32
32
  self.last_seen_names: List[str] = []
33
33
 
34
- def _decrypt_credentials(self) -> Dict[str, Any]:
34
+ def _decrypt_credentials(
35
+ self,
36
+ ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
35
37
  """Decrypt DBeaver credentials file using OpenSSL with default AES key"""
36
38
  if not self.credentials_path.exists():
37
- return {}
39
+ return {}, {}
38
40
 
39
41
  # DBeaver's default AES key and IV
40
42
  key = "babb4a9f774ab853c96c2d653dfe544a"
@@ -60,21 +62,23 @@ class DBeaverImporter:
60
62
  logger.warning(
61
63
  f"Could not decrypt credentials: {result.stderr.decode()}"
62
64
  )
63
- return {}
65
+ return {}, {}
64
66
 
65
67
  # Skip the first 16 bytes (padding) and parse JSON
66
68
  decrypted = result.stdout[16:]
67
69
  credentials_data = json.loads(decrypted)
68
70
 
69
71
  # Extract both connection and SSH credentials
70
- credentials = {}
71
- ssh_credentials = {}
72
+ credentials: dict[str, dict[str, Any]] = {}
73
+ ssh_credentials: dict[str, dict[str, Any]] = {}
72
74
  for conn_id, conn_data in credentials_data.items():
73
75
  if isinstance(conn_data, dict):
74
- if "#connection" in conn_data:
75
- credentials[conn_id] = conn_data["#connection"]
76
- if "network/ssh_tunnel" in conn_data:
77
- ssh_credentials[conn_id] = conn_data["network/ssh_tunnel"]
76
+ db_connection = conn_data.get("#connection")
77
+ if isinstance(db_connection, dict):
78
+ credentials[conn_id] = db_connection
79
+ ssh_connection = conn_data.get("network/ssh_tunnel")
80
+ if isinstance(ssh_connection, dict):
81
+ ssh_credentials[conn_id] = ssh_connection
78
82
 
79
83
  logger.info(
80
84
  f"Successfully decrypted credentials for {len(credentials)} connections"
@@ -83,7 +87,7 @@ class DBeaverImporter:
83
87
 
84
88
  except (subprocess.SubprocessError, json.JSONDecodeError) as e:
85
89
  logger.warning(f"Could not decrypt credentials file: {e}")
86
- return {}
90
+ return {}, {}
87
91
 
88
92
  def import_connections(
89
93
  self, merge_clusters: bool = True, only_names: Optional[List[str]] = None
@@ -98,21 +102,20 @@ class DBeaverImporter:
98
102
  data_sources = json.load(handle)
99
103
 
100
104
  # Try to decrypt credentials
101
- decrypt_result = self._decrypt_credentials()
102
- if isinstance(decrypt_result, tuple):
103
- credentials, ssh_credentials = decrypt_result
104
- else:
105
- credentials = decrypt_result or {}
106
- ssh_credentials = {}
105
+ credentials, ssh_credentials = self._decrypt_credentials()
107
106
 
108
107
  if not credentials and self.credentials_path.exists():
109
108
  # Fallback: try to read as plaintext JSON (some DBeaver versions don't encrypt)
110
109
  try:
111
110
  with self.credentials_path.open("r", encoding="utf-8") as handle:
112
111
  cred_data = json.load(handle)
113
- for conn_id, conn_creds in cred_data.items():
114
- if isinstance(conn_creds, dict) and "#connection" in conn_creds:
115
- credentials[conn_id] = conn_creds["#connection"]
112
+ if isinstance(cred_data, dict):
113
+ for conn_id, conn_creds in cred_data.items():
114
+ if not isinstance(conn_creds, dict):
115
+ continue
116
+ db_connection = conn_creds.get("#connection")
117
+ if isinstance(db_connection, dict):
118
+ credentials[conn_id] = db_connection
116
119
  logger.info("Credentials file was not encrypted")
117
120
  except (json.JSONDecodeError, UnicodeDecodeError):
118
121
  logger.warning(
@@ -125,10 +128,16 @@ class DBeaverImporter:
125
128
  print(f"Filtering: only {len(only_set)} requested connection(s)")
126
129
  self.last_requested_names = requested
127
130
 
128
- connections = []
131
+ connections: list[dict[str, Any]] = []
129
132
  imported_names: List[str] = []
130
133
  seen_names: List[str] = []
131
- for conn_id, conn_data in data_sources.get("connections", {}).items():
134
+ connections_data = data_sources.get("connections", {})
135
+ if not isinstance(connections_data, dict):
136
+ connections_data = {}
137
+
138
+ for conn_id, conn_data in connections_data.items():
139
+ if not isinstance(conn_data, dict):
140
+ continue
132
141
  original_name = conn_data.get("name", conn_id)
133
142
  if only_set and original_name not in only_set:
134
143
  continue
@@ -136,9 +145,7 @@ class DBeaverImporter:
136
145
  seen_names.append(original_name)
137
146
  # Pass both DB and SSH credentials
138
147
  db_creds = credentials.get(conn_id)
139
- ssh_creds = (
140
- ssh_credentials.get(conn_id) if "ssh_credentials" in locals() else None
141
- )
148
+ ssh_creds = ssh_credentials.get(conn_id)
142
149
  converted = self._convert_connection(
143
150
  conn_id, conn_data, db_creds, ssh_creds
144
151
  )
@@ -157,12 +164,14 @@ class DBeaverImporter:
157
164
  self,
158
165
  conn_id: str,
159
166
  conn_data: Dict[str, Any],
160
- creds: Dict[str, Any] = None,
161
- ssh_creds: Dict[str, Any] = None,
162
- ) -> Dict[str, Any]:
167
+ creds: Optional[Dict[str, Any]] = None,
168
+ ssh_creds: Optional[Dict[str, Any]] = None,
169
+ ) -> Optional[Dict[str, Any]]:
163
170
  """Convert a DBeaver connection to our format"""
164
171
  provider = conn_data.get("provider", "")
165
172
  config = conn_data.get("configuration", {})
173
+ if not isinstance(config, dict):
174
+ config = {}
166
175
  conn_name = conn_data.get("name", conn_id)
167
176
 
168
177
  print(f" Processing: {conn_name}...")
@@ -213,8 +222,14 @@ class DBeaverImporter:
213
222
 
214
223
  # Check for SSH tunnel configuration
215
224
  handlers = config.get("handlers", {}) # handlers is inside configuration
225
+ if not isinstance(handlers, dict):
226
+ handlers = {}
216
227
  ssh_handler = handlers.get("ssh_tunnel", {})
228
+ if not isinstance(ssh_handler, dict):
229
+ ssh_handler = {}
217
230
  ssh_props = ssh_handler.get("properties", {}) if ssh_handler else {}
231
+ if not isinstance(ssh_props, dict):
232
+ ssh_props = {}
218
233
 
219
234
  if ssh_handler and ssh_handler.get("enabled"):
220
235
  # Get SSH username from credentials or properties
@@ -368,9 +383,13 @@ class DBeaverImporter:
368
383
  )
369
384
 
370
385
  # Add servers from this connection to the group
386
+ group_servers = group.get("servers")
387
+ if not isinstance(group_servers, list):
388
+ group_servers = []
389
+ group["servers"] = group_servers
371
390
  for server in servers:
372
- if server not in group["servers"]:
373
- group["servers"].append(server)
391
+ if server not in group_servers:
392
+ group_servers.append(server)
374
393
 
375
394
  # Track original DBeaver name
376
395
  if (
@@ -381,8 +400,12 @@ class DBeaverImporter:
381
400
  import_part = (
382
401
  conn["description"].split("(imported from DBeaver:")[-1].rstrip(")")
383
402
  )
384
- if import_part not in group["original_names"]:
385
- group["original_names"].append(import_part)
403
+ group_original_names = group.get("original_names")
404
+ if not isinstance(group_original_names, list):
405
+ group_original_names = []
406
+ group["original_names"] = group_original_names
407
+ if import_part not in group_original_names:
408
+ group_original_names.append(import_part)
386
409
 
387
410
  # Return merged groups in original order with updated descriptions
388
411
  merged = []
@@ -1,7 +1,7 @@
1
1
  """Connection configuration loader."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Dict
4
+ from typing import Any, Dict, cast
5
5
 
6
6
  import yaml
7
7
 
@@ -35,16 +35,18 @@ def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
35
35
  if not isinstance(raw_configs, list):
36
36
  raise ValueError("Configuration file must contain a list of connections")
37
37
 
38
- connections = {}
39
- errors = []
38
+ connections: Dict[str, Connection] = {}
39
+ errors: list[str] = []
40
40
 
41
41
  for idx, config in enumerate(raw_configs):
42
42
  if not isinstance(config, dict):
43
43
  errors.append(f"Connection #{idx+1}: must be a dictionary")
44
44
  continue
45
45
 
46
+ config_dict = cast(Dict[str, Any], config)
47
+
46
48
  try:
47
- conn = Connection(config)
49
+ conn = Connection(config_dict)
48
50
 
49
51
  # Check for duplicate names
50
52
  if conn.name in connections:
@@ -54,7 +56,7 @@ def load_connections(yaml_path: str | Path) -> Dict[str, Connection]:
54
56
 
55
57
  except Exception as e:
56
58
  # Get connection name if available for better error messages
57
- name = config.get("connection_name", f"#{idx+1}")
59
+ name = config_dict.get("connection_name", f"#{idx+1}")
58
60
  errors.append(f"Connection '{name}': {e}")
59
61
 
60
62
  if errors:
@@ -1,9 +1,9 @@
1
1
  import asyncio
2
2
  import logging
3
3
  import os
4
- from contextlib import asynccontextmanager, nullcontext, suppress
4
+ from contextlib import asynccontextmanager, suppress
5
5
  from pathlib import Path
6
- from typing import Optional
6
+ from typing import Callable, Optional
7
7
 
8
8
  from ..base_cli import BaseCLIConnector
9
9
  from ...utils.ssh_tunnel_cli import CLISSHTunnel
@@ -55,9 +55,10 @@ class ClickHouseCLIConnector(BaseCLIConnector):
55
55
  self, query: str, database: Optional[str] = None, server: Optional[str] = None
56
56
  ) -> str:
57
57
  """Execute a read-only query using clickhouse-client and return raw TSV output"""
58
- return await self._run_query(
58
+ result = await self._run_query(
59
59
  query, database=database, server=server, output_path=None
60
60
  )
61
+ return result if result is not None else ""
61
62
 
62
63
  async def execute_query_to_file(
63
64
  self,
@@ -162,35 +163,38 @@ class ClickHouseCLIConnector(BaseCLIConnector):
162
163
  if asyncio.iscoroutine(drain_result):
163
164
  await drain_result
164
165
  stdin.close()
166
+ elif self.password:
167
+ process.kill()
168
+ await process.wait()
169
+ raise RuntimeError(
170
+ "clickhouse-client: failed to create subprocess stdin pipe"
171
+ )
165
172
 
166
173
  stdout = process.stdout
167
- stderr_task = asyncio.create_task(process.stderr.read())
168
-
169
- lines = [] if output_path is None else None
170
- pending_line = None
171
- wrote_content = False
172
-
173
- def emit_line(line: str) -> None:
174
- nonlocal wrote_content
175
- if output_path is None:
176
- lines.append(line)
177
- else:
178
- wrote_content = write_tsv_text_line(handle, line, wrote_content)
174
+ stderr_stream = process.stderr
175
+ if stdout is None or stderr_stream is None:
176
+ process.kill()
177
+ await process.wait()
178
+ raise RuntimeError(
179
+ "clickhouse-client: failed to create subprocess pipes"
180
+ )
179
181
 
182
+ stderr_task = asyncio.create_task(stderr_stream.read())
183
+ lines: list[str] = []
180
184
  loop = asyncio.get_event_loop()
181
185
  deadline = loop.time() + self.query_timeout
182
186
 
183
- async def read_line_with_timeout():
184
- remaining = deadline - loop.time()
185
- if remaining <= 0:
186
- raise asyncio.TimeoutError
187
- return await asyncio.wait_for(stdout.readline(), timeout=remaining)
188
-
189
- with (
190
- Path(output_path).open("w", encoding="utf-8", newline="")
191
- if output_path is not None
192
- else nullcontext()
193
- ) as handle:
187
+ async def stream_output(emit_line: Callable[[str], None]) -> str | None:
188
+ pending_line: str | None = None
189
+
190
+ async def read_line_with_timeout() -> bytes:
191
+ remaining = deadline - loop.time()
192
+ if remaining <= 0:
193
+ raise asyncio.TimeoutError
194
+ return await asyncio.wait_for(
195
+ stdout.readline(), timeout=remaining
196
+ )
197
+
194
198
  try:
195
199
  while True:
196
200
  line_bytes = await read_line_with_timeout()
@@ -202,7 +206,6 @@ class ClickHouseCLIConnector(BaseCLIConnector):
202
206
  if pending_line is not None:
203
207
  emit_line(pending_line)
204
208
  pending_line = line
205
-
206
209
  except asyncio.TimeoutError:
207
210
  logger.warning(
208
211
  "Query timeout - terminating clickhouse-client process"
@@ -217,34 +220,57 @@ class ClickHouseCLIConnector(BaseCLIConnector):
217
220
  raise TimeoutError(
218
221
  f"clickhouse-client: Query timeout after {self.query_timeout}s"
219
222
  )
223
+ return pending_line
220
224
 
221
- try:
222
- await asyncio.wait_for(process.wait(), timeout=1.0)
223
- except asyncio.TimeoutError:
224
- logger.error("clickhouse-client process did not terminate cleanly")
225
- process.kill()
226
- await process.wait()
225
+ async def finalize_process(
226
+ emit_line: Callable[[str], None], pending_line: str | None
227
+ ) -> None:
228
+ try:
229
+ await asyncio.wait_for(process.wait(), timeout=1.0)
230
+ except asyncio.TimeoutError:
231
+ logger.error(
232
+ "clickhouse-client process did not terminate cleanly"
233
+ )
234
+ process.kill()
235
+ await process.wait()
227
236
 
228
- if not stderr_task.done():
229
- stderr = await stderr_task
230
- else:
231
- stderr = stderr_task.result()
237
+ try:
238
+ stderr = (
239
+ await stderr_task
240
+ if not stderr_task.done()
241
+ else stderr_task.result()
242
+ )
243
+ except asyncio.CancelledError:
244
+ stderr = b""
232
245
 
233
- returncode = process.returncode
234
- if returncode is None:
235
- logger.debug(
236
- "clickhouse-client process still running after wait(); treating as successful termination"
237
- )
238
- if returncode not in (0, None):
239
- error_msg = stderr.decode() if stderr else "Unknown error"
240
- logger.error(f"clickhouse-client error: {error_msg}")
241
- raise RuntimeError(f"clickhouse-client: {error_msg}")
246
+ returncode = process.returncode
247
+ if returncode is None:
248
+ logger.debug(
249
+ "clickhouse-client process still running after wait(); treating as successful termination"
250
+ )
251
+ if returncode not in (0, None):
252
+ error_msg = stderr.decode() if stderr else "Unknown error"
253
+ logger.error(f"clickhouse-client error: {error_msg}")
254
+ raise RuntimeError(f"clickhouse-client: {error_msg}")
242
255
 
243
- if pending_line not in (None, ""):
244
- emit_line(pending_line)
256
+ if pending_line not in (None, ""):
257
+ emit_line(pending_line)
245
258
 
246
259
  if output_path is None:
260
+ pending_line = await stream_output(lines.append)
261
+ await finalize_process(lines.append, pending_line)
247
262
  return "\n".join(lines)
263
+
264
+ wrote_content = False
265
+
266
+ def emit_file_line(line: str) -> None:
267
+ nonlocal wrote_content
268
+ wrote_content = write_tsv_text_line(handle, line, wrote_content)
269
+
270
+ assert output_path is not None
271
+ with Path(output_path).open("w", encoding="utf-8", newline="") as handle:
272
+ pending_line = await stream_output(emit_file_line)
273
+ await finalize_process(emit_file_line, pending_line)
248
274
  return None
249
275
 
250
276
  except FileNotFoundError:
@@ -234,7 +234,7 @@ class ClickHousePythonConnector(BaseConnector):
234
234
  port: int,
235
235
  database: str,
236
236
  query: str,
237
- original_port: int = None,
237
+ original_port: Optional[int] = None,
238
238
  is_ssh_tunnel: bool = False,
239
239
  output_path: Optional[str] = None,
240
240
  ) -> str:
@@ -294,9 +294,9 @@ class ClickHousePythonConnector(BaseConnector):
294
294
  port: int,
295
295
  database: str,
296
296
  query: str,
297
- original_port: int = None,
297
+ original_port: Optional[int] = None,
298
298
  is_ssh_tunnel: bool = False,
299
- output_path: Optional[str] = None,
299
+ output_path: str = "",
300
300
  ) -> None:
301
301
  """Execute query synchronously and stream raw TSV output to a file."""
302
302
  client = None