1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 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
17 import sqlalchemy as sa
18 from sqlalchemy.ext.asyncio import AsyncConnection
21 from pygments import highlight
22 from pygments.lexers import PythonLexer, PostgresLexer
23 from pygments.formatters import HtmlFormatter
25 except ModuleNotFoundError:
26 CODE_HIGHLIGHT = False
29 def _debug_name(res: Any) -> str:
31 return cast(str, res.names.get('name', next(iter(res.names.values()))))
33 return f"Hnr {res.housenumber}" if res.housenumber is not None else '[NONE]'
37 """ Interface for logging function.
39 The base implementation does nothing. Overwrite the functions
40 in derived classes which implement logging functionality.
42 def get_buffer(self) -> str:
43 """ Return the current content of the log buffer.
47 def function(self, func: str, **kwargs: Any) -> None:
48 """ 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.
57 def comment(self, text: str) -> None:
58 """ Add a simple comment to the debug output.
62 def var_dump(self, heading: str, var: Any) -> None:
63 """ Print the content of the variable to the debug output prefixed by
68 def table_dump(self, heading: str, rows: Iterator[Optional[List[Any]]]) -> None:
69 """ Print the table generated by the generator function.
73 def result_dump(self, heading: str, results: Iterator[Tuple[Any, Any]]) -> None:
74 """ Print a list of search results generated by the generator function.
78 def sql(self, conn: AsyncConnection, statement: 'sa.Executable',
79 params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None]) -> None:
80 """ Print the SQL for the given statement.
83 def format_sql(self, conn: AsyncConnection, statement: 'sa.Executable',
84 extra_params: Union[Mapping[str, Any],
85 Sequence[Mapping[str, Any]], None]) -> str:
86 """ Return the comiled version of the statement.
88 compiled = cast('sa.ClauseElement', statement).compile(conn.sync_engine)
90 params = dict(compiled.params)
91 if isinstance(extra_params, Mapping):
92 for k, v in extra_params.items():
94 elif isinstance(extra_params, Sequence) and extra_params:
95 for k in extra_params[0]:
98 sqlstr = str(compiled)
100 if sa.__version__.startswith('1'):
102 return sqlstr % tuple((repr(params.get(name, None))
103 for name in compiled.positiontup)) # type: ignore
107 # Fixes an odd issue with Python 3.7 where percentages are not
109 sqlstr = re.sub(r'%(?!\()', '%%', sqlstr)
110 return sqlstr % params
113 class HTMLLogger(BaseLogger):
114 """ Logger that formats messages in HTML.
116 def __init__(self) -> None:
117 self.buffer = io.StringIO()
120 def _timestamp(self) -> None:
121 self._write(f'<p class="timestamp">[{dt.datetime.now()}]</p>')
124 def get_buffer(self) -> str:
125 return HTML_HEADER + self.buffer.getvalue() + HTML_FOOTER
128 def function(self, func: str, **kwargs: Any) -> None:
130 self._write(f"<h1>Debug output for {func}()</h1>\n<p>Parameters:<dl>")
131 for name, value in kwargs.items():
132 self._write(f'<dt>{name}</dt><dd>{self._python_var(value)}</dd>')
133 self._write('</dl></p>')
136 def section(self, heading: str) -> None:
138 self._write(f"<h2>{heading}</h2>")
141 def comment(self, text: str) -> None:
143 self._write(f"<p>{text}</p>")
146 def var_dump(self, heading: str, var: Any) -> None:
151 self._write(f'<h5>{heading}</h5>{self._python_var(var)}')
154 def table_dump(self, heading: str, rows: Iterator[Optional[List[Any]]]) -> None:
158 self._write(f'<table><thead><tr><th colspan="{len(head)}">{heading}</th></tr><tr>')
160 self._write(f'<th>{cell}</th>')
161 self._write('</tr></thead><tbody>')
166 self._write(f'<td>{cell}</td>')
168 self._write('</tbody></table>')
171 def result_dump(self, heading: str, results: Iterator[Tuple[Any, Any]]) -> None:
172 """ Print a list of search results generated by the generator function.
175 def format_osm(osm_object: Optional[Tuple[str, int]]) -> str:
189 return f'<a href="https://www.openstreetmap.org/{fullt}/{i}">{t}{i}</a>'
191 self._write(f'<h5>{heading}</h5><p><dl>')
193 for rank, res in results:
194 self._write(f'<dt>[{rank:.3f}]</dt> <dd>{res.source_table.name}(')
195 self._write(f"{_debug_name(res)}, type=({','.join(res.category)}), ")
196 self._write(f"rank={res.rank_address}, ")
197 self._write(f"osm={format_osm(res.osm_object)}, ")
198 self._write(f'cc={res.country_code}, ')
199 self._write(f'importance={res.importance or float("nan"):.5f})</dd>')
201 self._write(f'</dl><b>TOTAL:</b> {total}</p>')
204 def sql(self, conn: AsyncConnection, statement: 'sa.Executable',
205 params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None]) -> None:
207 sqlstr = self.format_sql(conn, statement, params)
209 sqlstr = highlight(sqlstr, PostgresLexer(),
210 HtmlFormatter(nowrap=True, lineseparator='<br />'))
211 self._write(f'<div class="highlight"><code class="lang-sql">{sqlstr}</code></div>')
213 self._write(f'<code class="lang-sql">{sqlstr}</code>')
216 def _python_var(self, var: Any) -> str:
218 fmt = highlight(str(var), PythonLexer(), HtmlFormatter(nowrap=True))
219 return f'<div class="highlight"><code class="lang-python">{fmt}</code></div>'
221 return f'<code class="lang-python">{str(var)}</code>'
224 def _write(self, text: str) -> None:
225 """ Add the raw text to the debug output.
227 self.buffer.write(text)
230 class TextLogger(BaseLogger):
231 """ Logger creating output suitable for the console.
233 def __init__(self) -> None:
234 self.buffer = io.StringIO()
237 def get_buffer(self) -> str:
238 return self.buffer.getvalue()
241 def function(self, func: str, **kwargs: Any) -> None:
242 self._write(f"#### Debug output for {func}()\n\nParameters:\n")
243 for name, value in kwargs.items():
244 self._write(f' {name}: {self._python_var(value)}\n')
248 def section(self, heading: str) -> None:
249 self._write(f"\n# {heading}\n\n")
252 def comment(self, text: str) -> None:
253 self._write(f"{text}\n")
256 def var_dump(self, heading: str, var: Any) -> None:
260 self._write(f'{heading}:\n {self._python_var(var)}\n\n')
263 def table_dump(self, heading: str, rows: Iterator[Optional[List[Any]]]) -> None:
264 self._write(f'{heading}:\n')
265 data = [list(map(self._python_var, row)) if row else None for row in rows]
266 assert data[0] is not None
267 num_cols = len(data[0])
269 maxlens = [max(len(d[i]) for d in data if d) for i in range(num_cols)]
270 tablewidth = sum(maxlens) + 3 * num_cols + 1
271 row_format = '| ' +' | '.join(f'{{:<{l}}}' for l in maxlens) + ' |\n'
272 self._write('-'*tablewidth + '\n')
273 self._write(row_format.format(*data[0]))
274 self._write('-'*tablewidth + '\n')
277 self._write(row_format.format(*row))
279 self._write('-'*tablewidth + '\n')
281 self._write('-'*tablewidth + '\n')
284 def result_dump(self, heading: str, results: Iterator[Tuple[Any, Any]]) -> None:
285 self._write(f'{heading}:\n')
287 for rank, res in results:
288 self._write(f'[{rank:.3f}] {res.source_table.name}(')
289 self._write(f"{_debug_name(res)}, type=({','.join(res.category)}), ")
290 self._write(f"rank={res.rank_address}, ")
291 self._write(f"osm={''.join(map(str, res.osm_object or []))}, ")
292 self._write(f'cc={res.country_code}, ')
293 self._write(f'importance={res.importance or -1:.5f})\n')
295 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:
300 sqlstr = '\n| '.join(textwrap.wrap(self.format_sql(conn, statement, params), width=78))
301 self._write(f"| {sqlstr}\n\n")
304 def _python_var(self, var: Any) -> str:
308 def _write(self, text: str) -> None:
309 self.buffer.write(text)
312 logger: ContextVar[BaseLogger] = ContextVar('logger', default=BaseLogger())
315 def set_log_output(fmt: str) -> None:
316 """ Enable collecting debug information.
319 logger.set(HTMLLogger())
321 logger.set(TextLogger())
323 logger.set(BaseLogger())
326 def log() -> BaseLogger:
327 """ Return the logger for the current context.
332 def get_and_disable() -> str:
333 """ Return the current content of the debug buffer and disable logging.
335 buf = logger.get().get_buffer()
336 logger.set(BaseLogger())
340 HTML_HEADER: str = """<!DOCTYPE html>
343 <title>Nominatim - Debug</title>
346 (HtmlFormatter(nobackground=True).get_style_defs('.highlight') if CODE_HIGHLIGHT else '') +\
348 h2 { font-size: x-large }
352 font-family: monospace
361 dt::after { content: ": "; }
374 border: solid lightgrey 0.1pt;
376 background-color: #f7f7f7
381 border: solid lightgrey 0.1pt
386 border-collapse: collapse;
389 border-right: thin solid;
397 width: calc(100% - 5pt);
408 HTML_FOOTER: str = "</body></html>"