dbqm 2.3.2__tar.gz → 2.4.0__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 (106) hide show
  1. {dbqm-2.3.2 → dbqm-2.4.0}/PKG-INFO +1 -1
  2. {dbqm-2.3.2 → dbqm-2.4.0}/README.md +3 -1
  3. dbqm-2.4.0/dbqm/_version.py +1 -0
  4. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/__init__.py +4 -4
  5. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/commands/query.py +23 -3
  6. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/deps.py +3 -0
  7. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/html_report.py +83 -11
  8. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm.egg-info/PKG-INFO +1 -1
  9. {dbqm-2.3.2 → dbqm-2.4.0}/tests/test_cli.py +109 -0
  10. dbqm-2.3.2/dbqm/_version.py +0 -1
  11. {dbqm-2.3.2 → dbqm-2.4.0}/PYPI.md +0 -0
  12. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/__init__.py +0 -0
  13. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/__main__.py +0 -0
  14. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/commands/__init__.py +0 -0
  15. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/commands/config_bundle.py +0 -0
  16. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/commands/connection.py +0 -0
  17. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/commands/inspect.py +0 -0
  18. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/commands/schema.py +0 -0
  19. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/envelope.py +0 -0
  20. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/errors.py +0 -0
  21. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/params.py +0 -0
  22. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/cli/render.py +0 -0
  23. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/__init__.py +0 -0
  24. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/audit.py +0 -0
  25. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/config_portability.py +0 -0
  26. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/connection_builder.py +0 -0
  27. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/crypto.py +0 -0
  28. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/db_manager.py +0 -0
  29. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/ddl_extractor.py +0 -0
  30. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/ddl_mysql.py +0 -0
  31. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/ddl_pg.py +0 -0
  32. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/exporter.py +0 -0
  33. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/group_engine.py +0 -0
  34. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/history.py +0 -0
  35. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/object_browser.py +0 -0
  36. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/oracle_client_installer.py +0 -0
  37. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/package_editor.py +0 -0
  38. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/paths.py +0 -0
  39. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/query_engine.py +0 -0
  40. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/read_only.py +0 -0
  41. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/table_browser.py +0 -0
  42. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/core/template_engine.py +0 -0
  43. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/design/__init__.py +0 -0
  44. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/design/tokens.py +0 -0
  45. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/main.py +0 -0
  46. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/models/__init__.py +0 -0
  47. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/models/connection.py +0 -0
  48. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/models/group.py +0 -0
  49. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/models/query.py +0 -0
  50. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/models/settings.py +0 -0
  51. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/models/template.py +0 -0
  52. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/__init__.py +0 -0
  53. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/app.py +0 -0
  54. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/__init__.py +0 -0
  55. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/column_maps.py +0 -0
  56. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/confirm.py +0 -0
  57. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/error.py +0 -0
  58. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/export_dir_setup.py +0 -0
  59. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/export_picker.py +0 -0
  60. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/help.py +0 -0
  61. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/oracle_client_dir.py +0 -0
  62. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/param_input.py +0 -0
  63. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/modals/text_input.py +0 -0
  64. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/__init__.py +0 -0
  65. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/adhoc.py +0 -0
  66. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/browser.py +0 -0
  67. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/config_port.py +0 -0
  68. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/connections.py +0 -0
  69. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/exec_routine.py +0 -0
  70. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/group_exec.py +0 -0
  71. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/group_manage.py +0 -0
  72. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/group_run.py +0 -0
  73. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/history.py +0 -0
  74. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/oracle_clients.py +0 -0
  75. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/package_editor.py +0 -0
  76. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/query_exec.py +0 -0
  77. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/query_manage.py +0 -0
  78. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/settings.py +0 -0
  79. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/template_manage.py +0 -0
  80. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/screens/tools.py +0 -0
  81. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/theme.py +0 -0
  82. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/utils.py +0 -0
  83. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/__init__.py +0 -0
  84. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/action_bar.py +0 -0
  85. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/dialog.py +0 -0
  86. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/empty_state.py +0 -0
  87. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/group_result.py +0 -0
  88. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/hierarchical_list.py +0 -0
  89. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/panel.py +0 -0
  90. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/progress.py +0 -0
  91. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/query_list.py +0 -0
  92. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/result_table.py +0 -0
  93. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/skeleton.py +0 -0
  94. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/sql_viewer.py +0 -0
  95. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/status_bar.py +0 -0
  96. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/templates_sidebar.py +0 -0
  97. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm/ui/widgets/verdict.py +0 -0
  98. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm.egg-info/SOURCES.txt +0 -0
  99. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm.egg-info/dependency_links.txt +0 -0
  100. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm.egg-info/entry_points.txt +0 -0
  101. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm.egg-info/requires.txt +0 -0
  102. {dbqm-2.3.2 → dbqm-2.4.0}/dbqm.egg-info/top_level.txt +0 -0
  103. {dbqm-2.3.2 → dbqm-2.4.0}/pyproject.toml +0 -0
  104. {dbqm-2.3.2 → dbqm-2.4.0}/setup.cfg +0 -0
  105. {dbqm-2.3.2 → dbqm-2.4.0}/tests/test_cli_markup.py +0 -0
  106. {dbqm-2.3.2 → dbqm-2.4.0}/tests/test_cli_tema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbqm
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: Multi-database interactive query tool with fullscreen TUI
5
5
  Author: Silvio Chagas
