php-version: ${{ matrix.php }}
tools: phpunit:9, phpcs, composer
ini-values: opcache.jit=disable
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-python@v4
with:
working-directory: Nominatim/test/bdd
- name: Install mypy and typechecking info
- run: pip3 install -U mypy osmium uvicorn types-PyYAML types-jinja2 types-psycopg2 types-psutil types-requests types-ujson typing-extensions
+ run: pip3 install -U mypy osmium uvicorn types-PyYAML types-jinja2 types-psycopg2 types-psutil types-requests types-ujson types-Pygments typing-extensions
if: matrix.flavour != 'oldstuff'
- name: Python static typechecking
pip3 install --user behave mkdocs mkdocstrings pytest pytest-asyncio pylint \
mypy types-PyYAML types-jinja2 types-psycopg2 types-psutil \
- types-ujson types-requests typing-extensions\
+ types-ujson types-requests types-Pygments typing-extensions\
sanic-testing httpx asgi-lifespan
```
$sSQL .= ' FROM placex';
$sSQL .= ' WHERE osm_type = \'N\'';
$sSQL .= ' AND country_code = \''.$sCountryCode.'\'';
- $sSQL .= ' AND rank_search < 26 '; // needed to select right index
+ $sSQL .= ' AND rank_address between 4 and 25'; // needed to select right index
$sSQL .= ' AND rank_search between 5 and ' .min(25, $iMaxRank);
- $sSQL .= ' AND class = \'place\' AND type != \'postcode\'';
+ $sSQL .= ' AND type != \'postcode\'';
$sSQL .= ' AND name IS NOT NULL ';
$sSQL .= ' and indexed_status = 0 and linked_place_id is null';
- $sSQL .= ' AND ST_DWithin('.$sPointSQL.', geometry, 1.8)) p ';
+ $sSQL .= ' AND ST_Buffer(geometry, reverse_place_diameter(rank_search)) && '.$sPointSQL;
+ $sSQL .= ') as a ';
$sSQL .= 'WHERE distance <= reverse_place_diameter(rank_search)';
$sSQL .= ' ORDER BY rank_search DESC, distance ASC';
$sSQL .= ' LIMIT 1';
$sSQL .= ' ST_distance('.$sPointSQL.', geometry) as distance';
$sSQL .= ' FROM placex';
$sSQL .= ' WHERE osm_type = \'N\'';
- // using rank_search because of a better differentiation
- // for place nodes at rank_address 16
$sSQL .= ' AND rank_search > '.$iRankSearch;
$sSQL .= ' AND rank_search <= '.$iMaxRank;
- $sSQL .= ' AND rank_search < 26 '; // needed to select right index
- $sSQL .= ' AND rank_address > 0';
- $sSQL .= ' AND class = \'place\'';
+ $sSQL .= ' AND rank_address between 4 and 25'; // needed to select right index
$sSQL .= ' AND type != \'postcode\'';
$sSQL .= ' AND name IS NOT NULL ';
$sSQL .= ' AND indexed_status = 0 AND linked_place_id is null';
- $sSQL .= ' AND ST_DWithin('.$sPointSQL.', geometry, reverse_place_diameter('.$iRankSearch.'::smallint))';
- $sSQL .= ' ORDER BY distance ASC,';
- $sSQL .= ' rank_address DESC';
- $sSQL .= ' limit 500) as a';
- $sSQL .= ' WHERE ST_CONTAINS((SELECT geometry FROM placex WHERE place_id = '.$iPlaceID.'), geometry )';
+ $sSQL .= ' AND ST_Buffer(geometry, reverse_place_diameter(rank_search)) && '.$sPointSQL;
+ $sSQL .= ' ORDER BY rank_search DESC, distance ASC';
+ $sSQL .= ' limit 100) as a';
+ $sSQL .= ' WHERE ST_Contains((SELECT geometry FROM placex WHERE place_id = '.$iPlaceID.'), geometry )';
$sSQL .= ' AND distance <= reverse_place_diameter(rank_search)';
- $sSQL .= ' ORDER BY distance ASC, rank_search DESC';
+ $sSQL .= ' ORDER BY rank_search DESC, distance ASC';
$sSQL .= ' LIMIT 1';
Debug::printSQL($sSQL);
AND rank_address between 4 and 25 AND type != 'postcode'
AND name is not null AND indexed_status = 0 AND linked_place_id is null;
---
+-- used in reverse large area lookup
+CREATE INDEX IF NOT EXISTS idx_placex_geometry_reverse_lookupPlaceNode
+ ON placex USING gist (ST_Buffer(geometry, reverse_place_diameter(rank_search)))
+ {{db.tablespace.search_index}}
+ WHERE rank_address between 4 and 25 AND type != 'postcode'
+ AND name is not null AND linked_place_id is null AND osm_type = 'N';
+---
CREATE INDEX IF NOT EXISTS idx_osmline_parent_place_id
ON location_property_osmline USING BTREE (parent_place_id) {{db.tablespace.search_index}}
WHERE parent_place_id is not null;
-- Usage: - linking of similar named places to boundaries
-- - linking of place nodes with same type to boundaries
--- - lookupPolygon()
CREATE INDEX idx_placex_geometry_placenode ON placex
USING {{postgres.spgist_geom}} (geometry) {{db.tablespace.address_index}}
WHERE osm_type = 'N' and rank_search < 26
from sqlalchemy.ext.asyncio import AsyncConnection
from nominatim.db.sqlalchemy_schema import SearchTables
+from nominatim.api.logging import log
class SearchConnection:
""" An extended SQLAlchemy connection class, that also contains
) -> Any:
""" Execute a 'scalar()' query on the connection.
"""
+ log().sql(self.connection, sql)
return await self.connection.scalar(sql, params)
- async def execute(self, sql: sa.sql.base.Executable,
+ async def execute(self, sql: 'sa.Executable',
params: Union[Mapping[str, Any], Sequence[Mapping[str, Any]], None] = None
- ) -> 'sa.engine.Result[Any]':
+ ) -> 'sa.Result[Any]':
""" Execute a 'execute()' query on the connection.
"""
+ log().sql(self.connection, sql)
return await self.connection.execute(sql, params)
--- /dev/null
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# This file is part of Nominatim. (https://nominatim.org)
+#
+# Copyright (C) 2023 by the Nominatim developer community.
+# For a full list of authors see the git log.
+"""
+Functions for specialised logging with HTML output.
+"""
+from typing import Any, cast
+from contextvars import ContextVar
+import textwrap
+import io
+
+import sqlalchemy as sa
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+try:
+ from pygments import highlight
+ from pygments.lexers import PythonLexer, PostgresLexer
+ from pygments.formatters import HtmlFormatter
+ CODE_HIGHLIGHT = True
+except ModuleNotFoundError:
+ CODE_HIGHLIGHT = False
+
+
+class BaseLogger:
+ """ Interface for logging function.
+
+ The base implementation does nothing. Overwrite the functions
+ in derived classes which implement logging functionality.
+ """
+ def get_buffer(self) -> str:
+ """ Return the current content of the log buffer.
+ """
+ return ''
+
+ def function(self, func: str, **kwargs: Any) -> None:
+ """ Start a new debug chapter for the given function and its parameters.
+ """
+
+
+ def section(self, heading: str) -> None:
+ """ Start a new section with the given title.
+ """
+
+
+ def comment(self, text: str) -> None:
+ """ Add a simple comment to the debug output.
+ """
+
+
+ def var_dump(self, heading: str, var: Any) -> None:
+ """ Print the content of the variable to the debug output prefixed by
+ the given heading.
+ """
+
+
+ def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None:
+ """ Print the SQL for the given statement.
+ """
+
+
+class HTMLLogger(BaseLogger):
+ """ Logger that formats messages in HTML.
+ """
+ def __init__(self) -> None:
+ self.buffer = io.StringIO()
+
+
+ def get_buffer(self) -> str:
+ return HTML_HEADER + self.buffer.getvalue() + HTML_FOOTER
+
+
+ def function(self, func: str, **kwargs: Any) -> None:
+ self._write(f"<h1>Debug output for {func}()</h1>\n<p>Parameters:<dl>")
+ for name, value in kwargs.items():
+ self._write(f'<dt>{name}</dt><dd>{self._python_var(value)}</dd>')
+ self._write('</dl></p>')
+
+
+ def section(self, heading: str) -> None:
+ self._write(f"<h2>{heading}</h2>")
+
+
+ def comment(self, text: str) -> None:
+ self._write(f"<p>{text}</p>")
+
+
+ def var_dump(self, heading: str, var: Any) -> None:
+ self._write(f'<h5>{heading}</h5>{self._python_var(var)}')
+
+
+ def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None:
+ sqlstr = str(cast('sa.ClauseElement', statement)
+ .compile(conn.sync_engine, compile_kwargs={"literal_binds": True}))
+ if CODE_HIGHLIGHT:
+ sqlstr = highlight(sqlstr, PostgresLexer(),
+ HtmlFormatter(nowrap=True, lineseparator='<br>'))
+ self._write(f'<div class="highlight"><code class="lang-sql">{sqlstr}</code></div>')
+ else:
+ self._write(f'<code class="lang-sql">{sqlstr}</code>')
+
+
+ def _python_var(self, var: Any) -> str:
+ if CODE_HIGHLIGHT:
+ fmt = highlight(repr(var), PythonLexer(), HtmlFormatter(nowrap=True))
+ return f'<div class="highlight"><code class="lang-python">{fmt}</code></div>'
+
+ return f'<code class="lang-python">{str(var)}</code>'
+
+
+ def _write(self, text: str) -> None:
+ """ Add the raw text to the debug output.
+ """
+ self.buffer.write(text)
+
+
+class TextLogger(BaseLogger):
+ """ Logger creating output suitable for the console.
+ """
+ def __init__(self) -> None:
+ self.buffer = io.StringIO()
+
+
+ def get_buffer(self) -> str:
+ return self.buffer.getvalue()
+
+
+ def function(self, func: str, **kwargs: Any) -> None:
+ self._write(f"#### Debug output for {func}()\n\nParameters:\n")
+ for name, value in kwargs.items():
+ self._write(f' {name}: {self._python_var(value)}\n')
+ self._write('\n')
+
+
+ def section(self, heading: str) -> None:
+ self._write(f"\n# {heading}\n\n")
+
+
+ def comment(self, text: str) -> None:
+ self._write(f"{text}\n")
+
+
+ def var_dump(self, heading: str, var: Any) -> None:
+ self._write(f'{heading}:\n {self._python_var(var)}\n\n')
+
+
+ def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None:
+ sqlstr = str(cast('sa.ClauseElement', statement)
+ .compile(conn.sync_engine, compile_kwargs={"literal_binds": True}))
+ sqlstr = '\n| '.join(textwrap.wrap(sqlstr, width=78))
+ self._write(f"| {sqlstr}\n\n")
+
+
+ def _python_var(self, var: Any) -> str:
+ return str(var)
+
+
+ def _write(self, text: str) -> None:
+ self.buffer.write(text)
+
+
+logger: ContextVar[BaseLogger] = ContextVar('logger', default=BaseLogger())
+
+
+def set_log_output(fmt: str) -> None:
+ """ Enable collecting debug information.
+ """
+ if fmt == 'html':
+ logger.set(HTMLLogger())
+ elif fmt == 'text':
+ logger.set(TextLogger())
+ else:
+ logger.set(BaseLogger())
+
+
+def log() -> BaseLogger:
+ """ Return the logger for the current context.
+ """
+ return logger.get()
+
+
+def get_and_disable() -> str:
+ """ Return the current content of the debug buffer and disable logging.
+ """
+ buf = logger.get().get_buffer()
+ logger.set(BaseLogger())
+ return buf
+
+
+HTML_HEADER: str = """<!DOCTYPE html>
+<html>
+<head>
+ <title>Nominatim - Debug</title>
+ <style>
+""" + \
+(HtmlFormatter(nobackground=True).get_style_defs('.highlight') if CODE_HIGHLIGHT else '') +\
+"""
+ h2 { font-size: x-large }
+
+ dl {
+ padding-left: 10pt;
+ font-family: monospace
+ }
+
+ dt {
+ float: left;
+ font-weight: bold;
+ margin-right: 0.5em
+ }
+
+ dt::after { content: ": "; }
+
+ dd::after {
+ clear: left;
+ display: block
+ }
+
+ .lang-sql {
+ color: #555;
+ font-size: small
+ }
+
+ h5 {
+ border: solid lightgrey 0.1pt;
+ margin-bottom: 0;
+ background-color: #f7f7f7
+ }
+
+ h5 + .highlight {
+ padding: 3pt;
+ border: solid lightgrey 0.1pt
+ }
+ </style>
+</head>
+<body>
+"""
+
+HTML_FOOTER: str = "</body></html>"
from nominatim.api.connection import SearchConnection
import nominatim.api.types as ntyp
import nominatim.api.results as nres
+from nominatim.api.logging import log
def _select_column_geometry(column: SaColumn,
geometry_output: ntyp.GeometryFormat) -> SaLabel:
""" Search for the given place in the placex table and return the
base information.
"""
+ log().section("Find in placex table")
t = conn.t.placex
sql = sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
t.c.class_, t.c.type, t.c.admin_level,
t.c.importance, t.c.wikipedia, t.c.indexed_date,
t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
t.c.linked_place_id,
- sa.func.ST_X(t.c.centroid).label('x'),
- sa.func.ST_Y(t.c.centroid).label('y'),
+ t.c.centroid,
_select_column_geometry(t.c.geometry, details.geometry_output))
if isinstance(place, ntyp.PlaceID):
""" Search for the given place in the osmline table and return the
base information.
"""
+ log().section("Find in interpolation table")
t = conn.t.osmline
sql = sa.select(t.c.place_id, t.c.osm_id, t.c.parent_place_id,
t.c.indexed_date, t.c.startnumber, t.c.endnumber,
t.c.step, t.c.address, t.c.postcode, t.c.country_code,
- sa.func.ST_X(sa.func.ST_Centroid(t.c.linegeo)).label('x'),
- sa.func.ST_Y(sa.func.ST_Centroid(t.c.linegeo)).label('y'),
+ t.c.linegeo.ST_Centroid().label('centroid'),
_select_column_geometry(t.c.linegeo, details.geometry_output))
if isinstance(place, ntyp.PlaceID):
""" Search for the given place in the table of Tiger addresses and return
the base information. Only lookup by place ID is supported.
"""
+ log().section("Find in TIGER table")
t = conn.t.tiger
sql = sa.select(t.c.place_id, t.c.parent_place_id,
t.c.startnumber, t.c.endnumber, t.c.step,
t.c.postcode,
- sa.func.ST_X(sa.func.ST_Centroid(t.c.linegeo)).label('x'),
- sa.func.ST_Y(sa.func.ST_Centroid(t.c.linegeo)).label('y'),
+ t.c.linegeo.ST_Centroid().label('centroid'),
_select_column_geometry(t.c.linegeo, details.geometry_output))
if isinstance(place, ntyp.PlaceID):
""" Search for the given place in the postcode table and return the
base information. Only lookup by place ID is supported.
"""
+ log().section("Find in postcode table")
t = conn.t.postcode
sql = sa.select(t.c.place_id, t.c.parent_place_id,
t.c.rank_search, t.c.rank_address,
t.c.indexed_date, t.c.postcode, t.c.country_code,
- sa.func.ST_X(t.c.geometry).label('x'),
- sa.func.ST_Y(t.c.geometry).label('y'),
+ t.c.geometry.label('centroid'),
_select_column_geometry(t.c.geometry, details.geometry_output))
if isinstance(place, ntyp.PlaceID):
details: ntyp.LookupDetails) -> Optional[nres.SearchResult]:
""" Retrieve a place with additional details from the database.
"""
+ log().function('get_place_by_id', place=place, details=details)
+
if details.geometry_output and details.geometry_output != ntyp.GeometryFormat.GEOJSON:
raise ValueError("lookup only supports geojosn polygon output.")
row = await find_in_placex(conn, place, details)
if row is not None:
result = nres.create_from_placex_row(row)
+ log().var_dump('Result', result)
await nres.add_result_details(conn, result, details)
return result
row = await find_in_osmline(conn, place, details)
if row is not None:
result = nres.create_from_osmline_row(row)
+ log().var_dump('Result', result)
await nres.add_result_details(conn, result, details)
return result
row = await find_in_postcode(conn, place, details)
if row is not None:
result = nres.create_from_postcode_row(row)
+ log().var_dump('Result', result)
await nres.add_result_details(conn, result, details)
return result
row = await find_in_tiger(conn, place, details)
if row is not None:
result = nres.create_from_tiger_row(row)
+ log().var_dump('Result', result)
await nres.add_result_details(conn, result, details)
return result
from nominatim.typing import SaSelect, SaRow
from nominatim.api.types import Point, LookupDetails
from nominatim.api.connection import SearchConnection
+from nominatim.api.logging import log
# This file defines complex result data classes.
# pylint: disable=too-many-instance-attributes
return self.importance or (0.7500001 - (self.rank_search/40.0))
- # pylint: disable=consider-using-f-string
- def centroid_as_geojson(self) -> str:
- """ Get the centroid in GeoJSON format.
- """
- return '{"type": "Point","coordinates": [%f, %f]}' % self.centroid
-
-
def _filter_geometries(row: SaRow) -> Dict[str, str]:
return {k[9:]: v for k, v in row._mapping.items() # pylint: disable=W0212
if k.startswith('geometry_')}
importance=row.importance,
country_code=row.country_code,
indexed_date=getattr(row, 'indexed_date'),
- centroid=Point(row.x, row.y),
+ centroid=Point.from_wkb(row.centroid.data),
geometry=_filter_geometries(row))
'step': str(row.step)},
country_code=row.country_code,
indexed_date=getattr(row, 'indexed_date'),
- centroid=Point(row.x, row.y),
+ centroid=Point.from_wkb(row.centroid.data),
geometry=_filter_geometries(row))
'endnumber': str(row.endnumber),
'step': str(row.step)},
country_code='us',
- centroid=Point(row.x, row.y),
+ centroid=Point.from_wkb(row.centroid.data),
geometry=_filter_geometries(row))
rank_search=row.rank_search,
rank_address=row.rank_address,
country_code=row.country_code,
- centroid=Point(row.x, row.y),
+ centroid=Point.from_wkb(row.centroid.data),
indexed_date=row.indexed_date,
geometry=_filter_geometries(row))
""" Retrieve more details from the database according to the
parameters specified in 'details'.
"""
+ log().section('Query details for result')
if details.address_details:
+ log().comment('Query address details')
await complete_address_details(conn, result)
if details.linked_places:
+ log().comment('Query linked places')
await complete_linked_places(conn, result)
if details.parented_places:
+ log().comment('Query parent places')
await complete_parented_places(conn, result)
if details.keywords:
+ log().comment('Query keywords')
await complete_keywords(conn, result)
from typing import Optional, Union, NamedTuple
import dataclasses
import enum
+from struct import unpack
@dataclasses.dataclass
class PlaceID:
return self.x
+ def to_geojson(self) -> str:
+ """ Return the point in GeoJSON format.
+ """
+ return f'{{"type": "Point","coordinates": [{self.x}, {self.y}]}}'
+
+
+ @staticmethod
+ def from_wkb(wkb: bytes) -> 'Point':
+ """ Create a point from EWKB as returned from the database.
+ """
+ if len(wkb) != 25:
+ raise ValueError("Point wkb has unexpected length")
+ if wkb[0] == 0:
+ gtype, srid, x, y = unpack('>iidd', wkb[1:])
+ elif wkb[0] == 1:
+ gtype, srid, x, y = unpack('<iidd', wkb[1:])
+ else:
+ raise ValueError("WKB has unknown endian value.")
+
+ if gtype != 0x20000001:
+ raise ValueError("WKB must be a point geometry.")
+ if srid != 4326:
+ raise ValueError("Only WGS84 WKB supported.")
+
+ return Point(x, y)
+
+
class GeometryFormat(enum.Flag):
""" Geometry output formats supported by Nominatim.
"""
def _format_search_json(result: napi.SearchResult, options: Mapping[str, Any]) -> str:
locales = options.get('locales', napi.Locales())
geom = result.geometry.get('geojson')
- centroid = result.centroid_as_geojson()
+ centroid = result.centroid.to_geojson()
out = JsonWriter()
out.start_object()\
from nominatim.config import Configuration
import nominatim.api as napi
+import nominatim.api.logging as loglib
from nominatim.api.v1.format import dispatch as formatting
CONTENT_TYPE = {
'text': 'text/plain; charset=utf-8',
'xml': 'text/xml; charset=utf-8',
- 'jsonp': 'application/javascript'
+ 'jsonp': 'application/javascript',
+ 'debug': 'text/html; charset=utf-8'
}
or self.config().DEFAULT_LANGUAGE
+ def setup_debugging(self) -> bool:
+ """ Set up collection of debug information if requested.
+
+ Return True when debugging was requested.
+ """
+ if self.get_bool('debug', False):
+ loglib.set_log_output('html')
+ return True
+
+ return False
+
+
def parse_format(params: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
""" Get and check the 'format' parameter and prepare the formatter.
`fmtter` is a formatter and `default` the
raise params.error("Missing ID parameter 'place_id' or 'osmtype'.")
place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
+ debug = params.setup_debugging()
+
details = napi.LookupDetails(address_details=params.get_bool('addressdetails', False),
linked_places=params.get_bool('linkedplaces', False),
parented_places=params.get_bool('hierarchy', False),
details.geometry_output = napi.GeometryFormat.GEOJSON
locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
- print(locales.languages)
result = await api.lookup(place, details)
+ if debug:
+ return params.build_response(loglib.get_and_disable(), 'debug')
+
if result is None:
raise params.error('No place with that OSM ID found.', status=404)
has_run_migration = False
for version, func in _MIGRATION_FUNCTIONS:
- if db_version <= version:
+ if db_version < version or \
+ (db_version == (3, 5, 0, 99) and version == (3, 5, 0, 99)):
title = func.__doc__ or ''
LOG.warning("Running: %s (%s)", title.split('\n', 1)[0], version)
kwargs = dict(conn=conn, config=config, paths=paths)
ON planet_osm_rels USING gin (parts)
WITH (fastupdate=off)""")
cur.execute("ANALYZE planet_osm_ways")
+
+
+@_migration(4, 2, 99, 1)
+def add_improved_geometry_reverse_placenode_index(conn: Connection, **_: Any) -> None:
+ """ Create improved index for reverse lookup of place nodes.
+ """
+ with conn.cursor() as cur:
+ cur.execute("""CREATE INDEX IF NOT EXISTS idx_placex_geometry_reverse_lookupPlaceNode
+ ON placex
+ USING gist (ST_Buffer(geometry, reverse_place_diameter(rank_search)))
+ WHERE rank_address between 4 and 25 AND type != 'postcode'
+ AND name is not null AND linked_place_id is null AND osm_type = 'N'
+ """)
return f"{self.major}.{self.minor}.{self.patch_level}-{self.db_patch_level}"
-NOMINATIM_VERSION = NominatimVersion(4, 2, 99, 0)
+NOMINATIM_VERSION = NominatimVersion(4, 2, 99, 1)
POSTGRESQL_REQUIRED_VERSION = (9, 6)
POSTGIS_REQUIRED_VERSION = (2, 2)
import nominatim.api as napi
from nominatim.db.sql_preprocessor import SQLPreprocessor
+import nominatim.api.logging as loglib
class APITester:
SQLPreprocessor(temp_db_conn, testapi.api.config)\
.run_sql_file(temp_db_conn, 'functions/address_lookup.sql')
+ loglib.set_log_output('text')
yield testapi
+ print(loglib.get_and_disable())
testapi.api.close()
@pytest.fixture(autouse=True)
def setup_status_mock(self, monkeypatch):
result = napi.SearchResult(napi.SourceTable.PLACEX, ('place', 'thing'),
- (1.0, -3.0))
+ napi.Point(1.0, -3.0))
monkeypatch.setattr(napi.NominatimAPI, 'lookup',
lambda *args: result)
('--relation', '1'),
('--place_id', '10001')])
- def test_status_json_format(self, cli_call, tmp_path, capsys, params):
+ def test_details_json_format(self, cli_call, tmp_path, capsys, params):
result = cli_call('details', '--project-dir', str(tmp_path), *params)
assert result == 0