clickhouse-users-cli 0.1.6__tar.gz → 0.1.7__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 (26) hide show
  1. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/PKG-INFO +12 -9
  2. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/README.md +11 -8
  3. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli/__init__.py +1 -1
  4. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli/i18n.py +4 -10
  5. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli/session.py +3 -1
  6. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli.egg-info/PKG-INFO +12 -9
  7. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli.egg-info/SOURCES.txt +8 -1
  8. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/pyproject.toml +8 -0
  9. clickhouse_users_cli-0.1.7/tests/test_app.py +154 -0
  10. clickhouse_users_cli-0.1.7/tests/test_db.py +128 -0
  11. clickhouse_users_cli-0.1.7/tests/test_i18n.py +47 -0
  12. clickhouse_users_cli-0.1.7/tests/test_session.py +54 -0
  13. clickhouse_users_cli-0.1.7/tests/test_sql_builder.py +93 -0
  14. clickhouse_users_cli-0.1.7/tests/test_style.py +22 -0
  15. clickhouse_users_cli-0.1.7/tests/test_validators.py +53 -0
  16. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/LICENSE +0 -0
  17. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli/app.py +0 -0
  18. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli/db.py +0 -0
  19. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli/sql_builder.py +0 -0
  20. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli/style.py +0 -0
  21. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli/validators.py +0 -0
  22. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli.egg-info/dependency_links.txt +0 -0
  23. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli.egg-info/entry_points.txt +0 -0
  24. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli.egg-info/requires.txt +0 -0
  25. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/clickhouse_users_cli.egg-info/top_level.txt +0 -0
  26. {clickhouse_users_cli-0.1.6 → clickhouse_users_cli-0.1.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clickhouse-users-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: CLI interativa para criar usuarios ClickHouse com privilegio minimo (least privilege)
5
5
  License-Expression: MIT
6
6
  Keywords: clickhouse,cli,users,access-control,least-privilege
@@ -59,8 +59,8 @@ ch-users
59
59
  |---|---|
60
60
  | Instalar / atualizar | `pip install -U clickhouse-users-cli` |
61
61
  | Rodar | `ch-users` |
62
- | Do código-fonte | `pip install -r requirements.txt` + `python main.py` |
63
- | Requisitos | Python `>= 3.10` + conta admin no ClickHouse (ex.: `default`) |
62
+ | Do código-fonte | `uv sync` + `uv run ch-users` |
63
+ | Requisitos | Python `>= 3.10` ([UV](https://docs.astral.sh/uv/) gerencia tudo via `uv.lock`) + conta admin no ClickHouse (ex.: `default`) |
64
64
  | Idioma | Automático pelo sistema operacional: PT em sistemas em português, EN nos demais. Force com `CH_USERS_LANG=pt` ou `CH_USERS_LANG=en` |
65
65
 
66
66
  ---
@@ -164,11 +164,13 @@ Princípios: **opt-in explícito** (nada pré-marcado), **escape correto** (back
164
164
  ## 🧑‍💻 Desenvolvimento
165
165
 
166
166
  ```powershell
167
- pip install -r requirements.txt
168
- python main.py # roda do fonte
169
- python -m build # gera sdist + wheel em dist/
170
- python -m twine check dist/* # valida (inclusive este README)
171
- python -m twine upload dist/* # publica (precisa de token PyPI)
167
+ uv sync # instala tudo (usa uv.lock)
168
+ uv run ch-users # roda o CLI do fonte
169
+ uv run python main.py # alternativa direta
170
+ uv run pytest -q # suíte de testes (44 testes, sem banco)
171
+ uv build # gera sdist + wheel em dist/
172
+ uvx twine check dist/* # valida (inclusive este README)
173
+ uv publish # publica (UV_PUBLISH_TOKEN)
172
174
  ```
173
175
 
174
176
  Versão em fonte única: `clickhouse_users_cli/__init__.py` (`__version__`).
@@ -177,8 +179,9 @@ Versão em fonte única: `clickhouse_users_cli/__init__.py` (`__version__`).
177
179
 
178
180
  ```
179
181
  main.py # entrypoint (dev local)
180
- requirements.txt
181
182
  pyproject.toml # build + metadados PyPI (comando: ch-users)
183
+ uv.lock # dependências travadas
184
+ .python-version # Python do projeto (uv)
182
185
  clickhouse_users_cli/
183
186
  __init__.py # __version__
184
187
  app.py # fluxos interativos (criar, listar, gerenciar, sessão)
@@ -35,8 +35,8 @@ ch-users
35
35
  |---|---|
36
36
  | Instalar / atualizar | `pip install -U clickhouse-users-cli` |
37
37
  | Rodar | `ch-users` |
38
- | Do código-fonte | `pip install -r requirements.txt` + `python main.py` |
39
- | Requisitos | Python `>= 3.10` + conta admin no ClickHouse (ex.: `default`) |
38
+ | Do código-fonte | `uv sync` + `uv run ch-users` |
39
+ | Requisitos | Python `>= 3.10` ([UV](https://docs.astral.sh/uv/) gerencia tudo via `uv.lock`) + conta admin no ClickHouse (ex.: `default`) |
40
40
  | Idioma | Automático pelo sistema operacional: PT em sistemas em português, EN nos demais. Force com `CH_USERS_LANG=pt` ou `CH_USERS_LANG=en` |
41
41
 
42
42
  ---
@@ -140,11 +140,13 @@ Princípios: **opt-in explícito** (nada pré-marcado), **escape correto** (back
140
140
  ## 🧑‍💻 Desenvolvimento
141
141
 
142
142
  ```powershell
143
- pip install -r requirements.txt
144
- python main.py # roda do fonte
145
- python -m build # gera sdist + wheel em dist/
146
- python -m twine check dist/* # valida (inclusive este README)
147
- python -m twine upload dist/* # publica (precisa de token PyPI)
143
+ uv sync # instala tudo (usa uv.lock)
144
+ uv run ch-users # roda o CLI do fonte
145
+ uv run python main.py # alternativa direta
146
+ uv run pytest -q # suíte de testes (44 testes, sem banco)
147
+ uv build # gera sdist + wheel em dist/
148
+ uvx twine check dist/* # valida (inclusive este README)
149
+ uv publish # publica (UV_PUBLISH_TOKEN)
148
150
  ```
149
151
 
150
152
  Versão em fonte única: `clickhouse_users_cli/__init__.py` (`__version__`).
@@ -153,8 +155,9 @@ Versão em fonte única: `clickhouse_users_cli/__init__.py` (`__version__`).
153
155
 
154
156
  ```
155
157
  main.py # entrypoint (dev local)
156
- requirements.txt
157
158
  pyproject.toml # build + metadados PyPI (comando: ch-users)
159
+ uv.lock # dependências travadas
160
+ .python-version # Python do projeto (uv)
158
161
  clickhouse_users_cli/
159
162
  __init__.py # __version__
160
163
  app.py # fluxos interativos (criar, listar, gerenciar, sessão)
@@ -1,5 +1,5 @@
1
1
  """ClickHouse Users CLI — crie usuários com escopo mínimo (least privilege)."""
2
2
 
3
- __version__ = "0.1.6"
3
+ __version__ = "0.1.7"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -215,11 +215,11 @@ STRINGS: dict[str, dict[str, str]] = {
215
215
  "v_pass_long": "Máximo 128 caracteres.",
216
216
  "v_pick_one": "Selecione pelo menos 1 item (espaço marca, enter confirma).",
217
217
  # Camada de banco
218
- "db_no_dep": "Dependência 'clickhouse-connect' não instalada. Rode: pip install -r requirements.txt",
218
+ "db_no_dep": "Dependência 'clickhouse-connect' ausente. Rode: uv sync",
219
219
  "db_connect_fail": "Falha ao conectar em {proto}://{host}:{port}: {e}",
220
220
  "db_not_connected": "Não conectado. Chame .connect() primeiro.",
221
221
  # Sessão YAML
222
- "sess_no_yaml": "Dependência 'pyyaml' não instalada. Rode: pip install pyyaml",
222
+ "sess_no_yaml": "Dependência 'pyyaml' ausente. Rode: uv sync",
223
223
  "sess_incomplete": "Arquivo de sessão incompleto (falta {e}). Apague e salve de novo.",
224
224
  "sess_read_fail": "Não foi possível ler a sessão salva: {e}",
225
225
  # SQL builder
@@ -428,11 +428,11 @@ STRINGS: dict[str, dict[str, str]] = {
428
428
  "v_pass_long": "Maximum 128 characters.",
429
429
  "v_pick_one": "Select at least 1 item (space toggles, enter confirms).",
430
430
  # DB layer
431
- "db_no_dep": "Missing 'clickhouse-connect' dependency. Run: pip install -r requirements.txt",
431
+ "db_no_dep": "Missing 'clickhouse-connect' dependency. Run: uv sync",
432
432
  "db_connect_fail": "Failed to connect to {proto}://{host}:{port}: {e}",
433
433
  "db_not_connected": "Not connected. Call .connect() first.",
434
434
  # YAML session
435
- "sess_no_yaml": "Missing 'pyyaml' dependency. Run: pip install pyyaml",
435
+ "sess_no_yaml": "Missing 'pyyaml' dependency. Run: uv sync",
436
436
  "sess_incomplete": "Saved session file is incomplete (missing {e}). Delete it and save again.",
437
437
  "sess_read_fail": "Could not read the saved session: {e}",
438
438
  # SQL builder
@@ -457,12 +457,6 @@ def detect_language() -> str:
457
457
  candidates.append(loc[0])
458
458
  except Exception:
459
459
  pass
460
- try:
461
- dloc = locale.getdefaultlocale()
462
- if dloc and dloc[0]:
463
- candidates.append(dloc[0])
464
- except Exception:
465
- pass
466
460
  for var in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"):
467
461
  candidates.append(os.environ.get(var, ""))
468
462
  for raw in candidates:
@@ -63,7 +63,9 @@ def load_session() -> ConnectionInfo:
63
63
  )
64
64
  except KeyError as e:
65
65
  raise RuntimeError(t("sess_incomplete", e=e)) from e
66
- except (OSError, ValueError, TypeError) as e:
66
+ except Exception as e:
67
+ # OSError, ValueError, TypeError, yaml.YAMLError… — qualquer falha de
68
+ # leitura/parse vira mensagem amigável em vez de stack trace.
67
69
  raise RuntimeError(t("sess_read_fail", e=e)) from e
68
70
 
69
71
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clickhouse-users-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: CLI interativa para criar usuarios ClickHouse com privilegio minimo (least privilege)
5
5
  License-Expression: MIT
6
6
  Keywords: clickhouse,cli,users,access-control,least-privilege
@@ -59,8 +59,8 @@ ch-users
59
59
  |---|---|
60
60
  | Instalar / atualizar | `pip install -U clickhouse-users-cli` |
61
61
  | Rodar | `ch-users` |
62
- | Do código-fonte | `pip install -r requirements.txt` + `python main.py` |
63
- | Requisitos | Python `>= 3.10` + conta admin no ClickHouse (ex.: `default`) |
62
+ | Do código-fonte | `uv sync` + `uv run ch-users` |
63
+ | Requisitos | Python `>= 3.10` ([UV](https://docs.astral.sh/uv/) gerencia tudo via `uv.lock`) + conta admin no ClickHouse (ex.: `default`) |
64
64
  | Idioma | Automático pelo sistema operacional: PT em sistemas em português, EN nos demais. Force com `CH_USERS_LANG=pt` ou `CH_USERS_LANG=en` |
65
65
 
66
66
  ---
@@ -164,11 +164,13 @@ Princípios: **opt-in explícito** (nada pré-marcado), **escape correto** (back
164
164
  ## 🧑‍💻 Desenvolvimento
165
165
 
166
166
  ```powershell
167
- pip install -r requirements.txt
168
- python main.py # roda do fonte
169
- python -m build # gera sdist + wheel em dist/
170
- python -m twine check dist/* # valida (inclusive este README)
171
- python -m twine upload dist/* # publica (precisa de token PyPI)
167
+ uv sync # instala tudo (usa uv.lock)
168
+ uv run ch-users # roda o CLI do fonte
169
+ uv run python main.py # alternativa direta
170
+ uv run pytest -q # suíte de testes (44 testes, sem banco)
171
+ uv build # gera sdist + wheel em dist/
172
+ uvx twine check dist/* # valida (inclusive este README)
173
+ uv publish # publica (UV_PUBLISH_TOKEN)
172
174
  ```
173
175
 
174
176
  Versão em fonte única: `clickhouse_users_cli/__init__.py` (`__version__`).
@@ -177,8 +179,9 @@ Versão em fonte única: `clickhouse_users_cli/__init__.py` (`__version__`).
177
179
 
178
180
  ```
179
181
  main.py # entrypoint (dev local)
180
- requirements.txt
181
182
  pyproject.toml # build + metadados PyPI (comando: ch-users)
183
+ uv.lock # dependências travadas
184
+ .python-version # Python do projeto (uv)
182
185
  clickhouse_users_cli/
183
186
  __init__.py # __version__
184
187
  app.py # fluxos interativos (criar, listar, gerenciar, sessão)
@@ -14,4 +14,11 @@ clickhouse_users_cli.egg-info/SOURCES.txt
14
14
  clickhouse_users_cli.egg-info/dependency_links.txt
15
15
  clickhouse_users_cli.egg-info/entry_points.txt
16
16
  clickhouse_users_cli.egg-info/requires.txt
17
- clickhouse_users_cli.egg-info/top_level.txt
17
+ clickhouse_users_cli.egg-info/top_level.txt
18
+ tests/test_app.py
19
+ tests/test_db.py
20
+ tests/test_i18n.py
21
+ tests/test_session.py
22
+ tests/test_sql_builder.py
23
+ tests/test_style.py
24
+ tests/test_validators.py
@@ -36,3 +36,11 @@ include = ["clickhouse_users_cli*"]
36
36
 
37
37
  [tool.setuptools.dynamic]
38
38
  version = { attr = "clickhouse_users_cli.__version__" }
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=9.1.1",
46
+ ]
@@ -0,0 +1,154 @@
1
+ """Testes dos fluxos interativos com prompts simulados (sem console real)."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from clickhouse_users_cli import app
8
+ from clickhouse_users_cli.i18n import set_language
9
+ from clickhouse_users_cli.sql_builder import GrantScope
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def _lang():
14
+ set_language("pt")
15
+ yield
16
+ set_language("pt")
17
+
18
+
19
+ def _patch_factories(monkeypatch, **answers):
20
+ """Substitui os factories do questionary por retornos fixos."""
21
+ for name, value in answers.items():
22
+ monkeypatch.setattr(app.Q, name, MagicMock(return_value=MagicMock()))
23
+ monkeypatch.setattr(app, "_ask_or_abort", MagicMock(side_effect=lambda prompt: answers.get("__ask__")))
24
+
25
+
26
+ def test_ask_profile_questions_by_mode(monkeypatch):
27
+ asked = {}
28
+
29
+ def fake_select(msg, **kw):
30
+ asked["q"] = msg
31
+ return MagicMock()
32
+
33
+ monkeypatch.setattr(app.Q, "select", fake_select)
34
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: "readonly")
35
+ app.ask_profile()
36
+ assert asked["q"] == "Que tipo de usuário criar?"
37
+ app.ask_profile(mode="edit")
38
+ assert asked["q"] == "Qual o novo tipo de acesso?"
39
+
40
+
41
+ def test_ask_profile_question_english(monkeypatch):
42
+ set_language("en")
43
+ monkeypatch.setattr(app.Q, "select", lambda msg, **kw: MagicMock())
44
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: "readonly")
45
+ with patch.object(app, "step") as step:
46
+ app.ask_profile()
47
+ assert "User type" in step.call_args[0][0]
48
+
49
+
50
+ def test_ask_databases_all_and_partial(monkeypatch):
51
+ admin = MagicMock()
52
+ admin.list_databases.return_value = ["vendas", "system"]
53
+ captured = {}
54
+
55
+ def fake_checkbox(msg, choices, validate, style, qmark, instruction):
56
+ captured["values"] = [c.value for c in choices]
57
+ captured["checked"] = [c.checked for c in choices]
58
+ return MagicMock()
59
+
60
+ monkeypatch.setattr(app.Q, "checkbox", fake_checkbox)
61
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: ["__ALL__"])
62
+ assert app.ask_databases(admin) == ["vendas", "system"]
63
+ assert captured["values"][0] == "__ALL__"
64
+ assert captured["checked"] == [False] * len(captured["values"])
65
+
66
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: ["vendas"])
67
+ assert app.ask_databases(admin) == ["vendas"]
68
+
69
+
70
+ def test_ask_tables_all_and_specific(monkeypatch):
71
+ admin = MagicMock()
72
+ admin.list_tables.return_value = ["pedidos", "clientes"]
73
+ monkeypatch.setattr(app.Q, "checkbox", MagicMock(return_value=MagicMock()))
74
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: ["__ALL__"])
75
+ scopes = app.ask_tables(admin, ["vendas"])
76
+ assert scopes == [GrantScope(database="vendas", table=None)]
77
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: ["pedidos"])
78
+ scopes = app.ask_tables(admin, ["vendas"])
79
+ assert scopes == [GrantScope(database="vendas", table="pedidos")]
80
+
81
+
82
+ def test_ask_privileges_profiles():
83
+ assert app.ask_privileges("readonly") == (["SHOW", "SELECT"], False)
84
+ assert app.ask_privileges("admin") == (["ALL"], True)
85
+
86
+
87
+ def test_ask_privileges_custom(monkeypatch):
88
+ monkeypatch.setattr(app.Q, "checkbox", MagicMock(return_value=MagicMock()))
89
+ monkeypatch.setattr(app.Q, "confirm", MagicMock(return_value=MagicMock()))
90
+ monkeypatch.setattr(app, "_ask_or_abort", MagicMock(side_effect=[["SELECT"], False]))
91
+ assert app.ask_privileges("custom") == (["SELECT"], False)
92
+
93
+
94
+ def test_flow_edit_grants_revokes_first(monkeypatch):
95
+ admin = MagicMock()
96
+ admin.conn.username = "default"
97
+ monkeypatch.setattr(app, "ask_profile", lambda mode="create": "readwrite")
98
+ monkeypatch.setattr(app, "ask_privileges", lambda profile, mode="create": (["SHOW", "SELECT"], False))
99
+ monkeypatch.setattr(app, "ask_databases", lambda admin, mode="create": ["vendas"])
100
+ monkeypatch.setattr(app, "ask_tables",
101
+ lambda admin, dbs, mode="create": [GrantScope(database="vendas", table="pedidos")])
102
+ monkeypatch.setattr(app.Q, "confirm", MagicMock(return_value=MagicMock()))
103
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: True)
104
+ app.flow_edit_grants(admin, "ana")
105
+ stmts = admin.execute_statements.call_args[0][0]
106
+ assert stmts[0] == "REVOKE ALL ON *.* FROM `ana`"
107
+ assert stmts[1] == "GRANT SHOW, SELECT ON `vendas`.`pedidos` TO `ana`"
108
+
109
+
110
+ def test_flow_edit_grants_cancelled_runs_nothing(monkeypatch):
111
+ admin = MagicMock()
112
+ admin.conn.username = "default"
113
+ monkeypatch.setattr(app, "ask_profile", lambda mode="create": "readonly")
114
+ monkeypatch.setattr(app, "ask_privileges", lambda profile, mode="create": (["SHOW"], False))
115
+ monkeypatch.setattr(app, "ask_databases", lambda admin, mode="create": ["vendas"])
116
+ monkeypatch.setattr(app, "ask_tables",
117
+ lambda admin, dbs, mode="create": [GrantScope(database="vendas", table=None)])
118
+ monkeypatch.setattr(app.Q, "confirm", MagicMock(return_value=MagicMock()))
119
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: False)
120
+ app.flow_edit_grants(admin, "ana")
121
+ admin.execute_statements.assert_not_called()
122
+
123
+
124
+ def test_maybe_load_without_file_returns_none(monkeypatch):
125
+ monkeypatch.setattr("clickhouse_users_cli.session.has_saved_session", lambda: False)
126
+ ask = MagicMock(side_effect=AssertionError("nao deveria perguntar"))
127
+ monkeypatch.setattr(app, "_ask_or_abort", ask)
128
+ assert app.maybe_load_saved_session() is None
129
+
130
+
131
+ def test_maybe_load_use_saved(monkeypatch):
132
+ from clickhouse_users_cli.db import ConnectionInfo
133
+
134
+ conn = ConnectionInfo(host="h", port=8123, secure=False, username="u", password="p")
135
+ monkeypatch.setattr("clickhouse_users_cli.session.has_saved_session", lambda: True)
136
+ monkeypatch.setattr("clickhouse_users_cli.session.load_session", lambda: conn)
137
+ monkeypatch.setattr(app.Q, "select", MagicMock(return_value=MagicMock()))
138
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: "use")
139
+ assert app.maybe_load_saved_session() is conn
140
+
141
+
142
+ def test_main_menu_forget_flag(monkeypatch):
143
+ seen = {}
144
+
145
+ def fake_select(msg, choices, default, style, qmark, instruction):
146
+ seen["values"] = [c.value for c in choices]
147
+ return MagicMock()
148
+
149
+ monkeypatch.setattr(app.Q, "select", fake_select)
150
+ monkeypatch.setattr(app, "_ask_or_abort", lambda prompt: "exit")
151
+ app.ask_main_action(show_forget=False)
152
+ assert "forget" not in seen["values"]
153
+ app.ask_main_action(show_forget=True)
154
+ assert "forget" in seen["values"]
@@ -0,0 +1,128 @@
1
+ """Testes da camada ClickHouse com cliente falso (sem rede)."""
2
+
3
+ import sys
4
+ import types
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+
9
+ from clickhouse_users_cli.db import SYSTEM_DATABASES, ClickHouseAdmin, ConnectionInfo
10
+ from clickhouse_users_cli.i18n import set_language
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def _lang():
15
+ set_language("pt")
16
+ yield
17
+
18
+
19
+ class FakeClient:
20
+ def __init__(self):
21
+ self.queries = []
22
+ self.commands = []
23
+
24
+ def query(self, sql):
25
+ self.queries.append(sql)
26
+
27
+ class R:
28
+ pass
29
+
30
+ r = R()
31
+ if sql == "SHOW USERS":
32
+ r.result_rows = [("default",), ("ana",)]
33
+ elif sql.startswith("SHOW GRANTS FOR"):
34
+ r.result_rows = [("GRANT SELECT ON db.t TO ana",)]
35
+ elif sql.startswith("SHOW CREATE USER"):
36
+ r.result_rows = [("CREATE USER ana HOST ANY",)]
37
+ elif sql == "SHOW DATABASES":
38
+ r.result_rows = [("vendas",), ("system",)]
39
+ elif sql.startswith("SHOW TABLES FROM"):
40
+ r.result_rows = [("pedidos",), ("clientes",)]
41
+ else:
42
+ r.result_rows = [("1",)]
43
+ return r
44
+
45
+ def command(self, sql):
46
+ self.commands.append(sql)
47
+
48
+
49
+ def _admin():
50
+ a = ClickHouseAdmin(ConnectionInfo(host="h", port=8123, secure=False,
51
+ username="default", password=""))
52
+ a._client = FakeClient()
53
+ return a
54
+
55
+
56
+ def test_client_requires_connect():
57
+ a = ClickHouseAdmin(ConnectionInfo(host="h", port=1, secure=False, username="u", password=""))
58
+ with pytest.raises(RuntimeError):
59
+ _ = a.client
60
+
61
+
62
+ def test_list_users_sorted():
63
+ assert _admin().list_users() == ["ana", "default"]
64
+
65
+
66
+ def test_show_grants_escapes_ident():
67
+ a = _admin()
68
+ assert a.show_grants("ana") == ["GRANT SELECT ON db.t TO ana"]
69
+ assert a.show_create_user("ana") == "CREATE USER ana HOST ANY"
70
+ assert a.client.queries[-2].startswith("SHOW GRANTS FOR `ana`")
71
+
72
+
73
+ def test_databases_system_last():
74
+ assert _admin().list_databases() == ["vendas", "system"]
75
+ assert "system" in SYSTEM_DATABASES
76
+
77
+
78
+ def test_manage_commands():
79
+ a = _admin()
80
+ a.deactivate_user("ana")
81
+ a.activate_user("ana")
82
+ a.drop_user("ana")
83
+ assert a.client.commands == [
84
+ "ALTER USER `ana` HOST NONE",
85
+ "ALTER USER `ana` HOST ANY",
86
+ "DROP USER IF EXISTS `ana`",
87
+ ]
88
+
89
+
90
+ def test_execute_statements_runs_all():
91
+ a = _admin()
92
+ a.execute_statements(["A", "B"])
93
+ assert a.client.commands == ["A", "B"]
94
+
95
+
96
+ def test_connect_missing_dependency():
97
+ monkeypatch_target = sys.modules.get("clickhouse_connect")
98
+ sys.modules["clickhouse_connect"] = None # força ImportError no import interno
99
+ try:
100
+ a = ClickHouseAdmin(ConnectionInfo(host="h", port=1, secure=False, username="u", password=""))
101
+ with pytest.raises(RuntimeError, match="clickhouse-connect"):
102
+ a.connect()
103
+ finally:
104
+ if monkeypatch_target is None:
105
+ sys.modules.pop("clickhouse_connect", None)
106
+ else:
107
+ sys.modules["clickhouse_connect"] = monkeypatch_target
108
+
109
+
110
+ def test_connect_success_and_failure_wrap():
111
+ mod = types.ModuleType("clickhouse_connect")
112
+
113
+ def ok_client(**kw):
114
+ return FakeClient()
115
+
116
+ def boom_client(**kw):
117
+ raise ConnectionError("refused")
118
+
119
+ sys.modules["clickhouse_connect"] = mod
120
+ try:
121
+ mod.get_client = ok_client
122
+ a = ClickHouseAdmin(ConnectionInfo(host="h", port=8123, secure=False, username="u", password=""))
123
+ assert a.connect() is a
124
+ mod.get_client = boom_client
125
+ with pytest.raises(RuntimeError, match="Falha ao conectar"):
126
+ a.connect()
127
+ finally:
128
+ sys.modules.pop("clickhouse_connect", None)
@@ -0,0 +1,47 @@
1
+ """Testes do i18n — paridade PT/EN, detecção e interpolação."""
2
+
3
+ import locale
4
+
5
+ from clickhouse_users_cli.i18n import STRINGS, detect_language, get_language, set_language, t
6
+
7
+
8
+ def test_key_parity():
9
+ assert set(STRINGS["pt"]) == set(STRINGS["en"])
10
+ assert len(STRINGS["pt"]) > 100
11
+
12
+
13
+ def test_interpolation_and_fallback():
14
+ set_language("pt")
15
+ assert t("q_port", port="8123") == "Porta HTTP(S) [8123]:"
16
+ assert t("connected", username="u", host="h", port=1) == "Conectado como 'u' em h:1"
17
+ assert t("chave_que_nao_existe") == "chave_que_nao_existe"
18
+ set_language("en")
19
+ assert t("q_port", port="8123") == "HTTP(S) port [8123]:"
20
+ assert get_language() == "en"
21
+ set_language("pt")
22
+
23
+
24
+ def _detect_with(env: dict, loc=(None, None)):
25
+ import os
26
+ from unittest.mock import patch
27
+
28
+ with patch.dict(os.environ, env, clear=False):
29
+ with patch.object(locale, "getlocale", return_value=loc), patch.object(
30
+ locale, "getdefaultlocale", return_value=(None, None)
31
+ ):
32
+ # limpa variáveis que poderiam interferir
33
+ for var in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"):
34
+ os.environ.pop(var, None)
35
+ for k, v in env.items():
36
+ os.environ[k] = v
37
+ return detect_language()
38
+
39
+
40
+ def test_detect_language():
41
+ assert _detect_with({"LANG": "pt_BR.UTF-8"}) == "pt"
42
+ assert _detect_with({"LANG": "Portuguese_Brazil"}) == "pt"
43
+ assert _detect_with({"LANG": "en_US.UTF-8"}) == "en"
44
+ assert _detect_with({"LANG": "C"}) == "en"
45
+ assert _detect_with({}) == "en"
46
+ assert _detect_with({"CH_USERS_LANG": "pt", "LANG": "en_US.UTF-8"}) == "pt"
47
+ assert _detect_with({"CH_USERS_LANG": "en", "LANG": "pt_BR.UTF-8"}) == "en"
@@ -0,0 +1,54 @@
1
+ """Testes da sessão YAML — roundtrip em diretório temporário."""
2
+
3
+ import pytest
4
+
5
+ from clickhouse_users_cli import session as s
6
+ from clickhouse_users_cli.db import ConnectionInfo
7
+ from clickhouse_users_cli.i18n import set_language
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def _tmp_session(tmp_path, monkeypatch):
12
+ monkeypatch.setattr(s, "SESSION_DIR", tmp_path)
13
+ monkeypatch.setattr(s, "SESSION_FILE", tmp_path / "connection.yaml")
14
+ set_language("pt")
15
+ yield
16
+
17
+
18
+ def _conn():
19
+ return ConnectionInfo(host="db.empresa.com", port=8443, secure=True,
20
+ username="default", password="s3cr3t!")
21
+
22
+
23
+ def test_roundtrip():
24
+ assert not s.has_saved_session()
25
+ path = s.save_session(_conn())
26
+ assert path.is_file()
27
+ assert s.has_saved_session()
28
+ back = s.load_session()
29
+ assert (back.host, back.port, back.secure, back.username, back.password) == \
30
+ ("db.empresa.com", 8443, True, "default", "s3cr3t!")
31
+
32
+
33
+ def test_delete():
34
+ s.save_session(_conn())
35
+ assert s.delete_session() is True
36
+ assert not s.has_saved_session()
37
+ assert s.delete_session() is False
38
+
39
+
40
+ def test_load_missing_is_friendly():
41
+ with pytest.raises(RuntimeError):
42
+ s.load_session()
43
+
44
+
45
+ def test_load_corrupt_is_friendly():
46
+ s.SESSION_FILE.write_text("{{{ nao: [yaml valido", encoding="utf-8")
47
+ with pytest.raises(RuntimeError):
48
+ s.load_session()
49
+
50
+
51
+ def test_load_incomplete_points_missing_key():
52
+ s.SESSION_FILE.write_text("host: x\n", encoding="utf-8")
53
+ with pytest.raises(RuntimeError, match="incompleto"):
54
+ s.load_session()
@@ -0,0 +1,93 @@
1
+ """Testes do construtor de SQL — puros, sem banco."""
2
+
3
+ import pytest
4
+
5
+ from clickhouse_users_cli.sql_builder import (
6
+ GrantScope,
7
+ UserSpec,
8
+ build_activate_user_sql,
9
+ build_all_statements,
10
+ build_create_user_sql,
11
+ build_deactivate_user_sql,
12
+ build_drop_user_sql,
13
+ build_edit_grants_statements,
14
+ build_grant_sql,
15
+ build_revoke_all_sql,
16
+ build_show_create_user_sql,
17
+ build_show_grants_sql,
18
+ escape_host,
19
+ escape_ident,
20
+ escape_string,
21
+ )
22
+
23
+
24
+ def test_escape_ident():
25
+ assert escape_ident("vendas") == "`vendas`"
26
+ assert escape_ident("a`b") == "`a``b`"
27
+
28
+
29
+ def test_escape_string():
30
+ assert escape_string("abc") == "'abc'"
31
+ assert escape_string("o'b") == "'o\\'b'"
32
+ assert escape_string("a\\b") == "'a\\\\b'"
33
+ assert escape_host("10.0.0.5") == "'10.0.0.5'"
34
+
35
+
36
+ def test_on_clause():
37
+ assert GrantScope(database="*").on_clause() == "*.*"
38
+ assert GrantScope(database="vendas", table=None).on_clause() == "`vendas`.*"
39
+ assert GrantScope(database="vendas", table="*").on_clause() == "`vendas`.*"
40
+ assert GrantScope(database="vendas", table="pedidos").on_clause() == "`vendas`.`pedidos`"
41
+
42
+
43
+ def _spec(**kw):
44
+ base = dict(username="ana", password="s3cret!!", privileges=["SHOW", "SELECT"],
45
+ scopes=[GrantScope(database="vendas", table="pedidos")])
46
+ base.update(kw)
47
+ return UserSpec(**base)
48
+
49
+
50
+ def test_create_user_any_host():
51
+ sql = build_create_user_sql(_spec())
52
+ assert sql == ("CREATE USER IF NOT EXISTS `ana` IDENTIFIED WITH plaintext_password"
53
+ " BY 's3cret!!' HOST ANY")
54
+
55
+
56
+ def test_create_user_custom_hosts_and_no_ine():
57
+ sql = build_create_user_sql(_spec(hosts=["LOCALHOST", "10.0.0.5"], if_not_exists=False))
58
+ assert "IF NOT EXISTS" not in sql
59
+ assert "HOST 'LOCALHOST', HOST '10.0.0.5'" in sql
60
+
61
+
62
+ def test_grant_sql_and_grant_option():
63
+ stmts = build_grant_sql(_spec(scopes=[GrantScope(database="v", table=None)], grant_option=True))
64
+ assert stmts == ["GRANT SHOW, SELECT ON `v`.* TO `ana` WITH GRANT OPTION"]
65
+
66
+
67
+ def test_grant_sql_validates():
68
+ with pytest.raises(ValueError):
69
+ build_grant_sql(_spec(scopes=[]))
70
+ with pytest.raises(ValueError):
71
+ build_grant_sql(_spec(privileges=[]))
72
+
73
+
74
+ def test_all_statements_order():
75
+ stmts = build_all_statements(_spec())
76
+ assert stmts[0].startswith("CREATE USER")
77
+ assert stmts[1].startswith("GRANT")
78
+
79
+
80
+ def test_manage_statements():
81
+ assert build_show_grants_sql("ana") == "SHOW GRANTS FOR `ana`"
82
+ assert build_show_create_user_sql("ana") == "SHOW CREATE USER `ana`"
83
+ assert build_deactivate_user_sql("ana") == "ALTER USER `ana` HOST NONE"
84
+ assert build_activate_user_sql("ana") == "ALTER USER `ana` HOST ANY"
85
+ assert build_drop_user_sql("ana") == "DROP USER IF EXISTS `ana`"
86
+ assert build_drop_user_sql("ana", if_exists=False) == "DROP USER `ana`"
87
+ assert build_revoke_all_sql("ana") == "REVOKE ALL ON *.* FROM `ana`"
88
+
89
+
90
+ def test_edit_grants_statements_revoke_first():
91
+ stmts = build_edit_grants_statements("ana", ["SELECT"], [GrantScope(database="v", table="t")], False)
92
+ assert stmts[0] == "REVOKE ALL ON *.* FROM `ana`"
93
+ assert stmts[1] == "GRANT SELECT ON `v`.`t` TO `ana`"
@@ -0,0 +1,22 @@
1
+ """Smoke tests visuais — nada pode quebrar ao renderizar."""
2
+
3
+ from clickhouse_users_cli import style
4
+ from clickhouse_users_cli.i18n import set_language
5
+
6
+
7
+ def test_render_pt_and_en(capfd):
8
+ for lang in ("pt", "en"):
9
+ set_language(lang)
10
+ style.banner()
11
+ style.step("4/6 · Teste", "subtítulo")
12
+ style.success("ok")
13
+ style.error("falhou")
14
+ style.warn("atenção")
15
+ style.info("dica")
16
+ table = style.make_table("Título")
17
+ table.add_column("A")
18
+ table.add_row("1")
19
+ style.console.print(table)
20
+ set_language("pt")
21
+ out, _ = capfd.readouterr()
22
+ assert "ClickHouse Users CLI" in out
@@ -0,0 +1,53 @@
1
+ """Testes dos validadores (mensagens em PT e EN)."""
2
+
3
+ from clickhouse_users_cli.i18n import set_language
4
+ from clickhouse_users_cli.validators import (
5
+ validate_at_least_one,
6
+ validate_host,
7
+ validate_new_password,
8
+ validate_port,
9
+ validate_required,
10
+ validate_username,
11
+ )
12
+
13
+
14
+ def test_required():
15
+ set_language("pt")
16
+ assert validate_required(" x ") is True
17
+ assert validate_required(" ") != True
18
+
19
+
20
+ def test_host():
21
+ set_language("pt")
22
+ assert validate_host("localhost") is True
23
+ assert validate_host("10.0.0.5") is True
24
+ assert validate_host("db.empresa.com") is True
25
+ assert validate_host("") == "Informe o host (ex.: localhost ou 10.0.0.5)."
26
+ assert "inválido" in validate_host("??").lower()
27
+
28
+
29
+ def test_port():
30
+ set_language("pt")
31
+ assert validate_port("8123") is True
32
+ assert "numérica" in validate_port("abc")
33
+ assert "65535" in validate_port("99999")
34
+
35
+
36
+ def test_username():
37
+ set_language("pt")
38
+ assert validate_username("analyst_julho") is True
39
+ assert "reservado" in validate_username("default")
40
+ assert validate_username("1abc") != True
41
+
42
+
43
+ def test_password():
44
+ set_language("en")
45
+ assert validate_new_password("12345678") is True
46
+ assert "Minimum 8" in validate_new_password("abc")
47
+ assert "Maximum 128" in validate_new_password("x" * 129)
48
+ set_language("pt")
49
+
50
+
51
+ def test_at_least_one():
52
+ assert validate_at_least_one(["a"]) is True
53
+ assert validate_at_least_one([]) != True