6
6
  License-Expression: MIT
@@ -225,7 +225,9 @@ offer `table|json|csv|raw`; every other command — `test`, `list`, `ddl`,
225
225
  `connection` group — offers `table|json`. `raw` prints plain values with no
226
226
  headers/decoration, handy for piping the body of a view, package, or
227
227
  procedure to another tool.
228
- `--export csv|json|txt` writes the result to a file regardless of `-f`.
228
+ `--export csv|json|txt|html` writes the result to a file regardless of `-f`.
229
+ `html` writes a standalone report meant to be read in a browser, and is not
230
+ available together with `--flat`.
229
231
 
230
232
  `-f json` wraps every command in one envelope: a success prints
231
233
  `{"ok": true, "command": "...", "data": {...}}` to stdout; a failure prints
@@ -0,0 +1 @@
1
+ __version__ = "2.4.0"
@@ -33,7 +33,7 @@ def build_parser() -> argparse.ArgumentParser:
33
33
  help="Parametro (pode repetir)")
34
34
  p_run.add_argument("-f", "--format", choices=["table", "json", "csv", "raw"], default="table",
35
35
  help="Formato de saida (padrao: table). 'raw' imprime valores sem decoracao.")
36
- p_run.add_argument("-e", "--export", choices=["csv", "json", "txt"],
36
+ p_run.add_argument("-e", "--export", choices=["csv", "json", "txt", "html"],
37
37
  help="Exportar resultado para arquivo")
38
38
 
39
39
  # --- run-group ---
@@ -43,8 +43,8 @@ def build_parser() -> argparse.ArgumentParser:
43
43
  help="Parametro compartilhado (pode repetir)")
44
44
  p_grp.add_argument("-f", "--format", choices=["table", "json"], default="table",
45
45
  help="Formato de saida")
46
- p_grp.add_argument("-e", "--export", choices=["csv", "json", "txt"],
47
- help="Exportar resultado para arquivo")
46
+ p_grp.add_argument("-e", "--export", choices=["csv", "json", "txt", "html"],
47
+ help="Exportar resultado para arquivo. 'html' nao pode ser usado com --flat.")
48
48
  p_grp.add_argument("--flat", action="store_true",
49
49
  help="Usar formato flat (um bloco por coluna)")
50
50
 
@@ -69,7 +69,7 @@ def build_parser() -> argparse.ArgumentParser:
69
69
  help="Parametro (pode repetir)")
70
70
  p_sql.add_argument("-f", "--format", choices=["table", "json", "csv", "raw"], default="table",
71
71
  help="Formato de saida. 'raw' imprime valores sem decoracao.")
