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():
93 if hasattr(v, 'to_wkt'):
94 params[k] = v.to_wkt()
95 elif isinstance(v, (int, float)):
99 elif isinstance(extra_params, Sequence) and extra_params:
100 for k in extra_params[0]:
103 sqlstr = str(compiled)
105 if conn.dialect.name == 'postgresql':
106 if sa.__version__.startswith('1'):
108 sqlstr = re.sub(r'__\[POSTCOMPILE_[^]]*\]', '%s', sqlstr)
109 return sqlstr % tuple((repr(params.get(name, None))
110 for name in compiled.positiontup)) # type: ignore
114 # Fixes an odd issue with Python 3.7 where percentages are not
116 sqlstr = re.sub(r'%(?!\()', '%%', sqlstr)
117 sqlstr = re.sub(r'__\[POSTCOMPILE_([^]]*)\]', r'%(\1)s', sqlstr)
118 return sqlstr % params
120 assert conn.dialect.name == 'sqlite'
122 # params in positional order
123 pparams = (repr(params.get(name, None)) for name in compiled.positiontup) # type: ignore
125 sqlstr = re.sub(r'__\[POSTCOMPILE_([^]]*)\]', '?', sqlstr)
126 sqlstr = re.sub(r"\?", lambda m: next(pparams), sqlstr)
130 class HTMLLogger(BaseLogger):
131 """ Logger that formats messages in HTML.
133 def __init__(self) -> None:
134 self.buffer = io.StringIO()
137 def _timestamp(self) -> None:
138 self._write(f'<p class="timestamp">[{dt.datetime.now()}]</p>')
141 def get_buffer(self) -> str:
142 return HTML_HEADER + self.buffer.getvalue() + HTML_FOOTER
145 def function(self, func: str, **kwargs: Any) -> None:
147 self._write(f"<h1>Debug output for {func}()</h1>\n<p>Parameters:<dl>")
148 for name, value in kwargs.items():
149 self._write(f'<dt>{name}</dt><dd>{self._python_var(value)}</dd>')
150 self._write('</dl></p>')
153 def section(self, heading: str) -> None:
155 self._write(f"<h2>{heading}</h2>")
158 def comment(self, text: str) -> None:
160 self._write(f"<p>{text}</p>")
163 def var_dump(self, heading: str, var: Any) -> None:
168 self._write(f'<h5>{heading}</h5>{self._python_var(var)}')
171 def table_dump(self, heading: str, rows: Iterator[Optional[List[Any]]]) -> None:
175 self._write(f'<table><thead><tr><th colspan="{len(head)}">{heading}</th></tr><tr>')
177 self._write(f'<th>{cell}</th>')
178 self._write('</tr></thead><tbody>')
183 self._write(f'<td>{cell}</td>')
185 self._write('</tbody></table>')
188 def result_dump(self, heading: str, results: Iterator[Tuple[Any, Any]]) -> None:
189 """ Print a list of search results generated by the generator function.
192 def format_osm(osm_object: Optional[Tuple[str, int]]) -> str:
206 return f'<a href="https://www.openstreetmap.org/{fullt}/{i}">{t}{i}</a>'
208 self._write(f'<h5>{heading}</h5><p><dl>')
210 for rank, res in results:
211 self._write(f'<dt>[{rank:.3f}]</dt> <dd>{res.source_table.name}(')
212 self._write(f"{_debug_name(res)}, type=({','.join(res.category)}), ")
213 self._write(f"rank={res.rank_address}, ")
214 self._write(f"osm={format_osm(res.osm_object)}, ")
215 self._write(f'cc={res.country_code}, ')
216 self._write(f'importance={res.importance or float("nan"):.5f})</dd>')
218 self._write(f'</dl><b>TOTAL:</b> {total}</p>')
221 def sql(self, conn: AsyncConnection, statement: 'sa.Executable',
222 params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None]) -> None:
224 sqlstr = self.format_sql(conn, statement, params)
226 sqlstr = highlight(sqlstr, PostgresLexer(),
227 HtmlFormatter(nowrap=True, lineseparator='<br />'))
228 self._write(f'<div class="highlight"><code class="lang-sql">{sqlstr}</code></div>')
230 self._write(f'<code class="lang-sql">{sqlstr}</code>')
233 def _python_var(self, var: Any) -> str:
235 fmt = highlight(str(var), PythonLexer(), HtmlFormatter(nowrap=True))
236 return f'<div class="highlight"><code class="lang-python">{fmt}</code></div>'
238 return f'<code class="lang-python">{str(var)}</code>'
241 def _write(self, text: str) -> None:
242 """ Add the raw text to the debug output.
244 self.buffer.write(text)
247 class TextLogger(BaseLogger):
248 """ Logger creating output suitable for the console.
250 def __init__(self) -> None:
251 self.buffer = io.StringIO()
254 def _timestamp(self) -> None:
255 self._write(f'[{dt.datetime.now()}]\n')
258 def get_buffer(self) -> str:
259 return self.buffer.getvalue()
262 def function(self, func: str, **kwargs: Any) -> None:
263 self._write(f"#### Debug output for {func}()\n\nParameters:\n")
264 for name, value in kwargs.items():
265 self._write(f' {name}: {self._python_var(value)}\n')
269 def section(self, heading: str) -> None:
271 self._write(f"\n# {heading}\n\n")
274 def comment(self, text: str) -> None:
275 self._write(f"{text}\n")
278 def var_dump(self, heading: str, var: Any) -> None:
282 self._write(f'{heading}:\n {self._python_var(var)}\n\n')
285 def table_dump(self, heading: str, rows: Iterator[Optional[List[Any]]]) -> None:
286 self._write(f'{heading}:\n')
287 data = [list(map(self._python_var, row)) if row else None for row in rows]
288 assert data[0] is not None
289 num_cols = len(data[0])
291 maxlens = [max(len(d[i]) for d in data if d) for i in range(num_cols)]
292 tablewidth = sum(maxlens) + 3 * num_cols + 1
293 row_format = '| ' +' | '.join(f'{{:<{l}}}' for l in maxlens) + ' |\n'
294 self._write('-'*tablewidth + '\n')
295 self._write(row_format.format(*data[0]))
296 self._write('-'*tablewidth + '\n')
299 self._write(row_format.format(*row))
301 self._write('-'*tablewidth + '\n')
303 self._write('-'*tablewidth + '\n')
306 def result_dump(self, heading: str, results: Iterator[Tuple[Any, Any]]) -> None:
308 self._write(f'{heading}:\n')
310 for rank, res in results:
311 self._write(f'[{rank:.3f}] {res.source_table.name}(')
312 self._write(f"{_debug_name(res)}, type=({','.join(res.category)}), ")
313 self._write(f"rank={res.rank_address}, ")
314 self._write(f"osm={''.join(map(str, res.osm_object or []))}, ")
315 self._write(f'cc={res.country_code}, ')
316 self._write(f'importance={res.importance or -1:.5f})\n')
318 self._write(f'TOTAL: {total}\n\n')
321 def sql(self, conn: AsyncConnection, statement: 'sa.Executable',
322 params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None]) -> None:
324 sqlstr = '\n| '.join(textwrap.wrap(self.format_sql(conn, statement, params), width=78))
325 self._write(f"| {sqlstr}\n\n")
328 def _python_var(self, var: Any) -> str:
332 def _write(self, text: str) -> None:
333 self.buffer.write(text)
336 logger: ContextVar[BaseLogger] = ContextVar('logger', default=BaseLogger())
339 def set_log_output(fmt: str) -> None:
340 """ Enable collecting debug information.
343 logger.set(HTMLLogger())
345 logger.set(TextLogger())
347 logger.set(BaseLogger())
350 def log() -> BaseLogger:
351 """ Return the logger for the current context.
356 def get_and_disable() -> str:
357 """ Return the current content of the debug buffer and disable logging.
359 buf = logger.get().get_buffer()
360 logger.set(BaseLogger())
364 HTML_HEADER: str = """<!DOCTYPE html>
367 <title>Nominatim - Debug</title>
370 (HtmlFormatter(nobackground=True).get_style_defs('.highlight') if CODE_HIGHLIGHT else '') +\
372 h2 { font-size: x-large }
376 font-family: monospace
385 dt::after { content: ": "; }
398 border: solid lightgrey 0.1pt;
400 background-color: #f7f7f7
405 border: solid lightgrey 0.1pt
410 border-collapse: collapse;
413 border-right: thin solid;
421 width: calc(100% - 5pt);
432 HTML_FOOTER: str = "</body></html>"