"""Apply the metadata collector."""
import os
import time
from fnmatch import fnmatch
from functools import lru_cache
from pathlib import Path
from types import NoneType
from typing import (
Any,
Collection,
Dict,
FrozenSet,
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
import tomlkit
import yaml
from rich.prompt import Prompt
from .api.config import CrawlerSettings, Datasets, DRSConfig, strip_protocol
from .api.metadata_stores import (
CatalogueBackendType,
CatalogueReader,
)
from .api.stores import IndexName
from .data_collector import DataCollector
from .logger import apply_verbosity, get_level_from_verbosity, logger
from .utils import (
Console,
EmptyCrawl,
IndexProgress,
MetadataCrawlerException,
find_closest,
load_plugins,
timedelta_to_str,
)
FilesArg = Union[str, Path, Sequence[Union[str, Path]]]
@lru_cache(maxsize=None)
def _norm_files_cached(
uris: Tuple[str, ...],
backend: Optional[CatalogueBackendType],
opts: FrozenSet[Tuple[str, str]],
) -> Tuple[str, ...]:
storage_options = dict(opts)
flat_uris: Tuple[str, ...] = ()
for _uri in uris:
flat_uris += tuple(
CatalogueReader.rglob_stores(_uri, backend=backend, **storage_options)
)
return flat_uris
def _norm_files(
metadata_stores: Optional[Union[FilesArg, Sequence[FilesArg]]],
backend: Optional[CatalogueBackendType] = None,
**storage_options: Any,
) -> Tuple[str, ...]:
if metadata_stores is None:
return ("",)
raw: Tuple[str, ...] = (
(str(metadata_stores),)
if isinstance(metadata_stores, (str, Path))
else tuple(str(p) for p in metadata_stores)
)
return _norm_files_cached(raw, backend, frozenset(storage_options.items()))
def _match(match: str, items: Collection[str]) -> List[str]:
out: List[str] = []
for item in items:
if fnmatch(item, match):
out.append(item)
if not out:
msg = find_closest(f"No such dataset: {match}", match, items)
raise MetadataCrawlerException(msg) from None
return out
def _get_num_of_indexed_objects(
metadata_stores: Optional[Union[Sequence[FilesArg], FilesArg]],
backend: Optional[CatalogueBackendType] = None,
storage_options: Optional[Dict[str, Any]] = None,
) -> int:
num_objects = 0
storage_options = storage_options or {}
for _uri in _norm_files(metadata_stores, backend=backend, **storage_options):
try:
cat = CatalogueReader.read_catalogue_metadata(
_uri, backend=backend, **storage_options
)
num_objects += cat.get("indexed_objects", 0)
except (FileNotFoundError, IsADirectoryError, yaml.parser.ParserError):
pass
return num_objects
def _get_search(
config: Dict[str, Datasets],
search_dirs: Optional[List[str]] = None,
datasets: Optional[List[str]] = None,
) -> list[CrawlerSettings]:
_search_items = []
search_dirs = search_dirs or []
datasets = datasets or []
if not datasets and not search_dirs:
return [
CrawlerSettings(name=k, search_path=cfg.root_path)
for (k, cfg) in config.items()
]
for item in datasets or []:
for ds in _match(item, config.keys()):
logger.debug("Adding dataset %s", ds)
_search_items.append(
CrawlerSettings(name=ds, search_path=config[ds].root_path)
)
for num, _dir in enumerate(map(strip_protocol, search_dirs or [])):
for name, cfg in config.items():
if _dir.is_relative_to(strip_protocol(cfg.root_path)):
logger.debug("Adding dataset %s", name)
_search_items.append(
CrawlerSettings(name=name, search_path=str(search_dirs[num]))
)
return _search_items
async def async_call(
index_system: str,
method: str,
uris: Optional[Sequence[str]] = None,
batch_size: int = 2500,
verbosity: int = 0,
log_suffix: Optional[str] = None,
num_objects: int = 0,
*args: Any,
**kwargs: Any,
) -> None:
"""Add / Delete metadata from index."""
env = cast(os._Environ[str], os.environ.copy())
old_level = apply_verbosity(verbosity, suffix=log_suffix)
try:
progress = IndexProgress(total=num_objects)
os.environ["MDC_LOG_INIT"] = "1"
os.environ["MDC_LOG_LEVEL"] = str(get_level_from_verbosity(verbosity))
os.environ["MDC_LOG_SUFFIX"] = log_suffix or os.getenv("MDC_LOG_SUFFIX") or ""
backends = load_plugins("metadata_crawler.ingester")
try:
cls = backends[index_system]
except KeyError:
msg = find_closest(
f"No such backend: {index_system}", index_system, backends.keys()
)
raise ValueError(msg) from None
storage_options = kwargs.pop("storage_options", {})
progress.start()
for _uri in uris or [""]:
async with cls(
batch_size=batch_size,
uri=_uri or None,
storage_options=storage_options,
progress=progress,
) as obj:
func = getattr(obj, method)
await func(**kwargs)
finally:
os.environ = env
progress.stop()
logger.set_level(old_level)
[docs]
async def async_index(
index_system: str,
*metadata_stores: FilesArg,
batch_size: int = 2500,
verbosity: int = 0,
log_suffix: Optional[str] = None,
backend: Optional[CatalogueBackendType] = None,
**kwargs: Any,
) -> None:
"""Index metadata in the indexing system.
Parameters
^^^^^^^^^^
index_system:
The index server where the metadata is indexed.
metadata_stores:
Uri to the metadata store(s).
batch_size:
If the index system supports batch-sizes, the size of the batches.
verbosity:
Set the verbosity of the system.
log_suffix:
Add a suffix to the log file output.
backend:
Backend to be used for the metadata store. If None given (default)
the backend will be guessed from the storage uri
.. versionchanged:: 2605.0.0
Added ``"mongodb"`` and ``"postgresql"`` backends.
Other Parameters
^^^^^^^^^^^^^^^^
**kwargs:
Keyword arguments used to delete data from the index.
Example
^^^^^^^
.. code-block:: python
await async_index(
"solr"
"/tmp/catalog.yaml",
server="localhost:8983",
batch_size=1000,
)
"""
storage_options: Dict[str, Any] = kwargs.get("storage_options", {})
_mdata: Optional[Union[FilesArg, Sequence[FilesArg]]] = kwargs.pop(
"metadata_stores", None
)
_mdata = metadata_stores or _mdata
await async_call(
index_system,
"index",
batch_size=batch_size,
verbosity=verbosity,
log_suffix=log_suffix,
uris=_norm_files(_mdata, backend=backend, **storage_options),
num_objects=_get_num_of_indexed_objects(
_mdata,
backend=backend,
storage_options=storage_options,
),
**kwargs,
)
[docs]
async def async_delete(
index_system: str,
batch_size: int = 2500,
verbosity: int = 0,
log_suffix: Optional[str] = None,
**kwargs: Any,
) -> None:
"""Delete metadata from the indexing system.
Parameters
^^^^^^^^^^^
index_system:
The index server where the metadata is indexed.
batch_size:
If the index system supports batch-sizes, the size of the batches.
verbosity:
Set the verbosity of the system.
log_suffix:
Add a suffix to the log file output.
Other Parameters
^^^^^^^^^^^^^^^^^
**kwargs:
Keyword arguments used to delete data from the index.
Examples
^^^^^^^^
.. code-block:: python
await async_delete(
"solr"
server="localhost:8983",
latest_version="latest",
facets=[("file", "*.nc"), ("project", "OBS")],
)
"""
await async_call(
index_system,
"delete",
batch_size=batch_size,
verbosity=verbosity,
log_suffix=log_suffix,
**kwargs,
)
[docs]
async def async_add(
*config_files: Union[Path, str, Dict[str, Any], tomlkit.TOMLDocument],
store: Optional[Union[str, Path, Dict[str, Any], tomlkit.TOMLDocument]] = None,
data_object: Optional[Union[str, List[str]]] = None,
data_set: Optional[Union[List[str], str]] = None,
data_store_prefix: Optional[str] = None,
collection: Optional[str] = None,
table: Optional[str] = None,
batch_size: int = 25_000,
catalogue_backend: Optional[CatalogueBackendType] = None,
backend: Optional[CatalogueBackendType] = None,
comp_level: int = 4,
storage_options: Optional[Dict[str, Any]] = None,
shadow: Optional[Union[str, List[str]]] = None,
latest_version: str = IndexName().latest,
all_versions: str = IndexName().all,
password: bool = False,
n_procs: Optional[int] = None,
no_sweep: bool = False,
sweep_grace_period: int = 5,
verbosity: int = 0,
log_suffix: Optional[str] = None,
fail_under: int = -1,
**kwargs: Any,
) -> None:
"""Harvest metadata from storage systems and add them to an intake catalogue.
.. versionchanged:: 2511.0.0
The catalogue argument has been rearanged and is now a keyword
argument: ``async_add("data.yaml", "drs-config.toml")`` becomes
``async_add("drs-config.toml", store="data.yaml")``. If the ``store`` keyword
is omitted the output catalogue will be interpreted as config file.
Parameters
^^^^^^^^^^
config_files:
Path to the drs-config file / loaded configuration.
store:
Path to the intake catalogue.
data_objects:
Instead of defining datasets that are to be crawled you can crawl
data based on their directories. The directories must be a root dirs
given in the drs-config file. By default all root dirs are crawled.
data_object:
Objects (directories or catalogue files) that are processed.
data_set:
Dataset(s) that should be crawled. The datasets need to be defined
in the drs-config file. By default all datasets are crawled.
Names can contain wildcards such as ``xces-*``.
data_store_prefix:
Name or path of the metadata store. For the *jsonlines*
backend this is a filesystem path prefix for the ``.json.gz``
files (resolved relative to *yaml_path* unless absolute).
For database backends it serves as the default collection or
table name. Defaults to ``"metadata"``.
collection:
Alias for *data_store_prefix* — preferred when using the
*mongodb* backend. Maps directly to the MongoDB collection
name.
table:
Alias for *data_store_prefix* — preferred when using the
*sql* backend. Maps directly to the SQL table name.
backend:
Backend to be used for the metadata store. If None given (default)
the backend will be guessed from the storage uri
.. versionchanged:: 2605.0.0
Added ``"mongodb"`` and ``"postgresql"`` backends.
catalogue_backend:
Alias for ``backend``
no_sweep:
Skip removal of stale records after crawling.
By default, database backends (MongoDB, PostgreSQL)
remove entries older than the grace period "
(set via ``sweep_grace_period``).
Use this flag for partial or incremental crawls
where not all data sources are being re-discovered.
.. versionadded:: 2605.0.0
sweep_grace_period:
Number of days to keep records before they become eligible
for sweeping. Records older than this grace period are
removed after a crawl. Overrides the MDC_GRACE_DAYS
environment variable. Defaults to 5 days.
.. versionadded:: 2605.0.0
batch_size:
Batch size that is used to collect the meta data. This can affect
performance.
comp_level:
Compression level used to write the meta data to csv.gz
storage_options:
Set additional storage options for adding metadata to the metadata store
shadow:
'Shadow' this storage options. This is useful to hide secrets in public
data catalogues.
latest_version:
Name of the core holding 'latest' metadata.
all_versions:
Name of the core holding 'all' metadata versions.
password:
Display a password prompt before beginning
n_procs:
Set the number of parallel processes for collecting.
verbosity:
Set the verbosity of the system.
log_suffix:
Add a suffix to the log file output.
fail_under:
Fail if less than X of the discovered files could be indexed.
Other Parameters
^^^^^^^^^^^^^^^^
**kwargs:
Additional keyword arguments.
Examples
^^^^^^^^
.. code-block:: python
await async_add(
"~/data/drs-config.toml",
store="my-data.yaml",
data_set=["cmip6", "cordex"],
)
"""
env = cast(os._Environ[str], os.environ.copy())
old_level = apply_verbosity(verbosity, suffix=log_suffix)
eval_env_dir = os.getenv("EVALUATION_SYSTEM_CONFIG_DIR")
cfg_files_fallback = (eval_env_dir,) if eval_env_dir else ()
backend = backend or catalogue_backend
try:
os.environ["MDC_LOG_INIT"] = "1"
os.environ["MDC_LOG_LEVEL"] = str(get_level_from_verbosity(verbosity))
os.environ["MDC_LOG_SUFFIX"] = log_suffix or os.getenv("MDC_LOG_SUFFIX") or ""
config_files = config_files or cfg_files_fallback
if not all(config_files):
raise MetadataCrawlerException("You must give a config file/directory")
st = time.time()
passwd: Optional[str] = None
if password: # pragma: no cover
passwd = Prompt.ask(
"[b]Enter the password", password=True
) # pragma: no cover
if passwd:
os.environ["DRS_STORAGE_PASSWD"] = passwd
data_object = (
data_object
if isinstance(data_object, (NoneType, list))
else [str(data_object)]
)
data_set = (
data_set if isinstance(data_set, (NoneType, list)) else [str(data_set)]
)
cfg = DRSConfig.load(*config_files)
async with DataCollector(
cfg,
store,
IndexName(latest=latest_version, all=all_versions),
*_get_search(cfg.datasets, data_object, data_set),
batch_size=batch_size,
comp_level=comp_level,
data_store_prefix=data_store_prefix or "",
table=table or "",
collection=collection or "",
n_procs=n_procs,
storage_options=storage_options or {},
shadow=shadow,
backend=backend,
no_sweep=no_sweep,
sweep_grace_period=sweep_grace_period,
**kwargs,
) as data_col:
await data_col.ingest_data()
num_files = data_col.ingested_objects
files_discovered = data_col.crawled_files
dt = timedelta_to_str(time.time() - st)
num_files = data_col.ingest_queue.total_files_discovered
logger.info("Discovered: %s files", f"{files_discovered:10,.0f}")
logger.info("Ingested: %s files", f"{num_files:10,.0f}")
logger.info("Spend: %s", dt)
Console.print(" " * Console.width, end="\r")
Console.print(
(
f"[bold]Ingested [green]{num_files:10,.0f}[/green] "
f"within [green]{dt}[/green][/bold]"
)
)
if (
files_discovered >= fail_under and num_files < fail_under
) or files_discovered == 0:
if data_col.ingest_queue.silent is False:
await data_col.ingest_queue.delete()
raise EmptyCrawl("Could not fulfill discovery threshold!") from None
finally:
os.environ = env
logger.set_level(old_level)