72
- p_sql.add_argument("-e", "--export", choices=["csv", "json", "txt"],
72
+ p_sql.add_argument("-e", "--export", choices=["csv", "json", "txt", "html"],
73
73
  help="Exportar resultado para arquivo")
74
74
  p_sql.add_argument("--commit", action="store_true",
75
75
  help="Auto-commit para DML (INSERT/UPDATE/DELETE)")
@@ -133,6 +133,8 @@ def cmd_run(args: argparse.Namespace) -> None:
133
133
  path = deps.export_query_json(result, table_name, param_values)
134
134
  elif fmt == "txt":
135
135
  path = deps.export_query_txt(result, table_name, param_values)
136
+ elif fmt == "html":
137
+ path = deps.export_query_html(result, table_name, param_values)
136
138
  else:
137
139
  _fail_or_print(args, "run", "usage", f"Formato de export invalido: {fmt}")
138
140
  if args.format == "json":
@@ -162,6 +164,14 @@ def cmd_run_group(args: argparse.Namespace) -> None:
162
164
  on stdout. The exit code alone is what lets a shell branch on the verdict
163
165
  without parsing anything.
164
166
  """
167
+ # An argument combination that can never be valid is refused before any
168
+ # work happens -- before the group is even resolved, let alone any query
169
+ # run or any history record written.
170
+ if args.export == "html" and args.flat:
171
+ _fail_or_print(args, "run-group", "usage",
172
+ "--flat nao tem versao HTML. Use --export html sem --flat, "
173
+ "ou --flat com csv, json ou txt.")
174
+
165
175
  group = deps.find_group(args.group)
166
176
  if not group:
167
177
  _fail_or_print(args, "run-group", "not_found", f"Grupo '{args.group}' nao encontrado.")
@@ -219,15 +229,21 @@ def cmd_run_group(args: argparse.Namespace) -> None:
219
229
  path = deps.export_group_flat_csv(group_result, param_values)
220
230
  elif fmt == "json":
221
231
  path = deps.export_group_flat_json(group_result, param_values)
222
- else:
232
+ elif fmt == "txt":
223
233
  path = deps.export_group_flat_txt(group_result, param_values)
234
+ else:
235
+ _fail_or_print(args, "run-group", "usage", f"Formato de export invalido: {fmt}")
224
236
  else:
225
237
  if fmt == "csv":
226
238
  path = deps.export_group_csv(group_result, param_values)
227
239
  elif fmt == "json":
228
240
  path = deps.export_group_json(group_result, param_values)
229
- else:
241
+ elif fmt == "txt":
230
242
  path = deps.export_group_txt(group_result, param_values)
243
+ elif fmt == "html":
244
+ path = deps.export_group_html(group_result, param_values)
245
+ else:
246
+ _fail_or_print(args, "run-group", "usage", f"Formato de export invalido: {fmt}")
231
247
  if args.format == "json":
232
248
  ok("run-group", {"exported": str(path), "format": fmt})
233
249
  else:
@@ -412,8 +428,12 @@ def cmd_sql(args: argparse.Namespace) -> None:
412
428
  path = deps.export_query_csv(qr, "adhoc", param_values)
413
429
  elif fmt == "json":
414
430
  path = deps.export_query_json(qr, "adhoc", param_values)
415
- else:
431
+ elif fmt == "txt":
416
432
  path = deps.export_query_txt(qr, "adhoc", param_values)
433
+ elif fmt == "html":
434
+ path = deps.export_query_html(qr, "adhoc", param_values)
435
+ else:
436
+ _fail_or_print(args, "sql", "usage", f"Formato de export invalido: {fmt}")
417
437
  if args.format == "json":
418
438
  ok("sql", {"exported": str(path), "format": fmt})
419
439
  return
@@ -39,6 +39,7 @@ from dbqm.core.exporter import (
39
39
  export_query_txt,
40
40
  )
41
41
  from dbqm.core.group_engine import build_group_result
42
+ from dbqm.core.html_report import export_group_html, export_query_html
42
43
  from dbqm.core.history import (
43
44
  clear_history,
44
45
  load_history,
@@ -83,9 +84,11 @@ __all__ = [
83
84
  "export_group_flat_csv",
84
85
  "export_group_flat_json",
85
86
  "export_group_flat_txt",
87
+ "export_group_html",
86
88
  "export_group_json",
87
89
  "export_group_txt",
88
90
  "export_query_csv",
91
+ "export_query_html",
89
92
  "export_query_json",
90
93
  "export_query_txt",
91
94
  "extract_ddl",
@@ -6,6 +6,7 @@ from html import escape as h
6
6
  from typing import Any
7
7
 
8
8
  from dbqm.core.group_engine import GroupResult
9
+ from dbqm.core.query_engine import QueryResult
9
10
  from dbqm.core.exporter import _build_filepath
10
11
  from dbqm.design.tokens import LIGHT_TOKENS, DARK_TOKENS
11
12
 
@@ -29,6 +30,25 @@ def _light_theme_block(tokens: dict[str, str]) -> str:
29
30
  return f"@media (prefers-color-scheme: light) {{\n :root {{\n{linhas}\n }}\n}}"
30
31
 
31
32
 
33
+ #: Layout rules shared by every report shell -- reset, body, header, the
34
+ #: params table and the data table. Renderer-specific rules (status
35
+ #: colouring, filter bar, search box, …) stay in their own `_build_*`
36
+ #: function; only what both renderers actually share lives here, so a change
37
+ #: to shared padding cannot drift between the two documents.
38
+ _BASE_STYLE_RULES = """\
39
+ * { margin: 0; padding: 0; box-sizing: border-box; }
40
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--ds-background); color: var(--ds-text); padding: 24px; }
41
+ .header { background: var(--ds-panel); border-radius: 8px; padding: 20px; margin-bottom: 20px; }
42
+ .header h1 { color: var(--ds-identity); font-size: 1.4em; }
43
+ .header .meta { color: var(--ds-text-muted); font-size: 0.85em; margin-top: 8px; }
44
+ .params { margin: 12px 0; border-collapse: collapse; }
45
+ .params td { padding: 4px 16px 4px 0; color: var(--ds-text-muted); font-size: 0.9em; }
46
+ table.data { width: 100%; border-collapse: collapse; margin-bottom: 8px; font-size: 0.9em; }
47
+ table.data th { background: var(--ds-panel); color: var(--ds-identity); padding: 8px 12px; text-align: left; border-bottom: 2px solid var(--ds-border); }
48
+ table.data td { padding: 6px 12px; border-bottom: 1px solid var(--ds-border); }
49
+ table.data tr:hover { background: var(--ds-surface-raised); }"""
50
+
51
+
32
52
  def export_group_html(group_result: GroupResult, params: dict[str, str] | None = None) -> str:
33
53
  """Export group comparison as a standalone HTML file. Returns file path."""
34
54
  query_names = list(group_result.query_results.keys())
@@ -39,6 +59,18 @@ def export_group_html(group_result: GroupResult, params: dict[str, str] | None =
39
59
  return str(filepath)
40
60
 
41
61
 
62
+ def export_query_html(
63
+ result: QueryResult,
64
+ table: str = "",
65
+ params: dict[str, str] | None = None,
66
+ ) -> str:
67
+ """Export one result set as a standalone HTML file. Returns file path."""
68
+ label = table or result.query_name
69
+ filepath = _build_filepath("consultas", label, result.connection_name, params, "html")
70
+ filepath.write_text(_build_query_html(result, label, params), encoding="utf-8")
71
+ return str(filepath)
72
+
73
+
42
74
  def _status_class(status: str) -> str:
43
75
  return {"OK": "ok", "OK*": "ok", "DIFF": "diff", "ABSENT": "absent"}.get(status, "")
44
76
 
@@ -106,26 +138,16 @@ def _build_html(group_result: GroupResult, query_names: list[str], params: dict[
106
138
  despercebido. Por isso: nenhuma cor composta em CSS pode carregar
107
139
  texto; texto so usa var(--token) puro, e estado em superficie e
108
140
  borda/marcador, nunca preenchimento. */
109
- * {{ margin: 0; padding: 0; box-sizing: border-box; }}
110
- body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--ds-background); color: var(--ds-text); padding: 24px; }}
111
- .header {{ background: var(--ds-panel); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
112
- .header h1 {{ color: var(--ds-identity); font-size: 1.4em; }}
113
- .header .meta {{ color: var(--ds-text-muted); font-size: 0.85em; margin-top: 8px; }}
141
+ {_BASE_STYLE_RULES}
114
142
  .badge {{ display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: bold; font-size: 0.85em; }}
115
143
  /* Estado em superficie e borda/marcador, nunca preenchimento: um preenchimento
116
144
  misturado a partir do proprio texto sempre compromete um dos dois. */
117
145
  .badge.ok {{ background: var(--ds-panel); color: var(--ds-verdict-match); border: 1px solid var(--ds-verdict-match); }}
118
146
  .badge.diff {{ background: var(--ds-panel); color: var(--ds-verdict-diff); border: 1px solid var(--ds-verdict-diff); }}
119
- .params {{ margin: 12px 0; border-collapse: collapse; }}
120
- .params td {{ padding: 4px 16px 4px 0; color: var(--ds-text-muted); font-size: 0.9em; }}
121
147
  h3 {{ color: var(--ds-identity); margin: 24px 0 8px; }}
122
148
  .filter-bar {{ margin-bottom: 8px; }}
123
149
  .filter-btn {{ background: var(--ds-panel); color: var(--ds-text-muted); border: 1px solid var(--ds-border); padding: 4px 12px; border-radius: 4px; cursor: pointer; margin-right: 4px; font-size: 0.8em; }}
124
150
  .filter-btn.active {{ background: var(--ds-surface-raised); color: var(--ds-identity); border-color: var(--ds-identity); }}
125
- table.data {{ width: 100%; border-collapse: collapse; margin-bottom: 8px; font-size: 0.9em; }}
126
- table.data th {{ background: var(--ds-panel); color: var(--ds-identity); padding: 8px 12px; text-align: left; border-bottom: 2px solid var(--ds-border); }}
127
- table.data td {{ padding: 6px 12px; border-bottom: 1px solid var(--ds-border); }}
128
- table.data tr:hover {{ background: var(--ds-surface-raised); }}
129
151
  .key {{ color: var(--ds-text-strong); font-weight: 600; }}
130
152
  .ok {{ color: var(--ds-verdict-match); font-weight: 600; }}
131
153
  .diff {{ color: var(--ds-verdict-diff); font-weight: 600; }}
@@ -182,3 +204,53 @@ function filterSearch(term) {{
182
204
  </script>
183
205
  </body>
184
206
  </html>"""
