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>"