Skip to content

Commit

Permalink
Merge pull request #73 from legend-exp/dbetto
Browse files Browse the repository at this point in the history
Deprecate `TextDB`, `AttrsDict`, `catalog` in favour of `dbetto`
  • Loading branch information
gipert authored Dec 22, 2024
2 parents 99d90c4 + 3a4cd77 commit 40c4a25
Show file tree
Hide file tree
Showing 15 changed files with 92 additions and 850 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,5 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

uv.lock
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
![License](https://img.shields.io/github/license/legend-exp/pylegendmeta)
[![Read the Docs](https://img.shields.io/readthedocs/pylegendmeta?logo=readthedocs)](https://pylegendmeta.readthedocs.io)

Access [legend-metadata](https://github.com/legend-exp/legend-metadata) in
Python.
Access [legend-metadata](https://github.com/legend-exp/legend-metadata) through
[dbetto](https://dbetto.readthedocs.io).
1 change: 1 addition & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"matplotlib": ("https://matplotlib.org/stable", None),
"sqlalchemy": ("https://docs.sqlalchemy.org", None),
"pygama": ("https://pygama.readthedocs.io/en/stable", None),
"dbetto": ("https://dbetto.readthedocs.io/en/stable", None),
} # add new intersphinx mappings here

# sphinx-autodoc
Expand Down
3 changes: 2 additions & 1 deletion docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ Welcome to pylegendmeta's documentation!
========================================

*pylegendmeta* is a Python package to access arbitrary text file databases,
specialized to the legend-metadata_ repository, which stores `LEGEND metadata
based on :mod:`dbetto` but specialized to the legend-metadata_ repository,
which stores `LEGEND metadata
<https://legend-exp.github.io/legend-data-format-specs/dev/metadata>`_.

Getting started
Expand Down
21 changes: 11 additions & 10 deletions docs/source/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ temporary (i.e. not preserved across system reboots) directory.
it or, alternatively, as an argument to the :class:`~.core.LegendMetadata`
constructor. Recommended if a custom legend-metadata_ is needed.

:class:`~.core.LegendMetadata` is a :class:`~.textdb.TextDB` object, which
implements an interface to a database of text files arbitrary scattered in a
filesystem. ``TextDB`` does not assume any directory structure or file naming.
:class:`~.core.LegendMetadata` is a :class:`dbetto.TextDB` object, provided by
the :mod:`dbetto` package, which implements an interface to a database of text
files arbitrary scattered in a filesystem. :class:`~dbetto.TextDB` does not
assume any directory structure or file naming.

.. note::

Expand Down Expand Up @@ -151,7 +152,7 @@ channel map:
Remapping and grouping metadata
-------------------------------

A second important method of ``TextDB`` is :meth:`.TextDB.map`, which allows to
A second important method of ``TextDB`` is :meth:`dbetto.TextDB.map`, which allows to
query ``(key, value)`` dictionaries with an alternative unique key defined in
``value``. A typical application is querying parameters in a channel map
corresponding to a certain DAQ channel:
Expand All @@ -167,10 +168,10 @@ corresponding to a certain DAQ channel:
...

If the requested key is not unique, an exception will be raised.
:meth:`.TextDB.map` can, however, handle non-unique keys too and return a
:meth:`dbetto.TextDB.map` can, however, handle non-unique keys too and return a
dictionary of matching entries instead, keyed by an arbitrary integer to allow
further :meth:`.TextDB.map` calls. The behavior is achieved by using
:meth:`.TextDB.group` or by setting the ``unique`` argument flag. A typical
further :meth:`dbetto.TextDB.map` calls. The behavior is achieved by using
:meth:`dbetto.TextDB.group` or by setting the ``unique`` argument flag. A typical
application is retrieving all channels attached to the same CC4:

>>> chmap = lmeta.hardware.configuration.channelmaps.on(datetime.now())
Expand All @@ -182,7 +183,7 @@ application is retrieving all channels attached to the same CC4:
'card': {'id': 1, 'address': '0x410', 'serialno': None},
'channel': 0,

For further details, have a look at the documentation for :meth:`.AttrsDict.map`.
For further details, have a look at the documentation for :meth:`dbetto.AttrsDict.map`.

LEGEND channel maps
-------------------
Expand All @@ -198,8 +199,8 @@ obtain channel-relevant metadata (hardware, analysis, etc.) in time:
>>> myicpc.analysis.usability # analysis info
'on'

Since :meth:`~.core.LegendMetadata.channelmap` returns an :class:`~.AttrsDict`,
other useful operations like :meth:`~.AttrsDict.map` can be applied.
Since :meth:`~.core.LegendMetadata.channelmap` returns an :class:`~dbetto.AttrsDict`,
other useful operations like :meth:`~dbetto.AttrsDict.map` can be applied.

Slow Control interface
----------------------
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ classifiers = [
]
requires-python = ">=3.9"
dependencies = [
"dbetto",
"GitPython",
"pandas",
"pyyaml",
Expand Down Expand Up @@ -102,7 +103,7 @@ ignore-words-list = "crate,nd,unparseable,compiletime"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
filterwarnings = ["error", 'ignore:\nPyarrow:DeprecationWarning']
filterwarnings = ["error", 'ignore:\nPyarrow:DeprecationWarning', 'ignore::DeprecationWarning']
log_cli_level = "info"
testpaths = "tests"

Expand Down
3 changes: 2 additions & 1 deletion src/legendmeta/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@

from __future__ import annotations

from dbetto import str_to_datetime as to_datetime

from ._version import version as __version__
from .catalog import to_datetime
from .core import LegendMetadata
from .slowcontrol import LegendSlowControlDB
from .textdb import AttrsDict, JsonDB, TextDB
Expand Down
255 changes: 9 additions & 246 deletions src/legendmeta/catalog.py
Original file line number Diff line number Diff line change
@@ -1,254 +1,17 @@
# Copyright (C) 2015 Oliver Schulz <[email protected]>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import bisect
import collections
import copy
import types
from collections import namedtuple
from datetime import datetime
from pathlib import Path
from string import Template

import yaml
import warnings

from . import utils
from dbetto.catalog import * # noqa: F403


def to_datetime(value):
"""Convert a LEGEND timestamp (or key) to :class:`datetime.datetime`."""
return datetime.strptime(value, "%Y%m%dT%H%M%SZ")


def unix_time(value):
"""Convert a LEGEND timestamp or datetime object to Unix time value"""
if isinstance(value, str):
return datetime.timestamp(datetime.strptime(value, "%Y%m%dT%H%M%SZ"))

if isinstance(value, datetime):
return datetime.timestamp(value)

msg = f"Can't convert type {type(value)} to unix time"
raise ValueError(msg)


class PropsStream:
"""Simple class to control loading of validity.yaml files"""

@staticmethod
def get(value):
if isinstance(value, str):
return PropsStream.read_from(value)

if isinstance(value, (collections.abc.Sequence, types.GeneratorType)):
return value

msg = f"Can't get PropsStream from value of type {type(value)}"
raise ValueError(msg)

@staticmethod
def read_from(file_name):
with Path(file_name).open() as r:
file = yaml.safe_load(r)
file = sorted(file, key=lambda item: unix_time(item["valid_from"]))
yield from file


class Catalog(namedtuple("Catalog", ["entries"])):
"""Implementation of the `YAML metadata validity specification <https://legend-exp.github.io/legend-data-format-specs/dev/metadata/#Specifying-metadata-validity-in-time-(and-system)>`_."""

__slots__ = ()

class Entry(namedtuple("Entry", ["valid_from", "file"])):
__slots__ = ()

@staticmethod
def get(value):
if isinstance(value, Catalog):
return value

if isinstance(value, str):
return Catalog.read_from(value)

msg = f"Can't get Catalog from value of type {type(value)}"
raise ValueError(msg)

@staticmethod
def read_from(file_name):
"""Read from a valdiity YAML file and build a Catalog object"""
entries = {}
for props in PropsStream.get(file_name):
timestamp = props["valid_from"]
system = "all" if props.get("category") is None else props["category"]
file_key = props["apply"]
if system not in entries:
entries[system] = []
mode = "append" if props.get("mode") is None else props["mode"]
mode = "reset" if len(entries[system]) == 0 else mode
if mode == "reset":
new = file_key
elif mode == "append":
new = entries[system][-1].file.copy() + file_key
elif mode == "remove":
new = entries[system][-1].file.copy()
for file in file_key:
new.remove(file)
elif mode == "replace":
new = entries[system][-1].file.copy()
if len(file_key) != 2:
msg = f"Invalid number of elements in replace mode: {len(file_key)}"
raise ValueError(msg)
new.remove(file_key[0])
new += [file_key[1]]

else:
msg = f"Unknown mode for {timestamp}"
raise ValueError(msg)

if timestamp in [entry.valid_from for entry in entries[system]]:
msg = f"Duplicate timestamp: {timestamp}, use reset mode instead with a single entry"
raise ValueError(msg)
entries[system].append(Catalog.Entry(unix_time(timestamp), new))

for system in entries:
entries[system] = sorted(
entries[system], key=lambda entry: entry.valid_from
)
return Catalog(entries)

def valid_for(self, timestamp, system="all", allow_none=False):
"""Get the valid entries for a given timestamp and system"""
if system in self.entries:
valid_from = [entry.valid_from for entry in self.entries[system]]
pos = bisect.bisect_right(valid_from, unix_time(timestamp))
if pos > 0:
return self.entries[system][pos - 1].file

if system != "all":
return self.valid_for(timestamp, system="all", allow_none=allow_none)

if allow_none:
return None

msg = f"No valid entries found for timestamp: {timestamp}, system: {system}"
raise RuntimeError(msg)

if system != "all":
return self.valid_for(timestamp, system="all", allow_none=allow_none)

if allow_none:
return None

msg = f"No entries found for system: {system}"
raise RuntimeError(msg)

@staticmethod
def get_files(catalog_file, timestamp, category="all"):
"""Helper function to get the files for a given timestamp and category"""
catalog = Catalog.read_from(catalog_file)
return Catalog.valid_for(catalog, timestamp, category)


class Props:
"""Class to handle overwriting of dictionaries in cascade order"""

@staticmethod
def read_from(sources, subst_pathvar=False, trim_null=False):
def read_impl(sources):
if isinstance(sources, str):
file_name = sources
result = utils.load_dict(file_name)
if subst_pathvar:
Props.subst_vars(
result,
var_values={"_": Path(file_name).parent},
ignore_missing=True,
)
return result

if isinstance(sources, list):
result = {}
for p in map(read_impl, sources):
Props.add_to(result, p)
return result

msg = f"Can't run Props.read_from on sources-value of type {type(sources)}"
raise ValueError(msg)

result = read_impl(sources)
if trim_null:
Props.trim_null(result)
return result

@staticmethod
def write_to(file_name, obj, ftype: str | None = None):
utils.write_dict(file_name, obj, ftype)

@staticmethod
def add_to(props_a, props_b):
a = props_a
b = props_b

for key in b:
if key in a:
if isinstance(a[key], dict) and isinstance(b[key], dict):
Props.add_to(a[key], b[key])
elif a[key] != b[key]:
a[key] = copy.deepcopy(b[key])
else:
a[key] = copy.deepcopy(b[key])

@staticmethod
def trim_null(props_a):
a = props_a

for key in list(a.keys()):
if isinstance(a[key], dict):
Props.trim_null(a[key])
elif a[key] is None:
del a[key]

@staticmethod
def subst_vars(props, var_values=None, ignore_missing=False):
if not var_values:
var_values = {}
return str_to_datetime(value) # noqa: F405

for key in props:
value = props[key]
if isinstance(value, str) and "$" in value:
new_value = None
if ignore_missing:
new_value = Template(value).safe_substitute(var_values)
else:
new_value = Template(value).substitute(var_values)

if new_value != value:
props[key] = new_value
elif isinstance(value, list):
new_values = []
for val in value:
if isinstance(val, str) and "$" in val:
if ignore_missing:
new_value = Template(val).safe_substitute(var_values)
else:
new_value = Template(val).substitute(var_values)
else:
new_value = val
new_values.append(new_value)
if new_values != value:
props[key] = new_values
elif isinstance(value, dict):
Props.subst_vars(value, var_values, ignore_missing)
warnings.warn(
"The catalog module has moved renamed to the dbetto package (https://github.com/gipert/dbetto). "
"Please update your code, as this module will be removed in a future release.",
DeprecationWarning,
stacklevel=2,
)
3 changes: 1 addition & 2 deletions src/legendmeta/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@
from pathlib import Path
from tempfile import gettempdir

from dbetto import AttrsDict, TextDB
from git import GitCommandError, InvalidGitRepositoryError, Repo

from .textdb import AttrsDict, TextDB

log = logging.getLogger(__name__)


Expand Down
Loading

0 comments on commit 40c4a25

Please sign in to comment.