207
+
208
+
209
+ def _build_query_html(result: QueryResult, label: str, params: dict[str, str] | None) -> str:
210
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
211
+
212
+ params_html = ""
213
+ if params:
214
+ rows = "".join(
215
+ f"<tr><td><strong>{h(str(k))}</strong></td><td>{h(str(v))}</td></tr>"
216
+ for k, v in params.items()
217
+ )
218
+ params_html = f'<div class="meta">Parametros:</div><table class="params">{rows}</table>'
219
+
220
+ header_cols = "".join(f"<th>{h(str(col))}</th>" for col in result.columns)
221
+ rows_html = ""
222
+ for row in result.rows:
223
+ cells = "".join(f"<td>{'' if v is None else h(str(v))}</td>" for v in row)
224
+ rows_html += f"<tr>{cells}</tr>"
225
+
226
+ return f"""<!DOCTYPE html>
227
+ <html lang="pt-BR">
228
+ <head>
229
+ <meta charset="UTF-8">
230
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
231
+ <title>Relatorio - {h(label)}</title>
232
+ <style>
233
+ {css_variables(DARK_TOKENS)}
234
+ {_light_theme_block(LIGHT_TOKENS)}
235
+ {_BASE_STYLE_RULES}
236
+ </style>
237
+ </head>
238
+ <body>
239
+ <div class="header">
240
+ <h1>DB Query Manager - Relatorio de Consulta</h1>
241
+ <div class="meta">
242
+ Consulta: <strong>{h(label)}</strong> |
243
+ Conexao: <strong>{h(result.connection_name)}</strong> |
244
+ Data: {now} |
245
+ Linhas: {result.row_count} |
246
+ Tempo: {result.elapsed:.3f}s
247
+ </div>
248
+ {params_html}
249
+ </div>
250
+
251
+ <table class="data">
252
+ <thead><tr>{header_cols}</tr></thead>
253
+ <tbody>{rows_html}</tbody>
254
+ </table>
255
+ </body>
256
+ </html>"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbqm
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: Multi-database interactive query tool with fullscreen TUI
5
5
  Author: Silvio Chagas
6
6
  License-Expression: MIT
@@ -261,6 +261,20 @@ class TestCmdRun:
261
261
  run_cli(["run", "test_query", "-e", "csv"])
262
262
  mock_exp.assert_called_once()
263
263
 
264
+ def test_run_export_html(self, tmp_config_dir):
265
+ query = _make_query()
266
+ conn = _make_connection()
267
+ result = _make_query_result()
268
+
269
+ with patch("dbqm.cli.deps.find_query", return_value=query), \
270
+ patch("dbqm.cli.deps.find_connection", return_value=conn), \
271
+ patch("dbqm.cli.deps.execute_query", return_value=result), \
272
+ patch("dbqm.cli.deps.record_query_execution"), \
273
+ patch("dbqm.cli.deps.log_execution"), \
274
+ patch("dbqm.cli.deps.export_query_html", return_value="/tmp/out.html") as mock_exp:
275
+ run_cli(["run", "test_query", "-e", "html"])
276
+ mock_exp.assert_called_once()
277
+
264
278
  def test_run_format_json(self, capsys):
265
279
  """`data` is `QueryResult.to_dict()` verbatim: `rows` are parallel
