1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Functions for specialised logging with HTML output.
10 from typing import Any, Iterator, Optional, List, Tuple, cast, Union, Mapping, Sequence
11 from contextvars import ContextVar
18 import sqlalchemy as sa
19 from sqlalchemy.ext.asyncio import AsyncConnection
22 from pygments import highlight
23 from pygments.lexers import PythonLexer, PostgresLexer
24 from pygments.formatters import HtmlFormatter
26 except ModuleNotFoundError:
27 CODE_HIGHLIGHT = False
30 def _debug_name(res: Any) -> str:
32 return cast(str, res.names.get('name', next(iter(res.names.values()))))
34 return f"Hnr {res.housenumber}" if res.housenumber is not None else '[NONE]'
38 """ Interface for logging function.
40 The base implementation does nothing. Overwrite the functions
41 in derived classes which implement logging functionality.
43 def get_buffer(self) -> str:
44 """ Return the current content of the log buffer.
48 def function(self, func: str, **kwargs: Any) -> None:
49 """ Start a new debug chapter for the given function and its parameters.
52 def section(self, heading: str) -> None:
53 """ Start a new section with the given title.
56 def comment(self, text: str) -> None:
57 """ Add a simple comment to the debug output.
60 def var_dump(self, heading: str, var: Any) -> None:
61 """ Print the content of the variable to the debug output prefixed by
65 def table_dump(self, heading: str, rows: Iterator[Optional[List[Any]]]) -> None:
66 """ Print the table generated by the generator function.
69 def result_dump(self, heading: str, results: Iterator[Tuple[Any, Any]]) -> None:
70 """ Print a list of search results generated by the generator function.
73 def sql(self, conn: AsyncConnection, statement: 'sa.Executable',
74 params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None]) -> None:
75 """ Print the SQL for the given statement.
78 def format_sql(self, conn: AsyncConnection, statement: 'sa.Executable',
79 extra_params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None]
81 """ Return the compiled version of the statement.
83 compiled = cast('sa.ClauseElement', statement).compile(conn.sync_engine)
85 params = dict(compiled.params)
86 if isinstance(extra_params, Mapping):
87 for k, v in extra_params.items():
88 if hasattr(v, 'to_wkt'):
89 params[k] = v.to_wkt()
90 elif isinstance(v, (int, float)):
94 elif isinstance(extra_params, Sequence) and extra_params:
95 for k in extra_params[0]:
98 sqlstr = str(compiled)
100 if conn.dialect.name == 'postgresql':
101 if sa.__version__.startswith('1'):
103 sqlstr = re.sub(r'__\[POSTCOMPILE_[^]]*\]', '%s', sqlstr)
104 return sqlstr % tuple((repr(params.get(name, None))
105 for name in compiled.positiontup)) # type: ignore
109 # Fixes an odd issue with Python 3.7 where percentages are not
111 sqlstr = re.sub(r'%(?!\()', '%%', sqlstr)
112 sqlstr = re.sub(r'__\[POSTCOMPILE_([^]]*)\]', r'%(\1)s', sqlstr)
113 return sqlstr % params
115 assert conn.dialect.name == 'sqlite'
117 # params in positional order
118 pparams = (repr(params.get(name, None)) for name in compiled.positiontup) # type: ignore
120 sqlstr = re.sub(r'__\[POSTCOMPILE_([^]]*)\]', '?', sqlstr)
121 sqlstr = re.sub(r"\?", lambda m: next(pparams), sqlstr)
126 class HTMLLogger(BaseLogger):
127 """ Logger that formats messages in HTML.
129 def __init__(self) -> None:
130 self.buffer = io.StringIO()
132 def _timestamp(self) -> None:
133 self._write(f'<p class="timestamp">[{dt.datetime.now()}]</p>')
135 def get_buffer(self) -> str:
136 return HTML_HEADER + self.buffer.getvalue() + HTML_FOOTER
138 def function(self, func: str, **kwargs: Any) -> None:
140 self._write(f"<h1>Debug output for {func}()</h1>\n<p>Parameters:<dl>")
141 for name, value in kwargs.items():
142 self._write(f'<dt>{name}</dt><dd>{self._python_var(value)}</dd>')
143 self._write('</dl></p>')
145 def section(self, heading: str) -> None:
147 self._write(f"<h2>{heading}</h2>")
149 def comment(self, text: str) -> None:
151 self._write(f"<p>{text}</p>")
153 def var_dump(self, heading: str, var: Any) -> None:
158 self._write(f'<h5>{heading}</h5>{self._python_var(var)}')
160 def table_dump(self, heading: str, rows: Iterator[Optional[List[Any]]]) -> None:
164 self._write(f'<table><thead><tr><th colspan="{len(head)}">{heading}</th></tr><tr>')
166 self._write(f'<th>{cell}</th>')
167 self._write('</tr></thead><tbody>')
172 self._write(f'<td>{cell}</td>')
174 self._write('</tbody></table>')
176 def result_dump(self, heading: str, results: Iterator[Tuple[Any, Any]]) -> None:
177 """ Print a list of search results generated by the generator function.
181 def format_osm(osm_object: Optional[Tuple[str, int]]) -> str:
195 return f'<a href="https://www.openstreetmap.org/{fullt}/{i}">{t}{i}</a>'
197 self._write(f'<h5>{heading}</h5><p><dl>')
199 for rank, res in results:
200 self._write(f'<dt>[{rank:.3f}]</dt> <dd>{res.source_table.name}(')
201 self._write(f"{_debug_name(res)}, type=({','.join(res.category)}), ")
202 self._write(f"rank={res.rank_address}, ")
203 self._write(f"osm={format_osm(res.osm_object)}, ")
204 self._write(f'cc={res.country_code}, ')
205 self._write(f'importance={res.importance or float("nan"):.5f})</dd>')
207 self._write(f'</dl><b>TOTAL:</b> {total}</p>')
209 def sql(self, conn: AsyncConnection, statement: 'sa.Executable',
210 params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None]) -> None:
212 sqlstr = self.format_sql(conn, statement, params)
214 sqlstr = highlight(sqlstr, PostgresLexer(),
215 HtmlFormatter(nowrap=True, lineseparator='<br />'))
216 self._write(f'<div class="highlight"><code class="lang-sql">{sqlstr}</code></div>')
218 self._write(f'<code class="lang-sql">{html.escape(sqlstr)}</code>')
220 def _python_var(self, var: Any) -> str:
222 fmt = highlight(str(var), PythonLexer(), HtmlFormatter(nowrap=True))
223 return f'<div class="highlight"><code class="lang-python">{fmt}</code></div>'
225 return f'<code class="lang-python">{html.escape(str(var))}</code>'
227 def _write(self, text: str) -> None:
228 """ Add the raw text to the debug output.
230 self.buffer.write(text)
233 class TextLogger(BaseLogger):
234 """ Logger creating output suitable for the console.
236 def __init__(self) -> None:
237 self.buffer = io.StringIO()
239 def _timestamp(self) -> None:
240 self._write(f'[{dt.datetime.now()}]\n')
242 def get_buffer(self) -> str:
243 return self.buffer.getvalue()
245 def function(self, func: str, **kwargs: Any) -> None:
246 self._write(f"#### Debug output for {func}()\n\nParameters:\n")
247 for name, value in kwargs.items():
248 self._write(f' {name}: {self._python_var(value)}\n')
251 def section(self, heading: str) -> None:
253 self._write(f"\n# {heading}\n\n")
255 def comment(self, text: str) -> None:
256 self._write(f"{text}\n")
258 def var_dump(self, heading: str, var: Any) -> None:
262 self._write(f'{heading}:\n {self._python_var(var)}\n\n')
264 def table_dump(self, heading: str, rows: Iterator[Optional[List[Any]]]) -> None:
265 self._write(f'{heading}:\n')
266 data = [list(map(self._python_var, row)) if row else None for row in rows]
267 assert data[0] is not None
268 num_cols = len(data[0])
270 maxlens = [max(len(d[i]) for d in data if d) for i in range(num_cols)]
271 tablewidth = sum(maxlens) + 3 * num_cols + 1
272 row_format = '| ' + ' | '.join(f'{{:<{ln}}}' for ln in maxlens) + ' |\n'
273 self._write('-'*tablewidth + '\n')
274 self._write(row_format.format(*data[0]))
275 self._write('-'*tablewidth + '\n')
278 self._write(row_format.format(*row))
280 self._write('-'*tablewidth + '\n')
282 self._write('-'*tablewidth + '\n')
284 def result_dump(self, heading: str, results: Iterator[Tuple[Any, Any]]) -> None:
286 self._write(f'{heading}:\n')
288 for rank, res in results:
289 self._write(f'[{rank:.3f}] {res.source_table.name}(')
290 self._write(f"{_debug_name(res)}, type=({','.join(res.category)}), ")
291 self._write(f"rank={res.rank_address}, ")
292 self._write(f"osm={''.join(map(str, res.osm_object or []))}, ")
293 self._write(f'cc={res.country_code}, ')
294 self._write(f'importance={res.importance or -1:.5f})\n')
296 self._write(f'TOTAL: {total}\n\n')
298 def sql(self, conn: AsyncConnection, statement: 'sa.Executable',
299 params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None]) -> None:
301 sqlstr = '\n| '.join(textwrap.wrap(self.format_sql(conn, statement, params), width=78))
302 self._write(f"| {sqlstr}\n\n")
304 def _python_var(self, var: Any) -> str:
307 def _write(self, text: str) -> None:
308 self.buffer.write(text)
311 logger: ContextVar[BaseLogger] = ContextVar('logger', default=BaseLogger())
314 def set_log_output(fmt: str) -> None:
315 """ Enable collecting debug information.
318 logger.set(HTMLLogger())
320 logger.set(TextLogger())
322 logger.set(BaseLogger())
325 def log() -> BaseLogger:
326 """ Return the logger for the current context.
331 def get_and_disable() -> str:
332 """ Return the current content of the debug buffer and disable logging.
334 buf = logger.get().get_buffer()
335 logger.set(BaseLogger())
339 HTML_HEADER: str = """<!DOCTYPE html>
342 <title>Nominatim - Debug</title>
345 (HtmlFormatter(nobackground=True).get_style_defs('.highlight') if CODE_HIGHLIGHT else '') + \
347 h2 { font-size: x-large }
351 font-family: monospace
360 dt::after { content: ": "; }
373 border: solid lightgrey 0.1pt;
375 background-color: #f7f7f7
380 border: solid lightgrey 0.1pt
385 border-collapse: collapse;
388 border-right: thin solid;
396 width: calc(100% - 5pt);
407 HTML_FOOTER: str = "</body></html>"