# -*- coding: utf-8 -*-"""This module implements the Dataset class, which binds a Setting to a sayt2search index and provides build/search operations."""importioimportjsonimporttypingasTimporturllib.requestfrompathlibimportPathfromzipfileimportZipFilefromdataclassesimportdataclassfromfunctoolsimportcached_propertyfromsayt2.apiimportDataSetasSayt2DataSetfrom.settingimportSetting
[docs]@dataclassclassDataset:""" A search dataset backed by a sayt2 index. Identified by a *name* and a *dir_root* directory that owns all dataset resources under a shared convention: - ``{dir_root}/{name}-setting.json`` -- field schema and display config - ``{dir_root}/{name}-data.json`` -- the records to index - ``{dir_root}/{name}-index/`` -- sayt2 index directory (auto-created) - ``{dir_root}/icons/{name}.png`` -- per-result icons (resolved on demand) """name:strdir_root:Path# ------------------------------------------------------------------# Computed paths (cached so repeated access is free)# ------------------------------------------------------------------@cached_propertydefpath_setting(self)->Path:returnself.dir_root/f"{self.name}-setting.json"@cached_propertydefpath_data(self)->Path:returnself.dir_root/f"{self.name}-data.json"@cached_propertydefdir_index(self)->Path:returnself.dir_root/f"{self.name}-index"@cached_propertydefdir_icons(self)->Path:returnself.dir_root/"icons"# ------------------------------------------------------------------# Resource accessors# ------------------------------------------------------------------
[docs]defget_icon(self,name:str)->Path:"""Return the path to ``{dir_icons}/{name}``."""returnself.dir_icons/name
[docs]defget_setting(self)->Setting:"""Load and return the :class:`.Setting` from disk (no cache)."""returnSetting.from_json_file(self.path_setting)
[docs]defget_data(self)->list[dict]:"""Read records from the local data JSON file. If the file does not exist, :meth:`download_data` is called first. """ifnotself.path_data.exists():# pragma: no coverself.download_data()returnjson.loads(self.path_data.read_text())
@cached_propertydefsetting(self)->Setting:"""Parsed :class:`.Setting`, cached after the first call."""returnself.get_setting()
[docs]defget_sayt2_dataset(self)->Sayt2DataSet:"""Create a :class:`sayt2.DataSet` wired to this dataset's index and setting."""returnSayt2DataSet(dir_root=self.dir_index,name=self.name,fields=self.setting.fields,downloader=self.get_data,sort=self.setting.sort,)
# ------------------------------------------------------------------# Download helpers (network-free parts are testable)# ------------------------------------------------------------------@staticmethoddef_extract_json_from_zip(zip_bytes:bytes)->bytes:"""Return the raw bytes of the first ``.json`` file found in *zip_bytes*."""withZipFile(io.BytesIO(zip_bytes))aszf:json_names=[nforninzf.namelist()ifn.endswith(".json")]returnzf.read(json_names[0])def_save_data(self,raw_bytes:bytes,is_zip:bool)->None:"""Write *raw_bytes* to :attr:`path_data`, decompressing if *is_zip* is True."""data_bytes=self._extract_json_from_zip(raw_bytes)ifis_zipelseraw_bytesself.path_data.write_bytes(data_bytes)def_fetch_url(self,url:str)->bytes:# pragma: no cover"""Download *url* and return raw bytes via :mod:`urllib`."""withurllib.request.urlopen(url)asresp:returnresp.read()
[docs]defdownload_data(self)->None:# pragma: no cover"""Download the dataset from :attr:`Setting.data_url` and save it locally. Raises :class:`ValueError` if ``data_url`` is not configured. """url=self.setting.data_urlifurlisNone:raiseValueError(f"'data_url' is not defined in setting file '{self.path_setting}'.")raw=self._fetch_url(url)self._save_data(raw,is_zip=url.endswith(".zip"))
# ------------------------------------------------------------------# Index and search# ------------------------------------------------------------------
[docs]defbuild_index(self,data:list[dict[str,T.Any]]|None=None,rebuild:bool=False,)->int:"""Build the sayt2 search index from *data*. :param data: records to index; if ``None`` the configured downloader is used. :param rebuild: if ``True``, evict the query cache before building so subsequent searches always reflect the new index. :returns: number of documents indexed. """ds=self.get_sayt2_dataset()ifrebuild:ds._cache.evict_all()count=ds.build_index(data=data)ds.close()returncount
[docs]defsearch(self,query:str,limit:int=20,)->list[dict[str,T.Any]]:"""Search the index and return matching documents as plain dicts. :param query: Lucene-syntax query string. :param limit: maximum number of results to return. :returns: list of ``hit.source`` dicts ordered by relevance / sort key. """withself.get_sayt2_dataset()asds:result=ds.search(query,limit=limit)return[hit.sourceforhitinresult.hits]