266
280
  arrays (a list per row, indexed by `columns`), not a dict per row —
@@ -428,6 +442,83 @@ class TestCmdRunGroup:
428
442
  run_cli(["run-group", "test_group", "-e", "csv"])
429
443
  mock_exp.assert_called_once()
430
444
 
445
+ def test_group_export_html(self):
446
+ group = _make_group()
447
+ q1 = _make_query("q1", "c1")
448
+ q2 = _make_query("q2", "c2")
449
+ c1 = _make_connection("c1")
450
+ c2 = _make_connection("c2")
451
+ gr = _make_group_result()
452
+
453
+ def find_query_side(name):
454
+ return {"q1": q1, "q2": q2}.get(name)
455
+
456
+ def find_conn_side(name):
457
+ return {"c1": c1, "c2": c2}.get(name)
458
+
459
+ with patch("dbqm.cli.deps.find_group", return_value=group), \
460
+ patch("dbqm.cli.deps.find_query", side_effect=find_query_side), \
461
+ patch("dbqm.cli.deps.find_connection", side_effect=find_conn_side), \
462
+ patch("dbqm.cli.deps.execute_query", return_value=_make_query_result()), \
463
+ patch("dbqm.cli.deps.build_group_result", return_value=gr), \
464
+ patch("dbqm.cli.deps.record_group_execution"), \
465
+ patch("dbqm.cli.deps.export_group_html", return_value="/tmp/g.html") as mock_exp:
466
+ run_cli(["run-group", "test_group", "-e", "html"])
467
+ mock_exp.assert_called_once()
468
+
469
+ def test_group_flat_html_is_refused_table_format(self, capsys):
470
+ """The trap: `--flat` is a CSV-shaped denormalisation with no HTML
471
+ equivalent. Silently ignoring the flag would hand back a different
472
+ shape (the non-flat report) with no signal that `--flat` was
473
+ dropped. Checked under `table` too, not just `json`: a refusal that
474
+ only fires under one renderer is the bug class 2.3.0 existed to
475
+ fix.
476
+
477
+ The refusal fires before the group is even resolved (a bad argument
478
+ combination costs no work), so `find_group` is mocked but must never
479
+ be called -- that is also what proves this test is pinned to *this*
480
+ refusal and not to the flat arm's generic bare-`else` fallback,
481
+ which would also exit 2 with no export call but only after
482
+ resolving the group and running every query in it.
483
+ """
484
+ with patch("dbqm.cli.deps.find_group") as mock_find_group, \
485
+ patch("dbqm.cli.deps.export_group_flat_csv") as mock_flat_csv, \
486
+ patch("dbqm.cli.deps.export_group_flat_json") as mock_flat_json, \
487
+ patch("dbqm.cli.deps.export_group_flat_txt") as mock_flat_txt:
488
+ with pytest.raises(SystemExit) as excinfo:
489
+ run_cli(["run-group", "test_group", "--flat", "-e", "html"])
490
+ assert excinfo.value.code == 2
491
+ saida = capsys.readouterr().out
492
+ assert "--flat" in saida, "must name the refused flag, not just exit 2"
493
+ assert "Exportado:" not in saida, "no export must have happened"
494
+ mock_find_group.assert_not_called()
495
+ mock_flat_csv.assert_not_called()
496
+ mock_flat_json.assert_not_called()
497
+ mock_flat_txt.assert_not_called()
498
+
499
+ def test_group_flat_html_is_refused_json_format(self, capsys):
500
+ """Same refusal, `-f json` side: the contract is that stdout stays
501
+ empty and the failure carries the usage token on stderr. Also fires
502
+ before the group is resolved -- see the table-format sibling above
503
+ for why `find_group` must stay uncalled."""
504
+ with patch("dbqm.cli.deps.find_group") as mock_find_group, \
505
+ patch("dbqm.cli.deps.export_group_flat_csv") as mock_flat_csv, \
506
+ patch("dbqm.cli.deps.export_group_flat_json") as mock_flat_json, \
507
+ patch("dbqm.cli.deps.export_group_flat_txt") as mock_flat_txt:
508
+ with pytest.raises(SystemExit) as excinfo:
509
+ run_cli(["run-group", "test_group", "--flat", "-e", "html", "-f", "json"])
510
+ assert excinfo.value.code == 2
511
+ saida = capsys.readouterr()
512
+ assert saida.out == ""
513
+ corpo = json.loads(saida.err)
514
+ assert corpo["error"]["code"] == "usage"
515
+ assert "--flat" in corpo["error"]["message"]
516
+ assert "html" in corpo["error"]["message"]
517
+ mock_find_group.assert_not_called()
518
+ mock_flat_csv.assert_not_called()
519
+ mock_flat_json.assert_not_called()
520
+ mock_flat_txt.assert_not_called()
521
+
431
522
  def test_group_export_json_format_emits_envelope(self, capsys):
432
523
  """CRITICAL fix: `-e` used to print bare prose and return under
433
524
  `-f json`."""
@@ -683,6 +774,24 @@ class TestCmdSql:
683
774
  run_cli(["sql", "SELECT 1", "test_conn", "-e", "csv"])
684
775
  mock_exp.assert_called_once()
685
776
 
777
+ def test_sql_export_html_does_not_fall_through_to_txt(self):
778
+ """The trap: `sql`'s export branch used to end in a bare `else` that
779
+ wrote TXT, so a new format would have written the wrong file and
780
+ reported success."""
781
+ conn = _make_connection()
782
+ from dbqm.core.query_engine import AdhocResult
783
+ adhoc = AdhocResult(
784
+ sql_type="SELECT", connection_name="test_conn",
785
+ columns=["x"], rows=[[1]], row_count=1, elapsed=0.01,
786
+ )
787
+ with patch("dbqm.cli.deps.find_connection", return_value=conn), \
788
+ patch("dbqm.cli.deps.execute_adhoc", return_value=adhoc), \
789
+ patch("dbqm.cli.deps.export_query_html", return_value="/tmp/o.html") as mock_html, \
790
+ patch("dbqm.cli.deps.export_query_txt") as mock_txt:
791
+ run_cli(["sql", "SELECT 1", "test_conn", "-e", "html"])
792
+ mock_html.assert_called_once()
793
+ mock_txt.assert_not_called()
794
+
686
795
  def test_sql_export_json_format_emits_envelope(self, capsys):
687
796
  """CRITICAL fix: `-e` used to print bare prose and return under
688
797
  `-f json`."""
@@ -1 +0,0 @@
1
- __version__ = "2.3.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes