diff --git a/.github/workflows/check_pull_request.yml b/.github/workflows/check_pull_request.yml index a2b716d..02b0ef4 100644 --- a/.github/workflows/check_pull_request.yml +++ b/.github/workflows/check_pull_request.yml @@ -46,6 +46,62 @@ jobs: ./run test + test_without_plotly: + name: Core installation without Plotly (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.15"] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: ">=0.13.4" + python-version: ${{ matrix.python-version }} + + - name: Install the core package without extras or development dependencies + run: uv sync --locked --no-dev --no-editable + + - name: Check core functionality and optional plotting exports + run: | + uv run --no-sync python -I - <<'PY' + import importlib.util + + assert importlib.util.find_spec("plotly") is None, "Plotly must not be installed" + + import pandas as pd + import hdhelpers + from hdhelpers.metadata import attach_metadata, attrs_to_json, get_series_unit, read_metadata + from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE + + series = pd.Series([1.0], index=pd.to_datetime(["2026-01-01T00:00:00Z"])) + series.attrs = SERIES_METADATA_EXAMPLE + meta = attach_metadata(series) + assert meta is not None + assert read_metadata(series) is meta + assert get_series_unit(series) == "m" + assert attrs_to_json(series.attrs)["metrics"][0]["value_dimensions"][0]["unit"] == "m" + converted = hdhelpers.modify_timezone(series, to_timezone="Europe/Berlin") + assert str(converted.index.tz) == "Europe/Berlin" + assert read_metadata(converted) is meta + + # Repeat every export to catch modules cached by an earlier failed import. + names = ( + "plotting", "FUSEKI_COLOR_CYCLE", "resolve_color", + "set_agnostic_theme", "TICKFORMATSTOPS", "set_dt_ticks", + ) + for name in names * 2: + try: + getattr(hdhelpers, name) + except ImportError as error: + assert "hdhelpers[plotting]" in str(error), (name, error) + else: + raise AssertionError(f"hdhelpers.{name} is available without Plotly") + PY + check_lint_format_mypy: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 635658d..ff95727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,12 @@ ## [Unreleased] -- Update python versions to fix CA certificate issue (https://emudhra.we-have-a-problem.com/) -- metadata accessors can fall back to the properties a metric inherits from objects higher up in an asset hierarchy (under "inherited" in the metric metadata of the current convention), only as last resort after "value_dimensions_shared". This is the default for get_metric_info, get_singlets_metric_info, get_series_info and get_singlets_info. The functions for dedicated information (units, names, display names, short display names and measurements) only do so with keyword argument include_inherited=True, since an inherited property with the same label usually does not describe the metric itself -- get_metric_info and get_singlets_metric_info fall back to the value dimension "value" and to "value_dimensions_shared" if the metric itself does not provide the information -- fix: (short) display names of metadata following the current convention treat null as missing and look up each field on the value dimension, the metric and "value_dimensions_shared" before falling back to the next field. So e.g. a display name of the metric wins over the name of its value dimension -- fix: get_metric_info returns None entries instead of raising if the metadata contains no metric information -- the series accessors determine their metric like the SingleTSFrame accessors: the one given by "single_metric" in the dataset metadata or, if that is missing, the only metric present. The metric named "series" is only used as default for older metadata structures -- a "single_metric" in the dataset metadata which is no string counts as missing. Before, e.g. a list let all accessors of Series and SingleTSFrames raise an error -- fix: get_series_display_name no longer falls back to the short display name, as documented and like get_display_names and get_singlets_display_names -- fix: the SingleTSFrame accessors no longer use the only metric present if it is another one than the metric given by "single_metric" in the dataset metadata. The information counts as missing then -- metadata following the current convention is extracted in a single pass in plain Python instead of evaluating glom specs per level and field. The accessors are considerably faster for frames with many metrics, e.g. about 100 times for 1000 metrics when requesting field names or dotted paths. Other glom specs are still evaluated by glom, but only on the levels needed -- the accessors of Series and SingleTSFrames only resolve the information of their single metric. So they are considerably faster for a Series carrying the metadata of many metrics, e.g. taken from a MultiTSFrame, and a spec is not applied to the other metrics. Also get_series_unit and get_series_name no longer evaluate glom specs for metadata following the current convention. Both are about 90 times faster for the metadata of 1000 metrics -- the requested information is only looked up on the levels needed, so e.g. a spec transforming the information is no longer applied to null values of levels which are not used anyway -- entries of metadata following the current convention which do not adhere to the conventions are skipped: metrics which are no objects or whose field named by the metric key is no string, and value dimensions which are no objects or whose column is no string. "value_dimensions" and "value_dimensions_shared" which are no lists count as empty, a metric key which is no string as missing. Before, a single such entry could make all information missing or raise an error -- new functions get_default_timezones, get_series_default_timezone and get_singlets_default_timezone provide the default time zone of the metrics ("default_timezone" of the current metadata convention), falling back to the one of the dataset metadata. It is meant for computations, e.g. bucketing days when aggregating, not for displaying timestamps, which is what the time zone of the plot target settings is for. New section "Which time zone to compute in?" in the documentation -- documentation: the information requested from get_metric_info, get_singlets_metric_info, get_series_info and get_singlets_info is interpreted as a glom spec. New section "How to query nested information?" with examples for dotted paths into sub dictionaries, other glom specs and keys containing dots -- new class Recording and functions get_recordings, get_series_recording and get_singlets_recording provide how the values of the metrics are recorded: "recording_mode", "data_frequency" with "data_frequency_offset" and "data_frequency_timezone", and "recording_aggregation_method" of the current metadata convention. The frequency falls back to "ref_data_frequency", "ref_data_frequency_offset" and "ref_data_frequency_timezone" of the dataset metadata as a whole, i.e. offset and time zone only apply together with the frequency of the same level. The values are provided as given in the metadata, the documentation of Recording explains what they mean -- new functions get_default_aggregation_methods, get_series_default_aggregation_method and get_singlets_default_aggregation_method provide the default aggregation method of the metrics ("default_aggregation_method"), normalizing "AVG" to "MEAN". Without one, it is "LAST" for metrics whose value dimension "value" has the value data type "boolean" or "string", otherwise "MEAN" -- new function without_metadata provides a copy of a Series or DataFrame without its metadata (attrs) to compute on. pandas deep-copies the attrs to every object an operation creates, which makes e.g. loops over the metrics of a MultiTSFrame with the metadata of many metrics slow. The copy shares the data until it is changed + +- Add validated, immutable metadata models with helpers to read, attach, update, and serialize them. Pandas operations share attached models instead of repeatedly copying metadata. +- Improve metadata lookups with consistent fallbacks for missing values, display names, and inherited properties (`include_inherited=True` for dedicated unit/name helpers). +- Add helpers for default time zones, recording information, default aggregation methods, and copying pandas objects without metadata (`copy_without_metadata`). +- **Breaking:** Only the current metadata conventions are supported. Invalid metadata returns no information. Legacy formats and arbitrary glom specs are removed; queries accept field names and dotted paths. +- Make Plotly optional: install `hdhelpers[plotting]` for plotting helpers. Core metadata and time zone functionality works without it. +- Add Python 3.15 support and update Python/tooling requirements to address the CA certificate security issue. +- Expand validation and regression tests, correct lint configuration, and add CI checks of actual installations without Plotly. ## [0.0.9] - 2026-09-10 - singletsframe support diff --git a/README.md b/README.md index ec783be..b5ac86f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ The documentation of the package is a [GitHub page](https://hetida.github.io/hdh ## Getting Started with hdhelpers Since version >0.13.10 the hetida designer runtime comes with an installed version of hdhelpers. To start the hetida designer we recommend following the [hetida designer setup guide](https://github.com/hetida/hetida-designer/blob/release/README.md#getting-started-with-hetida-designer). +The plotting helpers (`hdhelpers.plotting` and their names in `hdhelpers`) need plotly, which is the optional extra +`plotting`: `pip install "hdhelpers[plotting]"`. Everything else, e.g. the metadata models, works without plotly. + An example is given in [GitHub page](https://hetida.github.io/hdhelpers/first_steps.html#how-to-get-metadata-with-hdhelpers) of how to use functionalities of hdhelpers inside a hetida designer component. Furthermore the base component [Single Time Series Plot](https://github.com/hetida/hetida-designer/blob/release/runtime/transformations/components/visualization) uses hdhelpers to demonstrate the usage (since version >0.13.10). ## Developing for hdhelpers @@ -27,7 +30,8 @@ For dependency management and venv setup, building and publishing, [uv](https:// #### Python environment 1) Create a virtual environment with `uv venv`. This will create a hidden `.venv` directory. 2) Activate the virtual environment via `source .venv/bin/activate` -3) Run `uv sync` to install all dependencies given in pyproject.toml. +3) Run `uv sync --all-extras` to install all dependencies given in pyproject.toml, including the optional + ones of the plotting helpers. 4) In case you need to add a new dependency, do so via `uv add `. That way, uv finds versions of all dependencies that are compatible with each other. 5) In case you need a new requirement for development purposes please use `uv add --dev ` @@ -43,7 +47,7 @@ FROM hetida/designer-backend:<> USER root -RUN pip install . +RUN pip install ".[plotting]" USER hd_app @@ -69,6 +73,34 @@ writing component code. ### Code Quality Once you are done writing your code, including unit tests, use `./run check` to see if your code quality is sufficient. +#### Metadata schema snapshot +`tests/metadata/test_conventions_schema.py` compares the generated metadata JSON schemas with +`tests/data/convention_schemas.json`. Class docstrings become schema descriptions, so documentation +changes can also require a snapshot update. + +After an intentional schema change, regenerate the snapshot in Bash/WSL: + +```bash +UPDATE_SCHEMA_SNAPSHOT=1 uv run pytest tests/metadata/test_conventions_schema.py -q +``` + +Or in PowerShell: + +```powershell +$env:UPDATE_SCHEMA_SNAPSHOT = "1" +uv run pytest tests/metadata/test_conventions_schema.py -q +Remove-Item Env:UPDATE_SCHEMA_SNAPSHOT +``` + +Review the diff for unintended changes and rerun the test without the update flag: + +```bash +git diff -- tests/data/convention_schemas.json +uv run pytest tests/metadata/test_conventions_schema.py -q +``` + +Commit the updated snapshot together with the corresponding model or docstring changes. + ### Documentation For documentation we use the tool sphinx. Please apply `./run build_docs` to create the current state of documentation. It will be stored in **docs**. You can open the documentation by opening `docs/index.html`, e.g. with your browser. diff --git a/docs/_modules/hdhelpers/exceptions.html b/docs/_modules/hdhelpers/exceptions.html new file mode 100644 index 0000000..6bca556 --- /dev/null +++ b/docs/_modules/hdhelpers/exceptions.html @@ -0,0 +1,120 @@ + + + + + + + hdhelpers.exceptions — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.exceptions

+ 1from typing import Any
+ 2
+ 3
+
+[docs] + 4class HelperException(Exception): + 5 """Exception for reporting errors to hetida designer. + 6 + 7 Attributes: + 8 error_code (int | str): Error identifier; an empty string if unspecified. + 9 extra_information (dict | None): Optional details for the error handler. +10 """ +11 +12 __is_hetida_designer_exception__ = True +13 +14 def __init__( +15 self, +16 *args: Any, +17 error_code: int | str = "", +18 extra_information: dict | None = None, +19 **kwargs: Any, +20 ) -> None: +21 if not isinstance(error_code, int | str): +22 raise ValueError("The HelperException.error_code must be int or string!") +23 self.error_code = error_code +24 self.extra_information = extra_information +25 super().__init__(*args, **kwargs)
+ +26 +27 +
+[docs] +28class InsufficientPlottingData(HelperException): +29 """Raised when a plot component has insufficient data to produce a meaningful plot. +30 +31 Frontends can handle this by displaying a message instead of an empty plot. +32 """
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/helpers/locale.html b/docs/_modules/hdhelpers/helpers/locale.html new file mode 100644 index 0000000..7b0dea4 --- /dev/null +++ b/docs/_modules/hdhelpers/helpers/locale.html @@ -0,0 +1,117 @@ + + + + + + + hdhelpers.helpers.locale — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.helpers.locale

+ 1import hdhelpers
+ 2
+ 3
+
+[docs] + 4def get_locale(explicit_locale: str | None = None) -> str | None: + 5 """Read the explicit locale or plot target locale, normalizing underscores to hyphens. + 6 + 7 Args: + 8 explicit_locale: + 9 Override locale, e.g. "de_DE". None or "" uses the plot target setting. +10 +11 Returns: +12 str | None: Locale such as "de-DE", or None if no locale is configured. +13 +14 Code example: +15 +16 .. doctest:: +17 +18 >>> get_locale("de_DE") # returns "de-DE" +19 'de-DE' +20 +21 >>> print(get_locale()) # returns locale from plot target settings of execution or None +22 None +23 """ # noqa: E501 +24 if explicit_locale: +25 return explicit_locale.replace("_", "-") +26 +27 plot_target_settings = hdhelpers.plot_target_settings.get_plot_target_settings() +28 +29 if plot_target_settings.plot_target_locale is not None: +30 return plot_target_settings.plot_target_locale.replace("_", "-") +31 +32 return None
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/helpers/timezone_handling.html b/docs/_modules/hdhelpers/helpers/timezone_handling.html new file mode 100644 index 0000000..420ae1e --- /dev/null +++ b/docs/_modules/hdhelpers/helpers/timezone_handling.html @@ -0,0 +1,244 @@ + + + + + + + hdhelpers.helpers.timezone_handling — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.helpers.timezone_handling

+  1import datetime
+  2import logging
+  3import zoneinfo
+  4from functools import singledispatch
+  5
+  6import pandas as pd
+  7import pytz
+  8
+  9import hdhelpers
+ 10
+ 11logger = logging.getLogger("hdhelpers")
+ 12
+ 13
+ 14@singledispatch
+ 15def _convert_to_optional_timezone(object_to_convert, to_timezone: str | None):  # noqa: ARG001
+ 16    """Convert object_to_convert to to_timezone if not None,
+ 17    or to its own timezone if aware
+ 18    or to UTC otherwise"""
+ 19    raise TypeError(
+ 20        f"Entries to convert do not contain valid timestamps ({type(object_to_convert).__name__})"
+ 21    )
+ 22
+ 23
+ 24@_convert_to_optional_timezone.register(pd.Timestamp | pd.DatetimeIndex | datetime.datetime)
+ 25def _[T: (pd.Timestamp, pd.DatetimeIndex)](object_to_convert: T, to_timezone: str | None) -> T:
+ 26    object_to_convert = pd.to_datetime(object_to_convert)
+ 27    if to_timezone is None:
+ 28        if object_to_convert.tz is None:
+ 29            return object_to_convert.tz_localize("UTC")
+ 30        return object_to_convert
+ 31    if object_to_convert.tz is None:
+ 32        return object_to_convert.tz_localize(to_timezone)
+ 33    return object_to_convert.tz_convert(to_timezone)
+ 34
+ 35
+ 36@_convert_to_optional_timezone.register
+ 37def _(object_to_convert: pd.Series, to_timezone: str | None) -> pd.Series:
+ 38    if to_timezone is None:
+ 39        if object_to_convert.dt.tz is None:
+ 40            return object_to_convert.dt.tz_localize("UTC")
+ 41        return object_to_convert
+ 42    if object_to_convert.dt.tz is None:
+ 43        return object_to_convert.dt.tz_localize(to_timezone)
+ 44    return object_to_convert.dt.tz_convert(to_timezone)
+ 45
+ 46
+
+[docs] + 47def modify_timezone[T: (pd.Timestamp, datetime.datetime, pd.Series, pd.DataFrame)]( # noqa: PLR0912 + 48 object_to_convert: T, + 49 to_timezone: str | None = None, + 50 column_names: list[str] | None = None, + 51 convert_index: bool = True, + 52) -> T: + 53 """Convert aware timestamps or localize naive timestamps to the selected time zone. + 54 + 55 Args: + 56 object_to_convert (pd.Timestamp | datetime.datetime | pd.Series | pd.DataFrame): + 57 Timestamp, datetime, Series or DataFrame to convert. + 58 to_timezone: + 59 Target zone, e.g. "Europe/Berlin". None uses the plot target setting; if unset, + 60 naive values use UTC and aware values keep their zone. + 61 column_names: + 62 Columns to convert. None/[] selects the Series index or DataFrame "timestamp" + 63 column. For Series values, pass [series.name]. Ignored for scalars. + 64 convert_index: + 65 Also convert the pandas index. With column_names=None/[], a Series index is + 66 always converted. Ignored for scalars. + 67 + 68 Returns: + 69 pd.Timestamp | pd.Series | pd.DataFrame: + 70 Timestamp for scalar inputs; converted copy for nonempty pandas inputs. Empty + 71 pandas inputs are returned unchanged. + 72 + 73 Raises: + 74 TypeError: Unsupported input or incompatible timestamp/index values. + 75 ValueError: Unknown time zone or invalid datetime values. + 76 KeyError: A requested column is absent. + 77 + 78 Code example: + 79 + 80 .. doctest:: + 81 + 82 >>> from hdhelpers.helpers import modify_timezone + 83 >>> modified_timezone = modify_timezone(pd.to_datetime("2025-01-01T01:00:00+05:00"), to_timezone="Europe/Berlin") + 84 >>> int(modified_timezone.utcoffset().total_seconds()) + 85 3600 + 86 """ # noqa: E501 + 87 + 88 if not isinstance( + 89 object_to_convert, pd.Timestamp | pd.Series | pd.DataFrame | datetime.datetime + 90 ): + 91 raise TypeError( + 92 f"object_to_convert is {type(object_to_convert)} not pd.Timestamp | pd.Series |" + 93 " pd.DataFrame | datetime.datetime" + 94 ) + 95 if column_names is None: + 96 column_names = [] + 97 + 98 try: + 99 if to_timezone is None: +100 plot_target_settings = hdhelpers.plot_target_settings.get_plot_target_settings() +101 if plot_target_settings.plot_target_timezone is not None: +102 to_timezone = plot_target_settings.plot_target_timezone +103 +104 if isinstance(object_to_convert, pd.Timestamp | datetime.datetime): +105 return _convert_to_optional_timezone(object_to_convert, to_timezone) +106 +107 if object_to_convert.empty: +108 return object_to_convert +109 +110 if isinstance(object_to_convert, pd.Series): +111 new_object = object_to_convert.to_frame(name=object_to_convert.name) +112 else: +113 new_object = object_to_convert.copy(deep=True) +114 +115 if len(column_names) == 0: +116 if isinstance(object_to_convert, pd.Series): +117 new_object.index = _convert_to_optional_timezone(new_object.index, to_timezone) +118 msg = f"Converted index to datetime starting with {object_to_convert.index.min()}" +119 logger.debug(msg=msg) +120 elif isinstance(new_object, pd.DataFrame) and "timestamp" in new_object.columns: +121 new_object["timestamp"] = _convert_to_optional_timezone( +122 pd.to_datetime(new_object["timestamp"]), to_timezone +123 ) +124 msg = f"""Converted column "timestamp" to datetime starting with +125 {object_to_convert["timestamp"][0]}""" +126 logger.debug(msg=msg) +127 +128 if len(column_names) > 0: +129 for column in column_names: +130 new_object[column] = _convert_to_optional_timezone( +131 pd.to_datetime(new_object[column]), to_timezone +132 ) +133 +134 if convert_index: +135 new_object.index = _convert_to_optional_timezone(new_object.index, to_timezone) +136 +137 if not isinstance(object_to_convert, pd.Series): +138 new_object.attrs = object_to_convert.attrs +139 return new_object +140 +141 series_object = pd.Series( +142 new_object[object_to_convert.name], +143 index=new_object.index, +144 name=object_to_convert.name, +145 ) +146 series_object.attrs = object_to_convert.attrs +147 +148 return series_object +149 +150 except pytz.exceptions.UnknownTimeZoneError as exc: +151 possible_timezone = pytz.all_timezones +152 raise ValueError(f"""Timezone not known, please choose from {possible_timezone}""") from exc +153 except zoneinfo.ZoneInfoNotFoundError as exc: +154 raise ValueError(f"No time zone found with key {to_timezone}") from exc +155 except (AttributeError, pytz.exceptions.NonExistentTimeError) as exc: +156 raise TypeError("Entries to convert do not contain valid timestamps") from exc +157 except KeyError as exc: +158 exc.add_note(f"At least one column name of {column_names} not in object_to_convert") +159 raise
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/metadata/_reading.html b/docs/_modules/hdhelpers/metadata/_reading.html new file mode 100644 index 0000000..6d312d0 --- /dev/null +++ b/docs/_modules/hdhelpers/metadata/_reading.html @@ -0,0 +1,542 @@ + + + + + + + hdhelpers.metadata._reading — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.metadata._reading

+  1"""Reading metadata of the conventions, with the fallbacks of the conventions
+  2
+  3The methods of MetadataReading are available on the root models of the conventions
+  4(SeriesMetadata, SingleTSFrameMetadata, MultiTSFrameMetadata). They resolve the fallbacks:
+  5
+  6- Information of a value dimension, e.g. its unit: Each field of a chain, e.g. display_name and
+  7  then name, is looked up on the value dimension, for the value dimension "value" on the metric,
+  8  then on the shared value dimension of the column (value_dimensions_shared), before the next
+  9  field is tried. With include_inherited, the properties the metric inherits are the last resort
+ 10  for the value dimension "value".
+ 11- Information of a metric: the metric, its value dimension "value", the shared value dimension
+ 12  "value", with include_inherited finally the properties the metric inherits.
+ 13- Default time zone: the one of the metric, otherwise the one of the dataset metadata.
+ 14- Recording: the frequency of the metric together with its offset and time zone, otherwise the
+ 15  ones of the dataset metadata (ref_data_frequency etc.), always as a whole.
+ 16- Default aggregation method: the one of the metric, with AVG as MEAN, otherwise LAST for
+ 17  boolean and string values and MEAN for all others.
+ 18
+ 19Fields can be given as dotted paths, e.g. "inherited.site". Information which is null counts as
+ 20missing. For a metric without metadata, information is None, apart from the fallbacks to the
+ 21dataset metadata.
+ 22
+ 23For SERIES and SINGLETSFRAME metadata, the metric_id can be omitted, which refers to their single
+ 24metric. MULTITSFRAME metadata requires it.
+ 25"""
+ 26
+ 27import datetime
+ 28from collections.abc import Mapping, Sequence
+ 29from dataclasses import dataclass
+ 30from enum import Enum
+ 31from functools import cached_property
+ 32from typing import TYPE_CHECKING, Any, ClassVar, Literal
+ 33
+ 34from pydantic import BaseModel
+ 35
+ 36if TYPE_CHECKING:
+ 37    from hdhelpers.metadata.conventions import (
+ 38        MetricMetadata,
+ 39        SharedValueDimensionMetadata,
+ 40        ValueDimensionMetadata,
+ 41        _DatasetMetadataBase,
+ 42    )
+ 43
+ 44DISPLAY_NAME_FIELDS = ("display_name", "name")
+ 45SHORT_DISPLAY_NAME_FIELDS = ("short_display_name", *DISPLAY_NAME_FIELDS)
+ 46
+ 47# default aggregation method of metrics without one by the data type of their values
+ 48_DEFAULT_AGGREGATION_METHOD_BY_VALUE_DATA_TYPE = {"boolean": "LAST", "string": "LAST"}
+ 49_DEFAULT_AGGREGATION_METHOD = "MEAN"
+ 50_AGGREGATION_METHOD_ALIASES = {"AVG": "MEAN"}
+ 51
+ 52_MISSING = object()
+ 53
+ 54
+
+[docs] + 55@dataclass(frozen=True) + 56class Recording: + 57 """Immutable recording settings for a metric: mode, frequency and interval alignment. + 58 + 59 Metadata lookups use dataset frequency settings when the metric provides none. + 60 Recording modes describe when values are recorded: + 61 + 62 * ``periodic``: one value per interval, timestamped at its start. Missing values are gaps. + 63 * ``on_change``: record changes exceeding the value dimension's deadband. A value holds + 64 until the next one, provided the heartbeat interval is not exceeded. + 65 * ``irregular``: values at arbitrary points in time. + 66 + 67 Attributes: + 68 recording_mode (str | None): One of the modes above; None if unknown. + 69 data_frequency (str | None): Pandas offset alias or timedelta string, e.g. "15min" + 70 or "1MS". For on_change, the maximum time between recordings (heartbeat), + 71 even without a change. None if unknown. + 72 data_frequency_offset (str | None): Shift of the frequency intervals as a timedelta + 73 string, e.g. "5min". None means zero when a frequency is set. + 74 data_frequency_timezone (str | None): Time zone for aligning frequency intervals, + 75 e.g. "Europe/Berlin". None means UTC when a frequency is set. + 76 recording_aggregation_method (str | None): For periodic values, how each interval + 77 is aggregated, e.g. "MEAN" or "MAX". None for samples taken at the timestamp. + 78 + 79 data_frequency_offset and data_frequency_timezone belong to data_frequency: They are None + 80 if no data_frequency applies. + 81 """ + 82 + 83 recording_mode: str | None = None + 84 data_frequency: str | None = None + 85 data_frequency_offset: str | None = None + 86 data_frequency_timezone: str | None = None + 87 recording_aggregation_method: str | None = None
+ + 88 + 89 + 90_Frequency = tuple[str | None, str | None, str | None] + 91 + 92 + 93def lookup(obj: Any, path: str) -> Any: + 94 """A field, extra field or dotted path of fields of a model or mapping, None if missing + 95 + 96 Members of enums are given as their values, e.g. "MEAN" for AggregationMethod.MEAN, as in + 97 the JSON form of the metadata. + 98 """ + 99 for key in path.split("."): +100 obj = _field(obj, key) +101 if obj is None: +102 return None +103 return _plain(obj) +104 +105 +106def id_of(metric: "MetricMetadata", metric_key: str) -> Any: +107 """The id of a metric: its field named metric_key, which may contain dots, None if missing""" +108 return _plain(_field(metric, metric_key)) +109 +110 +111def _field(obj: Any, key: str) -> Any: +112 """A field or extra field of a model or a key of a mapping, None if missing""" +113 if isinstance(obj, BaseModel): +114 # __dict__ holds the fields, without warning about deprecated ones like getattr +115 value = obj.__dict__.get(key, _MISSING) +116 return (obj.__pydantic_extra__ or {}).get(key) if value is _MISSING else value +117 if isinstance(obj, Mapping): +118 return obj.get(key) +119 return None +120 +121 +122def _plain(value: Any) -> Any: +123 """The value of an enum member, e.g. "MEAN" instead of AggregationMethod.MEAN""" +124 return value.value if isinstance(value, Enum) else value +125 +126 +127def _non_empty(value: Any) -> str | None: +128 value = _plain(value) +129 return value if isinstance(value, str) and value != "" else None +130 +131 +132def _frequency(obj: Any, fields: tuple[str, str, str]) -> _Frequency: +133 """Frequency, offset and time zone of obj, all None if it has no frequency""" +134 frequency, offset, timezone = (_non_empty(lookup(obj, field)) for field in fields) +135 return (None, None, None) if frequency is None else (frequency, offset, timezone) +136 +137 +138def _value_dimension_info( +139 fields: tuple[str, ...], +140 metric: "MetricMetadata", +141 value_dimensions: Mapping[str, "ValueDimensionMetadata"], +142 shared: Mapping[str, "SharedValueDimensionMetadata"], +143 column: str, +144 include_inherited: bool, +145) -> Any: +146 value_dimension = value_dimensions.get(column) +147 shared_value_dimension = shared.get(column) +148 for field in fields: +149 info = lookup(value_dimension, field) +150 if info is None and column == "value": +151 info = lookup(metric, field) +152 if info is None: +153 info = lookup(shared_value_dimension, field) +154 if info is not None: +155 return info +156 if include_inherited and column == "value" and metric.inherited is not None: +157 for field in fields: +158 info = lookup(metric.inherited, field) +159 if info is not None: +160 return info +161 return None +162 +163 +164def _by_column( +165 value_dimensions: "Sequence[ValueDimensionMetadata] | Sequence[SharedValueDimensionMetadata]", +166) -> dict[str, Any]: +167 return {value_dimension.column: value_dimension for value_dimension in value_dimensions} +168 +169 +170def _fields(fields: str | Sequence[str]) -> tuple[str, ...]: +171 return (fields,) if isinstance(fields, str) else tuple(fields) +172 +173 +174class MetadataReading: +175 """Lookups with the fallbacks of the conventions, see the module docstring""" +176 +177 if TYPE_CHECKING: +178 dataset_metadata: _DatasetMetadataBase +179 metrics: tuple[MetricMetadata, ...] +180 value_dimensions_shared: tuple[SharedValueDimensionMetadata, ...] | None +181 _data_type: ClassVar[Literal["SERIES", "SINGLETSFRAME", "MULTITSFRAME"]] +182 +183 # metrics +184 +185 def metric_ids(self) -> list[str]: +186 """The ids of the metrics, i.e. their values of the field named by the metric key""" +187 return list(self._metrics_by_id) +188 +189 def metric(self, metric_id: str | None = None) -> "MetricMetadata": +190 """The metadata of a metric, KeyError if there is none""" +191 metric = self._metric_or_none(metric_id) +192 if metric is None: +193 raise KeyError(metric_id) +194 return metric +195 +196 @cached_property +197 def _metrics_by_id(self) -> "dict[str, MetricMetadata]": +198 """The metrics by id, built on first use +199 +200 Cached in __dict__ apart from the fields, which pydantic ignores when comparing models: +201 Reading must not change the equality, since pandas keeps only equal attrs, e.g. on concat. +202 """ +203 metric_key = self.dataset_metadata.metric_key +204 return {id_of(metric, metric_key): metric for metric in self.metrics} +205 +206 def _metric_or_none(self, metric_id: str | None) -> "MetricMetadata | None": +207 if metric_id is None: +208 if self._data_type == "MULTITSFRAME": +209 raise TypeError("The metric_id is required for MULTITSFRAME metadata.") +210 return self.metrics[0] +211 return self._metrics_by_id.get(metric_id) +212 +213 def _shared_by_column(self) -> "dict[str, SharedValueDimensionMetadata]": +214 return _by_column(self.value_dimensions_shared or ()) +215 +216 # information of value dimensions +217 +218 def value_dimension_info( +219 self, +220 fields: str | Sequence[str], +221 metric_id: str | None = None, +222 column: str = "value", +223 *, +224 include_inherited: bool = False, +225 ) -> Any: +226 """Information of a value dimension, trying the fields in turn""" +227 metric = self._metric_or_none(metric_id) +228 if metric is None: +229 return None +230 return _value_dimension_info( +231 _fields(fields), +232 metric, +233 _by_column(metric.value_dimensions), +234 self._shared_by_column(), +235 column, +236 include_inherited, +237 ) +238 +239 def value_dimension_infos( +240 self, fields: str | Sequence[str], *, include_inherited: bool = False +241 ) -> dict[str, dict[str, Any]]: +242 """Information of the value dimensions by column by metric +243 +244 The value dimensions of a metric are its own ones, "value" and the shared ones. +245 """ +246 fields = _fields(fields) +247 shared = self._shared_by_column() +248 result = {} +249 for metric_id, metric in self._metrics_by_id.items(): +250 value_dimensions = _by_column(metric.value_dimensions) +251 columns = dict.fromkeys([*shared, "value", *value_dimensions]) +252 result[metric_id] = { +253 column: _value_dimension_info( +254 fields, metric, value_dimensions, shared, column, include_inherited +255 ) +256 for column in columns +257 } +258 return result +259 +260 def unit( +261 self, +262 metric_id: str | None = None, +263 column: str = "value", +264 *, +265 include_inherited: bool = False, +266 ) -> str | None: +267 return self.value_dimension_info( # type: ignore[no-any-return] +268 "unit", metric_id, column, include_inherited=include_inherited +269 ) +270 +271 def units(self, *, include_inherited: bool = False) -> dict[str, dict[str, str | None]]: +272 return self.value_dimension_infos("unit", include_inherited=include_inherited) +273 +274 def measurement( +275 self, +276 metric_id: str | None = None, +277 column: str = "value", +278 *, +279 include_inherited: bool = False, +280 ) -> str | None: +281 return self.value_dimension_info( # type: ignore[no-any-return] +282 "measurement", metric_id, column, include_inherited=include_inherited +283 ) +284 +285 def measurements(self, *, include_inherited: bool = False) -> dict[str, dict[str, str | None]]: +286 return self.value_dimension_infos("measurement", include_inherited=include_inherited) +287 +288 def name( +289 self, +290 metric_id: str | None = None, +291 column: str = "value", +292 *, +293 include_inherited: bool = False, +294 ) -> str | None: +295 return self.value_dimension_info( # type: ignore[no-any-return] +296 "name", metric_id, column, include_inherited=include_inherited +297 ) +298 +299 def names(self, *, include_inherited: bool = False) -> dict[str, dict[str, str | None]]: +300 return self.value_dimension_infos("name", include_inherited=include_inherited) +301 +302 def display_name( +303 self, +304 metric_id: str | None = None, +305 column: str = "value", +306 *, +307 include_inherited: bool = False, +308 ) -> str | None: +309 """The display name, falling back to the name""" +310 return self.value_dimension_info( # type: ignore[no-any-return] +311 DISPLAY_NAME_FIELDS, metric_id, column, include_inherited=include_inherited +312 ) +313 +314 def display_names(self, *, include_inherited: bool = False) -> dict[str, dict[str, str | None]]: +315 return self.value_dimension_infos(DISPLAY_NAME_FIELDS, include_inherited=include_inherited) +316 +317 def short_display_name( +318 self, +319 metric_id: str | None = None, +320 column: str = "value", +321 *, +322 include_inherited: bool = False, +323 ) -> str | None: +324 """The short display name, falling back to the display name and the name""" +325 return self.value_dimension_info( # type: ignore[no-any-return] +326 SHORT_DISPLAY_NAME_FIELDS, metric_id, column, include_inherited=include_inherited +327 ) +328 +329 def short_display_names( +330 self, *, include_inherited: bool = False +331 ) -> dict[str, dict[str, str | None]]: +332 return self.value_dimension_infos( +333 SHORT_DISPLAY_NAME_FIELDS, include_inherited=include_inherited +334 ) +335 +336 # information of metrics +337 +338 def metric_info( +339 self, field: str, metric_id: str | None = None, *, include_inherited: bool = True +340 ) -> Any: +341 """Information of a metric, see the module docstring for the fallbacks""" +342 metric = self._metric_or_none(metric_id) +343 if metric is None: +344 return None +345 return self._metric_info(field, metric, self._shared_by_column(), include_inherited) +346 +347 def metric_infos(self, field: str, *, include_inherited: bool = True) -> dict[str, Any]: +348 shared = self._shared_by_column() +349 return { +350 metric_id: self._metric_info(field, metric, shared, include_inherited) +351 for metric_id, metric in self._metrics_by_id.items() +352 } +353 +354 @staticmethod +355 def _metric_info( +356 field: str, +357 metric: "MetricMetadata", +358 shared: Mapping[str, "SharedValueDimensionMetadata"], +359 include_inherited: bool, +360 ) -> Any: +361 info = lookup(metric, field) +362 if info is None: +363 info = lookup(_by_column(metric.value_dimensions).get("value"), field) +364 if info is None: +365 info = lookup(shared.get("value"), field) +366 if info is None and include_inherited: +367 info = lookup(metric.inherited, field) +368 return info +369 +370 # time zones, recording, aggregation and the queried interval +371 +372 def default_timezone(self, metric_id: str | None = None) -> str | None: +373 """The default time zone of the metric, otherwise the one of the dataset metadata""" +374 metric = self._metric_or_none(metric_id) +375 own = None if metric is None else _non_empty(metric.default_timezone) +376 return own or dataset_default_timezone(self) +377 +378 def default_timezones(self) -> dict[str, str | None]: +379 return {metric_id: self.default_timezone(metric_id) for metric_id in self._metrics_by_id} +380 +381 def recording(self, metric_id: str | None = None) -> Recording: +382 """How the values of the metric are recorded +383 +384 The frequency with its offset and time zone falls back to the ones of the dataset +385 metadata as a whole. +386 """ +387 dataset_frequency = _dataset_frequency(self) +388 metric = self._metric_or_none(metric_id) +389 if metric is None: +390 return Recording(None, *dataset_frequency, None) +391 own_frequency = _frequency( +392 metric, ("data_frequency", "data_frequency_offset", "data_frequency_timezone") +393 ) +394 return Recording( +395 _non_empty(metric.recording_mode), +396 *(own_frequency if own_frequency[0] is not None else dataset_frequency), +397 _non_empty(metric.recording_aggregation_method), +398 ) +399 +400 def recordings(self) -> dict[str, Recording]: +401 return {metric_id: self.recording(metric_id) for metric_id in self._metrics_by_id} +402 +403 def default_aggregation_method(self, metric_id: str | None = None) -> str: +404 """The default aggregation method of the metric, see the module docstring""" +405 metric = self._metric_or_none(metric_id) +406 if metric is None: +407 return _DEFAULT_AGGREGATION_METHOD +408 own_method = _non_empty(metric.default_aggregation_method) +409 if own_method is not None: +410 return _AGGREGATION_METHOD_ALIASES.get(own_method, own_method) +411 value_data_type = _plain( +412 _value_dimension_info( +413 ("value_data_type",), +414 metric, +415 _by_column(metric.value_dimensions), +416 self._shared_by_column(), +417 "value", +418 include_inherited=False, +419 ) +420 ) +421 return _DEFAULT_AGGREGATION_METHOD_BY_VALUE_DATA_TYPE.get( +422 value_data_type, _DEFAULT_AGGREGATION_METHOD +423 ) +424 +425 def default_aggregation_methods(self) -> dict[str, str]: +426 return { +427 metric_id: self.default_aggregation_method(metric_id) +428 for metric_id in self._metrics_by_id +429 } +430 +431 def queried_interval(self) -> tuple[datetime.datetime | None, datetime.datetime | None]: +432 """Start and end of the queried interval (the ref interval), None if missing""" +433 return ( +434 _datetime(self.dataset_metadata.ref_interval_start_timestamp), +435 _datetime(self.dataset_metadata.ref_interval_end_timestamp), +436 ) +437 +438 +439def _datetime(timestamp: str | None) -> datetime.datetime | None: +440 return None if timestamp is None else datetime.datetime.fromisoformat(timestamp) +441 +442 +443def _dataset_frequency(meta: MetadataReading) -> _Frequency: +444 return _frequency( +445 meta.dataset_metadata, +446 ("ref_data_frequency", "ref_data_frequency_offset", "ref_data_frequency_timezone"), +447 ) +448 +449 +450def dataset_default_timezone(meta: MetadataReading) -> str | None: +451 """The default time zone of the dataset metadata, which applies to metrics without one""" +452 return _non_empty(meta.dataset_metadata.default_timezone) +453 +454 +455def dataset_recording(meta: MetadataReading) -> Recording: +456 """The recording of a metric without metadata: only the frequency of the dataset metadata""" +457 return Recording(None, *_dataset_frequency(meta), None) +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/metadata/attached.html b/docs/_modules/hdhelpers/metadata/attached.html new file mode 100644 index 0000000..bfc54f6 --- /dev/null +++ b/docs/_modules/hdhelpers/metadata/attached.html @@ -0,0 +1,400 @@ + + + + + + + hdhelpers.metadata.attached — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.metadata.attached

+  1"""Metadata of the conventions attached to pandas objects as model
+  2
+  3The runtime of the hetida designer parses the metadata of each workflow input once and attaches
+  4the model under attrs["hd_metadata"] (META_KEY), in place of the keys of the conventions
+  5("dataset_metadata", "metrics", "value_dimensions_shared"). Other keys of attrs stay as they are.
+  6
+  7pandas deep-copies attrs for (almost) every object derived from another one. The models are
+  8immutable and return themselves when copied, so that all derived objects share the model
+  9instead of copying the metadata.
+ 10
+ 11Component code reads the metadata via read_metadata and attaches a changed version (see the
+ 12with_* methods of the models) to its own result via set_metadata. Everything serializing attrs
+ 13to JSON needs attrs_to_json, since the model is no JSON value.
+ 14"""
+ 15
+ 16from collections.abc import Mapping
+ 17from typing import Any, Literal, overload
+ 18
+ 19import pandas as pd
+ 20from pydantic import ValidationError
+ 21
+ 22from hdhelpers.metadata.conventions import (
+ 23    METADATA_MODELS,
+ 24    MetadataConventionError,
+ 25    MetadataConventionIssue,
+ 26    MetadataIssueSeverity,
+ 27    MultiTSFrameMetadata,
+ 28    SeriesMetadata,
+ 29    SingleTSFrameMetadata,
+ 30    TimeseriesDataType,
+ 31    _TimeseriesMetadataBase,
+ 32    check_metadata_conventions,
+ 33)
+ 34
+ 35META_KEY = "hd_metadata"
+ 36
+ 37# the top-level keys of attrs which belong to the conventions, i.e. the fields of the models
+ 38CONVENTION_KEYS = frozenset(
+ 39    field for model in METADATA_MODELS.values() for field in model.model_fields
+ 40)
+ 41
+ 42AnyMetadata = SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata
+ 43
+ 44
+ 45def infer_data_type(obj: pd.Series | pd.DataFrame) -> TimeseriesDataType:
+ 46    """SERIES for a Series, MULTITSFRAME for a frame with a "metric" column, else SINGLETSFRAME"""
+ 47    if isinstance(obj, pd.Series):
+ 48        return "SERIES"
+ 49    return "MULTITSFRAME" if "metric" in obj.columns else "SINGLETSFRAME"
+ 50
+ 51
+ 52def _convention_attrs(attrs: Mapping[Any, Any]) -> dict[str, Any]:
+ 53    return {key: value for key, value in attrs.items() if key in CONVENTION_KEYS}
+ 54
+ 55
+
+[docs] + 56def attach_metadata( + 57 obj: pd.Series | pd.DataFrame, data_type: TimeseriesDataType | None = None + 58) -> AnyMetadata | None: + 59 """Parse metadata in attrs and attach the resulting immutable model. + 60 + 61 Replace convention fields with attrs["hd_metadata"]; preserve other attrs. An already + 62 attached model is returned unchanged. + 63 + 64 Args: + 65 obj: Series or DataFrame to update in place. + 66 data_type: + 67 Convention for raw attrs, or None to infer it; see + 68 :ref:`metadata-type-selection`. Used only when no model is attached. + 69 + 70 Returns: + 71 SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata | None: + 72 Attached model, or None for absent/invalid metadata. Failure leaves attrs + 73 unchanged. + 74 + 75 Code example: + 76 + 77 Using :ref:`metadata-example-data`, attach a model while preserving unrelated attrs: + 78 + 79 .. doctest:: metadata.attach_metadata + 80 + 81 >>> data = series.copy() + 82 >>> data.attrs["source"] = "example" + 83 >>> meta = md.attach_metadata(data) + 84 >>> meta.unit() + 85 'm' + 86 >>> sorted(data.attrs) + 87 ['hd_metadata', 'source'] + 88 >>> md.read_metadata(data) is meta + 89 True + 90 >>> md.attach_metadata(data) is meta + 91 True + 92 >>> md.attach_metadata(pd.Series([1.0])) is None + 93 True + 94 """ + 95 attached = obj.attrs.get(META_KEY) + 96 if isinstance(attached, _TimeseriesMetadataBase): + 97 return attached # type: ignore[return-value] + 98 convention_attrs = _convention_attrs(obj.attrs) + 99 if len(convention_attrs) == 0: +100 return None +101 model = METADATA_MODELS[data_type or infer_data_type(obj)] +102 try: +103 meta = model.model_validate(convention_attrs) +104 except ValidationError: +105 return None +106 obj.attrs = {key: value for key, value in obj.attrs.items() if key not in CONVENTION_KEYS} | { +107 META_KEY: meta +108 } +109 return meta # type: ignore[return-value]
+ +110 +111 +112@overload +113def read_metadata( +114 obj: pd.Series | pd.DataFrame, data_type: Literal["SERIES"], *, required: Literal[True] +115) -> SeriesMetadata: ... +116@overload +117def read_metadata( +118 obj: pd.Series | pd.DataFrame, data_type: Literal["SERIES"], *, required: bool = False +119) -> SeriesMetadata | None: ... +120@overload +121def read_metadata( +122 obj: pd.Series | pd.DataFrame, data_type: Literal["SINGLETSFRAME"], *, required: Literal[True] +123) -> SingleTSFrameMetadata: ... +124@overload +125def read_metadata( +126 obj: pd.Series | pd.DataFrame, data_type: Literal["SINGLETSFRAME"], *, required: bool = False +127) -> SingleTSFrameMetadata | None: ... +128@overload +129def read_metadata( +130 obj: pd.Series | pd.DataFrame, data_type: Literal["MULTITSFRAME"], *, required: Literal[True] +131) -> MultiTSFrameMetadata: ... +132@overload +133def read_metadata( +134 obj: pd.Series | pd.DataFrame, data_type: Literal["MULTITSFRAME"], *, required: bool = False +135) -> MultiTSFrameMetadata | None: ... +136@overload +137def read_metadata( +138 obj: pd.Series | pd.DataFrame, data_type: None = None, *, required: Literal[True] +139) -> AnyMetadata: ... +140@overload +141def read_metadata( +142 obj: pd.Series | pd.DataFrame, data_type: None = None, *, required: bool = False +143) -> AnyMetadata | None: ... +
+[docs] +144def read_metadata( +145 obj: pd.Series | pd.DataFrame, +146 data_type: TimeseriesDataType | None = None, +147 *, +148 required: bool = False, +149) -> AnyMetadata | None: +150 """Read metadata as a model without changing the input. +151 +152 Reuse an attached model; otherwise parse convention fields in attrs. +153 +154 Args: +155 obj: Series or DataFrame to read. +156 data_type: +157 Expected convention. None keeps an attached model's type or infers it for raw +158 attrs; see :ref:`metadata-type-selection`. +159 required: +160 If True, raise on missing, invalid or wrong-type metadata instead of returning +161 None. +162 +163 Returns: +164 SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata | None: +165 Attached or newly parsed model; None for missing, invalid or wrong-type +166 metadata. +167 +168 Raises: +169 MetadataConventionError: Metadata cannot be returned and required=True. +170 +171 Code example: +172 +173 Using :ref:`metadata-example-data`, read a model without changing the original attrs: +174 +175 .. doctest:: metadata.read_metadata +176 +177 >>> meta = md.read_metadata(series, required=True) +178 >>> meta.unit() +179 'm' +180 >>> "hd_metadata" in series.attrs +181 False +182 >>> missing = pd.Series([1.0]) +183 >>> md.read_metadata(missing) is None +184 True +185 >>> try: +186 ... md.read_metadata(missing, required=True) +187 ... except md.MetadataConventionError as error: +188 ... print(error.issues[0].message) +189 No metadata in attrs. +190 """ +191 attached = obj.attrs.get(META_KEY) +192 if isinstance(attached, _TimeseriesMetadataBase): +193 if data_type is None or attached._data_type == data_type: +194 return attached # type: ignore[return-value] +195 _no_metadata( +196 required, +197 f"The attached metadata is {attached._data_type} metadata, not {data_type} metadata.", +198 ) +199 return None +200 +201 convention_attrs = _convention_attrs(obj.attrs) +202 if len(convention_attrs) == 0: +203 _no_metadata(required, "No metadata in attrs.") +204 return None +205 data_type = data_type or infer_data_type(obj) +206 try: +207 return METADATA_MODELS[data_type].model_validate(convention_attrs) # type: ignore[return-value] +208 except ValidationError as e: +209 if not required: +210 return None +211 issues = [ +212 issue +213 for issue in check_metadata_conventions(convention_attrs, data_type) +214 if issue.severity == MetadataIssueSeverity.ERROR +215 ] +216 raise MetadataConventionError(issues) from e
+ +217 +218 +219def _no_metadata(required: bool, message: str) -> None: +220 """None, or with required raise a MetadataConventionError""" +221 if required: +222 raise MetadataConventionError( +223 [ +224 MetadataConventionIssue( +225 severity=MetadataIssueSeverity.ERROR, location="", message=message +226 ) +227 ] +228 ) +229 +230 +
+[docs] +231def set_metadata(obj: pd.Series | pd.DataFrame, meta: AnyMetadata) -> None: +232 """Attach the supplied metadata model to a result object. +233 +234 Store it under attrs["hd_metadata"], replacing convention fields and preserving +235 unrelated attrs. +236 +237 Args: +238 obj: Result Series or DataFrame created by your component; updated in place. +239 meta: Validated metadata model; shared without copying or revalidation. +240 +241 Returns: +242 None: Updates obj in place. +243 +244 Code example: +245 +246 Convert the metre values from :ref:`metadata-example-data` to centimetres and set matching +247 metadata on the result. The original metadata stays unchanged: +248 +249 .. doctest:: metadata.set_metadata +250 +251 >>> meta = md.read_metadata(series, required=True) +252 >>> result = series * 100 +253 >>> changed = meta.with_value_dimension(None, "value", unit="cm") +254 >>> md.set_metadata(result, changed) +255 >>> md.get_series_unit(result), md.get_series_unit(series) +256 ('cm', 'm') +257 >>> md.read_metadata(result) is changed +258 True +259 """ +260 obj.attrs = {key: value for key, value in obj.attrs.items() if key not in CONVENTION_KEYS} | { +261 META_KEY: meta +262 }
+ +263 +264 +
+[docs] +265def attrs_to_json(attrs: Mapping[Any, Any]) -> dict[Any, Any]: +266 """Replace attached metadata with its JSON fields in a new attrs dictionary. +267 +268 Keep unrelated attrs as-is. Omit optional metadata fields set to None or their defaults. +269 +270 Args: +271 attrs: Pandas attrs mapping; left unchanged. +272 +273 Returns: +274 dict[Any, Any]: +275 Dictionary for json.dumps or export. Unrelated attrs must also be +276 JSON-compatible. +277 +278 Code example: +279 +280 Using :ref:`metadata-example-data`, convert attached models to JSON-compatible dictionaries. +281 This function returns a dictionary; json.dumps produces the JSON string: +282 +283 .. doctest:: metadata.attrs_to_json +284 +285 >>> import json +286 >>> data = series.copy() +287 >>> _ = md.attach_metadata(data) +288 >>> attrs = md.attrs_to_json(data.attrs) +289 >>> isinstance(attrs, dict), "hd_metadata" in attrs +290 (True, False) +291 >>> encoded = json.dumps(attrs) +292 >>> json.loads(encoded)["metrics"][0]["value_dimensions"][0]["unit"] +293 'm' +294 >>> export = data.copy() +295 >>> export.attrs = md.attrs_to_json(export.attrs) +296 >>> "hd_metadata" in data.attrs, "hd_metadata" in export.attrs +297 (True, False) +298 +299 The export copy can now be written with ``export.to_frame().to_parquet("series.parquet")`` +300 if a pandas Parquet engine is installed. The original keeps its attached model. +301 """ +302 attached = attrs.get(META_KEY) +303 if not isinstance(attached, _TimeseriesMetadataBase): +304 return dict(attrs) +305 rest = {key: value for key, value in attrs.items() if key != META_KEY} +306 return rest | attached.model_dump(mode="json")
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/metadata/conventions.html b/docs/_modules/hdhelpers/metadata/conventions.html new file mode 100644 index 0000000..6749dba --- /dev/null +++ b/docs/_modules/hdhelpers/metadata/conventions.html @@ -0,0 +1,1984 @@ + + + + + + + hdhelpers.metadata.conventions — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.metadata.conventions

+   1"""Metadata conventions for timeseries data
+   2
+   3The models in this module specify the hetida designer metadata conventions for the metadata
+   4(attrs) of SERIES, SINGLETSFRAME and MULTITSFRAME objects, one model per data type. See the user
+   5guide page on attached metadata for an introduction.
+   6
+   7Raw metadata is stored as convention fields in pandas attrs. A parsed model can be attached
+   8under attrs["hd_metadata"]; read_metadata accepts both forms. Adapters exchange the raw form,
+   9e.g. in the Data-Attributes header of generic REST adapter timeseries endpoints or the
+  10__metadata__ field of the direct provisioning adapter's wrapped format. Use attrs_to_json
+  11to convert attached models back to raw fields for export.
+  12
+  13The conventions are strict: Metadata adhering to them provides the required fields, possibly as
+  14null if the information is unknown, e.g. the unit of a value dimension. Consumers like components
+  15should nevertheless cope with missing metadata, since validation of metadata received from
+  16adapters is optional.
+  17
+  18The models document the request body of the hetida designer backend endpoint for checking
+  19metadata. Hence they occur as JSON Schemas in the openapi.json of the backend, so that third party
+  20projects, e.g. adapters providing or receiving such metadata, can validate against them.
+  21Additional fields are allowed at every level. The models are immutable, see ConventionModel.
+  22Metadata timestamps are UTC ISO strings with an explicit Z or +00:00 offset.
+  23
+  24check_metadata_conventions additionally checks the rules which JSON Schema cannot express,
+  25optionally against the actual data.
+  26"""
+  27
+  28import datetime
+  29import difflib
+  30import json
+  31import math
+  32import re
+  33from collections import Counter
+  34from collections.abc import Mapping
+  35from enum import StrEnum
+  36from functools import cache, lru_cache
+  37from importlib import resources
+  38from typing import Annotated, Any, ClassVar, Literal, NoReturn, Self
+  39
+  40import pandas as pd
+  41from pydantic import (
+  42    AfterValidator,
+  43    BaseModel,
+  44    BeforeValidator,
+  45    ConfigDict,
+  46    Field,
+  47    GetJsonSchemaHandler,
+  48    ModelWrapValidatorHandler,
+  49    StrictBool,
+  50    StrictFloat,
+  51    StrictInt,
+  52    StrictStr,
+  53    ValidationError,
+  54    ValidationInfo,
+  55    model_validator,
+  56)
+  57from pydantic.json_schema import JsonSchemaValue
+  58from pydantic_core import CoreSchema, PydanticCustomError
+  59
+  60from hdhelpers.metadata._reading import MetadataReading, id_of
+  61
+  62TimeseriesDataType = Literal["SERIES", "SINGLETSFRAME", "MULTITSFRAME"]
+  63
+  64
+
+[docs] + 65class IntervalType(StrEnum): + 66 """Which boundaries belong to a time interval + 67 + 68 closed: both, open: none, left_closed / right_open: only the start, + 69 right_closed / left_open: only the end. + 70 """ + 71 + 72 CLOSED = "closed" + 73 LEFT_CLOSED = "left_closed" + 74 RIGHT_CLOSED = "right_closed" + 75 OPEN = "open" + 76 LEFT_OPEN = "left_open" + 77 RIGHT_OPEN = "right_open"
+ + 78 + 79 + 80# [0-9] instead of \d, since \d also matches non-ASCII digits in some regex dialects + 81UTC_TIMESTAMP_PATTERN = ( + 82 r"^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])" + 83 r"T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?(Z|\+00:00)$" + 84) + 85 + 86 + 87def _check_point_in_time(timestamp: str) -> str: + 88 """Reject timestamps of the pattern which are no actual points in time, e.g. February 30""" + 89 try: + 90 datetime.datetime.fromisoformat(timestamp) + 91 except ValueError as e: + 92 raise ValueError(f"{timestamp!r} is not an actual point in time: {e}.") from e + 93 return timestamp + 94 + 95 + 96def _utc_isoformat(value: Any) -> Any: + 97 """A datetime with UTC offset 0 as isoformat string, so that producers can pass datetimes""" + 98 if not isinstance(value, datetime.datetime): + 99 return value + 100 if value.utcoffset() is None: + 101 raise ValueError(f"{value!r} is naive, but needs to be in UTC.") + 102 if value.utcoffset() != datetime.timedelta(0): + 103 raise ValueError(f"{value!r} is not in UTC.") + 104 return value.isoformat() + 105 + 106 + 107# The pattern specifies the format in the JSON Schema, which cannot express that the date and + 108# time must exist. Pydantic's datetime type is not used, since it accepts further formats and + 109# offsets, so that the check would be more lenient than the published JSON Schema. + 110UtcTimestamp = Annotated[ + 111 str, + 112 Field(pattern=UTC_TIMESTAMP_PATTERN, json_schema_extra={"format": "date-time"}), + 113 AfterValidator(_check_point_in_time), + 114 # last, so that it runs first and the JSON Schema keeps the pattern + 115 BeforeValidator(_utc_isoformat), + 116] + 117 + 118 +
+[docs] + 119class AggregationMethod(StrEnum): + 120 """Method for aggregating timeseries values into coarser intervals. + 121 + 122 Metadata lookups normalize AVG to MEAN; they are distinct enum values. + 123 """ + 124 + 125 MEAN = "MEAN" + 126 AVG = "AVG" + 127 MEDIAN = "MEDIAN" + 128 SUM = "SUM" + 129 MIN = "MIN" + 130 MAX = "MAX" + 131 FIRST = "FIRST" + 132 LAST = "LAST" + 133 COUNT = "COUNT"
+ + 134 + 135 +
+[docs] + 136class ValueDataType(StrEnum): + 137 """Data type of the values of a value dimension""" + 138 + 139 FLOAT = "float" + 140 INTEGER = "integer" + 141 BOOLEAN = "boolean" + 142 STRING = "string"
+ + 143 + 144 +
+[docs] + 145class RecordingMode(StrEnum): + 146 """When the values of a timeseries are recorded + 147 + 148 periodic: one value per interval of data_frequency, timestamped with the start of the + 149 interval. A missing value is a gap. + 150 on_change: a value is recorded when it changes (by more than the deadband of its value + 151 dimension) and holds until the next one. Missing timestamps are no gaps, as long as + 152 data_frequency (the heartbeat) is not exceeded. + 153 irregular: values at arbitrary points in time. + 154 """ + 155 + 156 PERIODIC = "periodic" + 157 ON_CHANGE = "on_change" + 158 IRREGULAR = "irregular"
+ + 159 + 160 + 161_FREQUENCY_FORMAT = 'as Pandas date offset alias or timedelta string, e.g. "15min" or "1D"' + 162_OFFSET_DESCRIPTION = ( + 163 "Offset of the timestamps against the starts of the intervals of {frequency} (see" + 164 ' {timezone}) as timedelta string, e.g. "4min": not negative and smaller than {frequency}. 0' + 165 " if missing or null. Requires {frequency}." + 166) + 167 + 168_TIMEZONE_FORMAT = ( + 169 'as IANA time zone name like "Europe/Berlin" or "UTC" (preferred, since it respects daylight' + 170 ' saving time) or as fixed UTC offset like "+02:00"' + 171) + 172 + 173_ALIGNMENT_DESCRIPTION = ( + 174 "Time zone the intervals of {frequency} are aligned to, " + 175 + _TIMEZONE_FORMAT + 176 + ". Without it, the intervals start at the multiples of {frequency} since" + 177 " 1970-01-01T00:00:00Z. With it, for a {frequency} shorter than a day, the intervals restart" + 178 " at each local midnight: They start at local midnight plus multiples of {frequency}, and the" + 179 " last interval of a day ends at the next local midnight, so it may be shorter, e.g. on the" + 180 " day of a daylight saving time change. For a {frequency} of a day or longer, the intervals" + 181 " follow the calendar of the time zone, e.g. days from local midnight to local midnight. The" + 182 " offset shifts the intervals in both cases. Requires {frequency}." + 183) + 184_TIMEZONE_PURPOSE = ( + 185 " Components evaluating the timeseries in calendar intervals use it, e.g. a report" + 186 " aggregating days, which start at midnight of this time zone, so the results differ between" + 187 " time zones. This is not the time zone timestamps are displayed in: That is the plot target" + 188 " timezone of the execution context, which depends on the viewer, while this time zone belongs" + 189 " to the data, e.g. the time zone of the plant." + 190) + 191 + 192 + 193def _immutable(self: Any, *_args: Any, **_kwargs: Any) -> NoReturn: + 194 raise TypeError(f"{type(self).__name__} is immutable") + 195 + 196 + 197class FrozenDict(dict[str, Any]): + 198 """A dict which cannot be changed + 199 + 200 Holds dict values of extra fields of the convention models, since the models are shared + 201 between pandas objects instead of being copied. Use it as a read-only mapping, not as a dict: + 202 The builtin frozendict of Python 3.15, which is planned to replace it, is no dict subclass. + 203 """ + 204 + 205 __slots__ = () + 206 __setitem__ = __delitem__ = __ior__ = _immutable # type: ignore[assignment] + 207 clear = pop = popitem = setdefault = update = _immutable # type: ignore[assignment] + 208 + 209 def __copy__(self) -> Self: + 210 return self + 211 + 212 def __deepcopy__(self, memo: dict[int, Any]) -> Self: + 213 return self + 214 + 215 def __reduce__(self) -> tuple[Any, ...]: + 216 return (FrozenDict, (dict(self),)) + 217 + 218 + 219def _freeze(value: Any) -> Any: + 220 """A JSON value with dicts and lists as FrozenDicts and tuples, recursively""" + 221 if isinstance(value, dict): + 222 if not all(isinstance(key, str) for key in value): + 223 raise ValueError("Keys of JSON objects must be strings.") + 224 return FrozenDict({key: _freeze(item) for key, item in value.items()}) + 225 if isinstance(value, list | tuple): + 226 return tuple(_freeze(item) for item in value) + 227 if ( + 228 value is None + 229 or isinstance(value, str | int) + 230 or (isinstance(value, float) and math.isfinite(value)) + 231 ): + 232 return value + 233 raise ValueError(f"{value!r} is not a JSON value.") + 234 + 235 + 236_JsonFloat = Annotated[StrictFloat, Field(allow_inf_nan=False)] + 237 + 238# Scalars are validated within pydantic-core. Only dicts and lists need Python for freezing them. + 239_FrozenValue = Annotated[ + 240 StrictStr + 241 | StrictInt + 242 | _JsonFloat + 243 | StrictBool + 244 | None + 245 | Annotated[Any, AfterValidator(_freeze)], + 246 Field(union_mode="left_to_right"), + 247] + 248 + 249 + 250def optional(default: Any = None, **kwargs: Any) -> Any: + 251 """Field for an optional value, left out of dumps while it is None or the default""" + 252 return Field(default, exclude_if=lambda value: value is None or value == default, **kwargs) + 253 + 254 + 255def _item_count(min_length: int, max_length: int | None = None) -> BeforeValidator: + 256 """Check the number of items before validating them + 257 + 258 Pydantic counts the items of a tuple after validating them, so that an invalid item would + 259 also be reported as missing. The JSON Schema states the counts via json_schema_extra. + 260 """ + 261 + 262 def items(count: int) -> str: + 263 return f"{count} item" if count == 1 else f"{count} items" + 264 + 265 def check(value: Any) -> Any: + 266 if isinstance(value, list | tuple): + 267 if len(value) < min_length: + 268 raise PydanticCustomError( + 269 "too_short", f"Should have at least {items(min_length)}, not {len(value)}" + 270 ) + 271 if max_length is not None and len(value) > max_length: + 272 raise PydanticCustomError( + 273 "too_long", f"Should have at most {items(max_length)}, not {len(value)}" + 274 ) + 275 # a tuple, since strict validation after a before validator rejects lists + 276 return tuple(value) + 277 return value + 278 + 279 return BeforeValidator(check) + 280 + 281 + 282class ConventionModel(BaseModel): + 283 """Base of the models of the metadata conventions + 284 + 285 The models are immutable, including the values of extra fields, so that pandas objects can + 286 share them instead of copying them: pandas deep-copies the attrs of an object for (almost) + 287 every object derived from it. A changed version of a model is a new model, which shares all + 288 unchanged parts with the original. + 289 """ + 290 + 291 # Validation is lax, so that Python input may contain lists for the tuples. Fields whose type + 292 # lax validation would coerce (bool, int, float) use the strict types instead, so that the + 293 # results equal strict validation of JSON input. + 294 model_config = ConfigDict(extra="allow", frozen=True) + 295 __pydantic_extra__: dict[str, _FrozenValue] + 296 + 297 def __deepcopy__(self, memo: dict[int, Any] | None = None) -> Self: + 298 return self + 299 + 300 def model_copy( + 301 self, + 302 *, + 303 update: Mapping[str, Any] | None = None, + 304 deep: bool = False, # noqa: ARG002 + 305 ) -> Self: + 306 """Copy with the given fields replaced, without validating them + 307 + 308 Always shallow, since the model is immutable. A deep copy would apply the update to the + 309 original, since __deepcopy__ returns the original. + 310 """ + 311 return super().model_copy(update=update, deep=False) + 312 + 313 @classmethod + 314 def __get_pydantic_json_schema__( + 315 cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler + 316 ) -> JsonSchemaValue: + 317 json_schema = handler(core_schema) + 318 # Any JSON value is allowed in extra fields, as without the typed __pydantic_extra__ + 319 handler.resolve_ref_schema(json_schema)["additionalProperties"] = True + 320 return json_schema + 321 + 322 +
+[docs] + 323class SharedValueDimensionMetadata(ConventionModel): + 324 """Default metadata for a value column shared across metrics. + 325 + 326 Matched by column name ("value" for a SERIES) and used when the corresponding field + 327 is missing from metric-specific metadata. + 328 """ + 329 + 330 column: str = Field( + 331 ..., + 332 description="The actual column name in the MULTITSFRAME or SINGLETSFRAME, connecting the" + 333 ' value dimension to its column. "value" for a SERIES.', + 334 ) + 335 name: str | None = optional( + 336 description='Name of the value dimension, e.g. "temperature". May equal column.' + 337 ) + 338 display_name: str | None = optional( + 339 description="A shorter name for labels, captions or plot titles. Falls back to name.", + 340 ) + 341 short_display_name: str | None = optional( + 342 description="A short name for legends in plots. Falls back to display_name." + 343 ) + 344 description: str | None = optional( + 345 description="A description. Should be considered empty if missing." + 346 ) + 347 unit: str | None = optional( + 348 description='Unit of the values, e.g. "°C" or "m/s", "" for dimensionless quantities', + 349 ) + 350 measurement: str | None = optional(description='The measured quantity, e.g. "temperature"') + 351 value_data_type: ValueDataType | None = optional(description="Data type of the values") + 352 deadband: StrictFloat | None = optional( + 353 ge=0, + 354 allow_inf_nan=False, + 355 description="For a metric with recording_mode on_change: the change of the values, in" + 356 " their unit, which has to be exceeded for a value to be recorded. 0 records every" + 357 " change.", + 358 ) + 359 decimals: StrictInt | None = optional( + 360 ge=0, + 361 description="Number of decimal places numeric values should be displayed with, in their" + 362 " unit, e.g. 2 for 12.35. Only affects how values are displayed, e.g. in tables, tooltips" + 363 " or reports; the values themselves are not rounded. Up to the consumer if missing or" + 364 " null.", + 365 )
+ + 366 + 367 +
+[docs] + 368class ValueDimensionMetadata(SharedValueDimensionMetadata): + 369 """Metadata for one metric's value column, such as its unit and measured quantity. + 370 + 371 Each metric describes its value columns; a SERIES uses "value". Non-null fields take + 372 precedence over shared metadata for the same column. Unit and measurement are required + 373 fields, but may be None if unknown. + 374 """ + 375 + 376 unit: str | None = Field( + 377 ..., + 378 description='Unit of the values, e.g. "°C" or "m/s". "" for dimensionless quantities, null' + 379 " if unknown.", + 380 ) + 381 measurement: str | None = Field( + 382 ..., description='The measured quantity, e.g. "temperature". Null if unknown.' + 383 )
+ + 384 + 385 +
+[docs] + 386class InheritedMetricMetadata(ConventionModel): + 387 """Properties supplied by a source system for a metric and its parent assets. + 388 + 389 Stores already-resolved values, e.g. a plant's latitude; the source system resolves the + 390 hierarchy. Metadata lookups use these properties as fallbacks only with + 391 ``include_inherited=True``. Convention-defined fields such as name, id and unit belong in + 392 the metric or value-dimension metadata. + 393 + 394 Use JSON types matching each value's meaning: numbers, booleans and UTC ISO timestamp + 395 strings for points in time. For example, hetida platform supplies properties by label, + 396 using a signal's own value or that of the nearest parent asset providing one. + 397 """
+ + 398 + 399 +
+[docs] + 400class MetricCommentReply(ConventionModel): + 401 """A reply to a comment attached to a metric""" + 402 + 403 id: str | None = optional(description="Id of the reply, e.g. a UUID") + 404 created_at: UtcTimestamp | None = optional(description="When the reply was created") + 405 edited_at: UtcTimestamp | None = optional( + 406 description="When the reply was last edited. Null or missing if never edited." + 407 ) + 408 content: str | None = optional(description="Free text of the reply") + 409 username: str | None = optional(description="Username of the author of the reply")
+ + 410 + 411 +
+[docs] + 412class MetricComment(ConventionModel): + 413 """A comment attached to a metric, describing a time range of its timeseries data + 414 + 415 E.g. the hetida platform provides the comments on a signal whose time range overlaps the + 416 queried interval and which existed at query time, newest first. + 417 + 418 Start and end of the time range must be provided together. They are equal for a comment on a + 419 single point in time. + 420 """ + 421 + 422 id: str | None = optional(description="Id of the comment, e.g. a UUID") + 423 start_date: UtcTimestamp | None = optional( + 424 description="Start of the time range the comment refers to" + 425 ) + 426 end_date: UtcTimestamp | None = optional( + 427 description="End of the time range the comment refers to" + 428 ) + 429 created_at: UtcTimestamp | None = optional(description="When the comment was created") + 430 edited_at: UtcTimestamp | None = optional( + 431 description="When the comment was last edited. Null or missing if never edited." + 432 ) + 433 content: str | None = optional(description="Free text of the comment") + 434 username: str | None = optional(description="Username of the author of the comment") + 435 tags: tuple[str, ...] | None = optional(description="Free text tags classifying the comment") + 436 replies: tuple[MetricCommentReply, ...] | None = optional( + 437 description="Replies to the comment, oldest first" + 438 )
+ + 439 + 440 +
+[docs] + 441class MetricMetadata(ConventionModel): + 442 """Metadata of a metric, i.e. of a single, possibly multidimensional timeseries + 443 + 444 A metric is identified by its field named by dataset_metadata.metric_key, whose values occur + 445 in the metric column of a MULTITSFRAME. Every metric must have this field as a string, + 446 unique among the metrics. + 447 """ + 448 + 449 id: str = Field( + 450 ..., + 451 description="Id of the metric, also if another field identifies the metric in the data" + 452 " via dataset_metadata.metric_key. E.g. the hetida platform provides the signal id here.", + 453 ) + 454 name: str = Field(..., description="Long name of the metric") + 455 display_name: str | None = optional( + 456 description="A shorter name for labels, captions or plot titles" + 457 ) + 458 short_display_name: str | None = optional(description="A short name for legends in plots") + 459 description: str | None = optional(description="A one sentence description") + 460 external_id: str | None = optional( + 461 description="Unique id / key in external source systems. Often used to identify the" + 462 " metric in the metric column, i.e. as metric_key.", + 463 ) + 464 signal_id: str | None = optional(description="Actual platform signal id") + 465 channel_id: str | None = optional( + 466 description="Actual platform channel id. Deprecated, since the platform calls channels" + 467 " signals now.", + 468 deprecated='Use "signal_id" instead.', + 469 ) + 470 value_dimensions: Annotated[tuple[ValueDimensionMetadata, ...], _item_count(1)] = Field( + 471 ..., + 472 json_schema_extra={"minItems": 1}, + 473 description="Metadata of the value dimensions of this metric: an entry for each value" + 474 ' column of the data, i.e. exactly the value dimension "value" for a SERIES. Each column' + 475 " may occur at most once.", + 476 ) + 477 default_aggregation_method: AggregationMethod | None = optional( + 478 description="How the timeseries should be aggregated by default, e.g. when components" + 479 " plot or report it in coarser intervals. If missing or null: LAST if the value_data_type" + 480 ' of the value dimension "value" is boolean or string, otherwise MEAN. AVG is an alias' + 481 " of MEAN. E.g. the hetida platform provides the method it aggregates the signal with when" + 482 " bucketing it, for a signal group member the one configured for the member.", + 483 ) + 484 default_timezone: str | None = optional( + 485 description="Time zone the timeseries should be evaluated in by default, " + 486 + _TIMEZONE_FORMAT + 487 + "." + 488 + _TIMEZONE_PURPOSE + 489 + " Falls back to dataset_metadata.default_timezone.", + 490 ) + 491 path: tuple[str, ...] | None = optional( + 492 description="Location of the metric in the hierarchy of its source system: the names of" + 493 " the levels from the top down to the object the metric belongs to, without the metric" + 494 ' itself, e.g. ["Plant A", "Pickling Unit", "Influx"]. Corresponds to the breadcrumb' + 495 " path of the sources of generic REST adapters.", + 496 ) + 497 origin: str | None = optional( + 498 description="Where the values come from, as free text of the source system, e.g." + 499 ' "OPC_UA", "MQTT" or "FORM" for values entered manually.', + 500 ) + 501 recording_mode: RecordingMode | None = optional( + 502 description="When the values are recorded. Unknown if missing or null. periodic requires" + 503 " data_frequency (or dataset_metadata.ref_data_frequency).", + 504 ) + 505 data_frequency: str | None = optional( + 506 description="Interval in which values of the metric are recorded " + 507 + _FREQUENCY_FORMAT + 508 + ": For recording_mode periodic the interval between subsequent values, for on_change" + 509 " the interval after which the value is recorded again at the latest, also without a" + 510 " change (heartbeat). Together with data_frequency_offset and data_frequency_timezone it" + 511 " takes precedence over ref_data_frequency, ref_data_frequency_offset and" + 512 " ref_data_frequency_timezone of the dataset metadata for this metric.", + 513 ) + 514 data_frequency_offset: str | None = optional( + 515 description=_OFFSET_DESCRIPTION.format( + 516 frequency="data_frequency", timezone="data_frequency_timezone" + 517 ), + 518 ) + 519 data_frequency_timezone: str | None = optional( + 520 description=_ALIGNMENT_DESCRIPTION.format(frequency="data_frequency") + 521 ) + 522 recording_aggregation_method: AggregationMethod | None = optional( + 523 description="Only for recording_mode periodic: how each value aggregates the values of" + 524 " the interval [timestamp, timestamp + data_frequency), e.g. MEAN for an average or MAX" + 525 " for a maximum. Null or missing if the values are samples, e.g. the value holding at" + 526 " the timestamp.", + 527 ) + 528 inherited: InheritedMetricMetadata | None = optional() + 529 comments: tuple[MetricComment, ...] | None = optional( + 530 description="Comments attached to the metric, describing situations in its timeseries data", + 531 )
+ + 532 + 533 +
+[docs] + 534class SignalGroupMetadata(ConventionModel): + 535 """A group of signals a dataset was read from + 536 + 537 E.g. the hetida platform provides the signal group a MULTITSFRAME was read from. + 538 """ + 539 + 540 id: str | None = optional(description="Id of the signal group") + 541 name: str | None = optional(description="Name of the signal group") + 542 description: str | None = optional(description="Description of the signal group")
+ + 543 + 544 + 545REF_INTERVAL_FIELDS = ( + 546 "ref_interval_start_timestamp", + 547 "ref_interval_end_timestamp", + 548 "ref_interval_type", + 549) + 550 + 551# JSON Schema of the rule that the ref interval is required unless ref_dataset_discrete is true + 552_REF_INTERVAL_REQUIRED_SCHEMA: dict[str, Any] = { + 553 "if": { + 554 "properties": {"ref_dataset_discrete": {"const": True}}, + 555 "required": ["ref_dataset_discrete"], + 556 }, + 557 "else": { + 558 "required": list(REF_INTERVAL_FIELDS), + 559 "properties": {field: {"type": "string"} for field in REF_INTERVAL_FIELDS}, + 560 }, + 561} + 562 + 563 + 564class _DatasetMetadataBase(ConventionModel): + 565 model_config = ConfigDict(json_schema_extra=_REF_INTERVAL_REQUIRED_SCHEMA) + 566 + 567 metric_key: str = Field( + 568 ..., + 569 description="Which field of the metric objects under metrics identifies a metric, i.e." + 570 ' occurs in the metric column of a MULTITSFRAME, e.g. "id" or "external_id".', + 571 ) + 572 queried_metrics: tuple[str, ...] = Field( + 573 ..., + 574 description="The requested metrics. Since not all queried metrics may have data in the" + 575 " queried interval, a metric may be missing in the data.", + 576 ) + 577 signal_group: SignalGroupMetadata | None = optional() + 578 ref_dataset_discrete: StrictBool | None = optional( + 579 False, + 580 description="Whether the ref dataset should be considered as discrete, i.e. isolated" + 581 " datapoints instead of an interval. If true, the ref interval is not required.", + 582 ) + 583 ref_interval_start_timestamp: UtcTimestamp | None = optional( + 584 description="Start of the queried time interval (ref interval) in UTC isoformat." + 585 " Required unless ref_dataset_discrete is true.", + 586 ) + 587 ref_interval_end_timestamp: UtcTimestamp | None = optional( + 588 description="End of the queried time interval (ref interval) in UTC isoformat." + 589 " Required unless ref_dataset_discrete is true.", + 590 ) + 591 ref_interval_type: IntervalType | None = optional( + 592 description="Type of the ref interval. Required unless ref_dataset_discrete is true.", + 593 ) + 594 ref_data_frequency: str | None = optional( + 595 description="Frequency of the timeseries " + 596 + _FREQUENCY_FORMAT + 597 + ": the data_frequency of all metrics without their own, with the same meaning as" + 598 " there. Together with ref_data_frequency_offset and ref_data_frequency_timezone.", + 599 ) + 600 ref_data_frequency_offset: str | None = optional( + 601 description=_OFFSET_DESCRIPTION.format( + 602 frequency="ref_data_frequency", timezone="ref_data_frequency_timezone" + 603 ) + 604 + " Applies to all metrics without their own data_frequency.", + 605 ) + 606 ref_data_frequency_timezone: str | None = optional( + 607 description=_ALIGNMENT_DESCRIPTION.format(frequency="ref_data_frequency") + 608 + " Applies to all metrics without their own data_frequency.", + 609 ) + 610 query_timestamp: UtcTimestamp | None = optional( + 611 description="The point in time whose state of the data was delivered in UTC isoformat:" + 612 " Data stored, changed or invalidated later is not included. A source supporting it" + 613 " delivers the same data again when queried for this point in time, e.g. for" + 614 " reproducing an execution.", + 615 ) + 616 default_aggregation_interval: str | None = optional( + 617 description="Interval to aggregate the timeseries to by default, e.g. when components" + 618 " plot or report them, " + 619 + _FREQUENCY_FORMAT + 620 + ". The metrics are aggregated with their default_aggregation_method. E.g. a hetida" + 621 " platform signal group is configured with such an interval.", + 622 ) + 623 default_timezone: str | None = optional( + 624 description="Time zone the timeseries should be evaluated in by default, " + 625 + _TIMEZONE_FORMAT + 626 + "." + 627 + _TIMEZONE_PURPOSE + 628 + " Applies to all metrics without their own default_timezone.", + 629 ) + 630 invalidation_interval_start: UtcTimestamp | None = optional( + 631 description="Start of an invalidation interval separate from the ref interval in UTC" + 632 " isoformat", + 633 ) + 634 invalidation_interval_end: UtcTimestamp | None = optional( + 635 description="End of an invalidation interval separate from the ref interval in UTC" + 636 " isoformat", + 637 ) + 638 invalidation_interval_type: IntervalType | None = optional( + 639 IntervalType.CLOSED, description="Type of the invalidation interval" + 640 ) + 641 invalidate_dataset: StrictBool | None = optional( + 642 description="Whether existing data should be invalidated: in the invalidation interval if" + 643 " specified, otherwise in the ref interval. If neither is specified, a closed ref interval" + 644 " from the first to the last datapoint is inferred. Without datapoints no invalidation can" + 645 " occur then. What null means is up to the adapter.", + 646 ) + 647 delete_invalidated: StrictBool | None = optional( + 648 description="Whether data to be invalidated should be deleted instead. What null means is" + 649 " up to the adapter. Adapters do not need to support deletion.", + 650 ) + 651 invalidation_timestamp: UtcTimestamp | None = optional( + 652 description="Invalidation timestamp for all invalidated datapoints. If null, the" + 653 " execution_timestamp query parameter sent to generic REST adapters or, if that is not" + 654 " present either, the current time should be used.", + 655 ) + 656 only_invalidate: StrictBool | None = optional( + 657 False, + 658 description="If true, existing data should only be invalidated and the new data should" + 659 " not be stored. Cannot be true if invalidate_dataset is false.", + 660 ) + 661 new_data_invalidation_date: UtcTimestamp | None = optional( + 662 description="An invalidation date to be stored together with all new datapoints, i.e." + 663 " invalidating them in advance. Ignored if null.", + 664 ) + 665 + 666 +
+[docs] + 667class SingleMetricDatasetMetadata(_DatasetMetadataBase): + 668 """Metadata associated with a SERIES or SINGLETSFRAME as a whole + 669 + 670 E.g. the queried time interval or how existing data should be handled when the dataset is + 671 sent to an adapter. The fields concerning invalidation and deletion are only relevant for + 672 adapters actually supporting these operations, like the built-in SQL adapter. Other adapters + 673 may ignore them. + 674 + 675 Start and end of an interval must be provided together. + 676 """ + 677 + 678 single_metric: str = Field( + 679 ..., description="The metric_key field value of the single metric under metrics." + 680 )
+ + 681 + 682 +
+[docs] + 683class MultiTSFrameDatasetMetadata(_DatasetMetadataBase): + 684 """Metadata associated with a MULTITSFRAME as a whole + 685 + 686 E.g. the queried time interval or how existing data should be handled when the dataset is + 687 sent to an adapter. The fields concerning invalidation and deletion are only relevant for + 688 adapters actually supporting these operations, like the built-in SQL adapter. Other adapters + 689 may ignore them. + 690 + 691 Start and end of an interval must be provided together. + 692 """ + 693 + 694 single_metric: str | None = optional( + 695 description="If the MULTITSFRAME holds a single metric: its metric_key field value. Must" + 696 " not be set if more than one metric is present.", + 697 )
+ + 698 + 699 + 700class _TimeseriesMetadataBase(MetadataReading, ConventionModel): + 701 """Base of the root models, the metadata of a SERIES, SINGLETSFRAME or MULTITSFRAME + 702 + 703 The methods of MetadataReading look up information with the fallbacks of the conventions. + 704 The with_* methods return changed versions, which share all unchanged parts with this one. + 705 """ + 706 + 707 _data_type: ClassVar[TimeseriesDataType] + 708 + 709 value_dimensions_shared: tuple[SharedValueDimensionMetadata, ...] | None = optional( + 710 description="Metadata of value dimensions that have the same meaning for all metrics." + 711 " Consumers use it as fallback, but it does not replace the value dimensions of the" + 712 " metrics. Each column may occur at most once.", + 713 ) + 714 + 715 @model_validator(mode="wrap") + 716 @classmethod + 717 def _conforms( + 718 cls, data: Any, handler: ModelWrapValidatorHandler[Self], info: ValidationInfo + 719 ) -> Self: + 720 """Reject metadata violating the rules of the conventions which JSON Schema cannot express + 721 + 722 check_metadata_conventions reports these errors itself, at their exact locations. + 723 """ + 724 model = handler(data) + 725 if (info.context or {}).get(_SKIP_RULES) is True: + 726 return model + 727 errors = _rule_errors(data, model, cls._data_type) + 728 if len(errors) > 0: + 729 raise MetadataConventionError(errors) + 730 return model + 731 + 732 def model_copy(self, *, update: Mapping[str, Any] | None = None, deep: bool = False) -> Self: + 733 copied = super().model_copy(update=update, deep=deep) + 734 copied.__dict__.pop("_metrics_by_id", None) # the metrics may differ + 735 return copied + 736 + 737 def _converted[T: "_TimeseriesMetadataBase"](self, model: type[T], **fields: Any) -> T: + 738 """The metadata as model with the given fields, keeping the extra fields""" + 739 return model.model_validate(self.__pydantic_extra__ | fields) + 740 + 741 def with_metric(self, metric_id: str | None = None, /, **changes: Any) -> Self: + 742 """A version with fields of a metric changed, the other metrics are shared + 743 + 744 The changed metric is validated, including the rules of the conventions which concern it. + 745 """ + 746 old = self.metric(metric_id) + 747 new = type(old).model_validate(old.model_dump() | changes) + 748 errors = _metric_rule_errors(self, new) + 749 new_id = id_of(new, self.dataset_metadata.metric_key) + 750 if new_id != id_of(old, self.dataset_metadata.metric_key) and new_id in self.metric_ids(): + 751 errors.append(_error("metrics", f'Metric "{new_id}" occurs more than once.')) + 752 if len(errors) > 0: + 753 raise MetadataConventionError(errors) + 754 return self.model_copy( + 755 update={"metrics": tuple(new if metric is old else metric for metric in self.metrics)} + 756 ) + 757 + 758 def with_value_dimension( + 759 self, metric_id: str | None = None, column: str = "value", /, **changes: Any + 760 ) -> Self: + 761 """A version with fields of a value dimension of a metric changed, see with_metric""" + 762 metric = self.metric(metric_id) + 763 if column not in {value_dimension.column for value_dimension in metric.value_dimensions}: + 764 raise KeyError(column) + 765 return self.with_metric( + 766 metric_id, + 767 value_dimensions=tuple( + 768 value_dimension.model_dump() | changes + 769 if value_dimension.column == column + 770 else value_dimension + 771 for value_dimension in metric.value_dimensions + 772 ), + 773 ) + 774 + 775 def with_dataset_metadata(self, **changes: Any) -> Self: + 776 """A version with fields of the dataset metadata changed, the metrics are shared + 777 + 778 Validated including all rules of the conventions, since the dataset metadata concerns + 779 all metrics. + 780 """ + 781 old = self.dataset_metadata + 782 changed = self.model_copy( + 783 update={"dataset_metadata": type(old).model_validate(old.model_dump() | changes)} + 784 ) + 785 errors = _rule_errors(None, changed, self._data_type) + 786 if len(errors) > 0: + 787 raise MetadataConventionError(errors) + 788 return changed + 789 + 790 + 791def _value_dimension(column: str, unit: str | None, measurement: str | None) -> dict[str, Any]: + 792 return {"column": column, "unit": unit, "measurement": measurement} + 793 + 794 + 795SERIES_METADATA_EXAMPLE: dict[str, Any] = { + 796 "dataset_metadata": { + 797 "metric_key": "id", + 798 "single_metric": "7ab8c419-8745-4714-b588-57c0c772cc95", + 799 "queried_metrics": ["7ab8c419-8745-4714-b588-57c0c772cc95"], + 800 "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + 801 "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + 802 "ref_interval_type": "right_open", + 803 "query_timestamp": "2026-01-02T00:05:00+00:00", + 804 }, + 805 "metrics": [ + 806 { + 807 "id": "7ab8c419-8745-4714-b588-57c0c772cc95", + 808 "name": "Water level north", + 809 "value_dimensions": [_value_dimension("value", "m", "level") | {"decimals": 2}], + 810 "default_aggregation_method": "MEAN", + 811 "default_timezone": "Europe/Berlin", + 812 "path": ["Waterworks North", "Gauge North"], + 813 "origin": "OPC_UA", + 814 "recording_mode": "periodic", + 815 "data_frequency": "15min", + 816 "recording_aggregation_method": "MEAN", + 817 } + 818 ], + 819} + 820 + 821SINGLETSFRAME_METADATA_EXAMPLE: dict[str, Any] = { + 822 "dataset_metadata": { + 823 "metric_key": "external_id", + 824 "single_metric": "abc.temp", + 825 "queried_metrics": ["abc.temp"], + 826 "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + 827 "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + 828 "ref_interval_type": "closed", + 829 }, + 830 "metrics": [ + 831 { + 832 "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + 833 "external_id": "abc.temp", + 834 "name": "ABC temperature", + 835 "value_dimensions": [ + 836 _value_dimension("value", "°C", "temperature"), + 837 _value_dimension("state", None, None) | {"name": "measurement state"}, + 838 ], + 839 } + 840 ], + 841} + 842 + 843MULTITSFRAME_METADATA_EXAMPLE: dict[str, Any] = { + 844 "dataset_metadata": { + 845 "metric_key": "external_id", + 846 "queried_metrics": ["plant_a.pressure", "plant_a.temperature"], + 847 "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + 848 "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + 849 "ref_interval_type": "closed", + 850 "default_aggregation_interval": "1h", + 851 "default_timezone": "Europe/Berlin", + 852 }, + 853 "metrics": [ + 854 { + 855 "id": "c5a8e2f1-0b3d-4e6a-9f7c-1d2e3f4a5b6c", + 856 "external_id": "plant_a.pressure", + 857 "name": "Plant A pressure", + 858 "value_dimensions": [_value_dimension("value", "bar", "pressure")], + 859 }, + 860 { + 861 "id": "8d7c6b5a-4f3e-4d2c-8b1a-0f9e8d7c6b5a", + 862 "external_id": "plant_a.temperature", + 863 "name": "Plant A temperature", + 864 "value_dimensions": [ + 865 _value_dimension("value", "°C", "temperature") | {"deadband": 0.5, "decimals": 1} + 866 ], + 867 "default_aggregation_method": "MAX", + 868 "recording_mode": "on_change", + 869 "data_frequency": "1h", + 870 }, + 871 ], + 872} + 873 + 874_SINGLE_METRIC_DESCRIPTION = ( + 875 "Metadata of the single metric: exactly one metric, which has the field named by" + 876 " dataset_metadata.metric_key with the value of dataset_metadata.single_metric." + 877) + 878 + 879 + 880# the metric id field set when a metric gets another id, if the metric key field is taken + 881_METRIC_NAME_FIELD = "metric" + 882 + 883 + 884def _renamed[T: SharedValueDimensionMetadata]( + 885 value_dimensions: tuple[T, ...] | None, column: str, new_column: str + 886) -> tuple[T, ...] | None: + 887 """Only the value dimensions of column, renamed to new_column, None if there are none""" + 888 renamed = tuple( + 889 value_dimension.model_copy(update={"column": new_column}) + 890 for value_dimension in value_dimensions or () + 891 if value_dimension.column == column + 892 ) + 893 return renamed if len(renamed) > 0 else None + 894 + 895 + 896def _with_value_column(metric: MetricMetadata, column: str, new_column: str) -> MetricMetadata: + 897 """The metric with only the value dimension of column, renamed to new_column""" + 898 return metric.model_copy( + 899 update={"value_dimensions": _renamed(metric.value_dimensions, column, new_column) or ()} + 900 ) + 901 + 902 +
+[docs] + 903class SeriesMetadata(_TimeseriesMetadataBase): + 904 """Immutable metadata for a SERIES: one metric with a single "value" dimension. + 905 + 906 Read from a pandas Series with read_metadata. See the module documentation for storage, + 907 adapter exchange and common field rules. + 908 """ + 909 + 910 model_config = ConfigDict(json_schema_extra={"examples": [SERIES_METADATA_EXAMPLE]}) + 911 + 912 _data_type: ClassVar[TimeseriesDataType] = "SERIES" + 913 + 914 dataset_metadata: SingleMetricDatasetMetadata + 915 metrics: Annotated[tuple[MetricMetadata, ...], _item_count(1, 1)] = Field( + 916 ..., + 917 json_schema_extra={"minItems": 1, "maxItems": 1}, + 918 description=_SINGLE_METRIC_DESCRIPTION, + 919 ) + 920 +
+[docs] + 921 def as_singletsframe(self, column: str = "value") -> "SingleTSFrameMetadata": + 922 """The metadata of the SINGLETSFRAME with the values of the SERIES in column + 923 + 924 Renames the value dimension "value" of the metric and the shared one to column. + 925 """ + 926 return self._converted( + 927 SingleTSFrameMetadata, + 928 dataset_metadata=self.dataset_metadata, + 929 metrics=(_with_value_column(self.metric(), "value", column),), + 930 value_dimensions_shared=_renamed(self.value_dimensions_shared, "value", column), + 931 )
+
+ + 932 + 933 +
+[docs] + 934class SingleTSFrameMetadata(_TimeseriesMetadataBase): + 935 """Immutable metadata for a SINGLETSFRAME: one metric with one or more value columns. + 936 + 937 Read from a pandas DataFrame with read_metadata. See the module documentation for storage, + 938 adapter exchange and common field rules. + 939 """ + 940 + 941 model_config = ConfigDict(json_schema_extra={"examples": [SINGLETSFRAME_METADATA_EXAMPLE]}) + 942 + 943 _data_type: ClassVar[TimeseriesDataType] = "SINGLETSFRAME" + 944 + 945 dataset_metadata: SingleMetricDatasetMetadata + 946 metrics: Annotated[tuple[MetricMetadata, ...], _item_count(1, 1)] = Field( + 947 ..., + 948 json_schema_extra={"minItems": 1, "maxItems": 1}, + 949 description=_SINGLE_METRIC_DESCRIPTION, + 950 ) + 951 +
+[docs] + 952 def as_series(self, column: str = "value") -> SeriesMetadata: + 953 """The metadata of the SERIES with the values of column + 954 + 955 Keeps only the value dimensions of column, renamed to "value". KeyError if the metric + 956 has no value dimension of column. + 957 """ + 958 if column not in {vd.column for vd in self.metric().value_dimensions}: + 959 raise KeyError(column) + 960 return self._converted( + 961 SeriesMetadata, + 962 dataset_metadata=self.dataset_metadata, + 963 metrics=(_with_value_column(self.metric(), column, "value"),), + 964 value_dimensions_shared=_renamed(self.value_dimensions_shared, column, "value"), + 965 )
+ + 966 +
+[docs] + 967 def as_multitsframe(self, metric_id: str | None = None) -> "MultiTSFrameMetadata": + 968 """The metadata of the MULTITSFRAME of the single metric, named metric_id if given + 969 + 970 If metric_id differs from the id of the metric, it becomes its id: in the field named by + 971 the metric key if the metric does not have it, otherwise in the field "metric", which + 972 then becomes the metric key. + 973 """ + 974 metric = self.metric() + 975 dataset_metadata = self.dataset_metadata.model_dump() + 976 if metric_id is not None and metric_id != self.metric_ids()[0]: + 977 metric_key = dataset_metadata["metric_key"] + 978 if id_of(metric, metric_key) is not None: + 979 metric_key = dataset_metadata["metric_key"] = _METRIC_NAME_FIELD + 980 metric = type(metric).model_validate(metric.model_dump() | {metric_key: metric_id}) + 981 metric_id = id_of(metric, dataset_metadata["metric_key"]) + 982 return self._converted( + 983 MultiTSFrameMetadata, + 984 dataset_metadata=dataset_metadata + 985 | {"single_metric": metric_id, "queried_metrics": [metric_id]}, + 986 metrics=(metric,), + 987 value_dimensions_shared=self.value_dimensions_shared, + 988 )
+
+ + 989 + 990 +
+[docs] + 991class MultiTSFrameMetadata(_TimeseriesMetadataBase): + 992 """Immutable metadata for a MULTITSFRAME: multiple metrics identified by a metric column. + 993 + 994 Each metric can have several value dimensions; a MULTITSFRAME may also hold one metric. + 995 Read from a pandas DataFrame with read_metadata. See the module documentation for storage, + 996 adapter exchange and common field rules. + 997 """ + 998 + 999 model_config = ConfigDict(json_schema_extra={"examples": [MULTITSFRAME_METADATA_EXAMPLE]}) +1000 +1001 _data_type: ClassVar[TimeseriesDataType] = "MULTITSFRAME" +1002 +1003 dataset_metadata: MultiTSFrameDatasetMetadata +1004 metrics: tuple[MetricMetadata, ...] = Field( +1005 ..., +1006 description="Metadata of the metrics: one for each metric in the data. Each metric must" +1007 " have the field named by dataset_metadata.metric_key as a string, unique among the" +1008 " metrics.", +1009 ) +1010 +
+[docs] +1011 def narrowed_to(self, metric_id: str) -> SingleTSFrameMetadata: +1012 """The metadata of the SINGLETSFRAME of one metric, KeyError if it has no metadata +1013 +1014 The metric is shared, not copied. It becomes the single and only queried metric. +1015 """ +1016 return self._converted( +1017 SingleTSFrameMetadata, +1018 dataset_metadata=self.dataset_metadata.model_dump() +1019 | {"single_metric": metric_id, "queried_metrics": [metric_id]}, +1020 metrics=(self.metric(metric_id),), +1021 value_dimensions_shared=self.value_dimensions_shared, +1022 )
+
+ +1023 +1024 +1025METADATA_MODELS: dict[str, type[_TimeseriesMetadataBase]] = { +1026 "SERIES": SeriesMetadata, +1027 "SINGLETSFRAME": SingleTSFrameMetadata, +1028 "MULTITSFRAME": MultiTSFrameMetadata, +1029} +1030 +1031 +
+[docs] +1032class MetadataIssueSeverity(StrEnum): +1033 """Severity of an issue found when checking metadata against the metadata conventions +1034 +1035 error: Violates the conventions, so helpers and components may misinterpret the metadata. +1036 warning: Likely a mistake, e.g. metadata not matching the data. +1037 """ +1038 +1039 ERROR = "error" +1040 WARNING = "warning"
+ +1041 +1042 +
+[docs] +1043class MetadataConventionIssue(BaseModel): +1044 """A metadata validation problem, reported as an error or warning. +1045 +1046 Attributes: +1047 severity (MetadataIssueSeverity): Error for a violation; warning for a likely mistake. +1048 location (str): Path to the affected field, e.g. "metrics[0].name"; empty for the +1049 metadata as a whole. +1050 message (str): Explanation of the problem. +1051 """ +1052 +1053 severity: MetadataIssueSeverity +1054 location: str = Field( +1055 ..., +1056 description="Where the issue occurs, e.g. metrics[0].value_dimensions[1].column in the" +1057 " checked metadata. Empty for the checked object as a whole.", +1058 ) +1059 message: str
+ +1060 +1061 +
+[docs] +1062class MetadataConventionError(ValueError): +1063 """Raised when required metadata is missing, invalid or of the wrong type. +1064 +1065 Attributes: +1066 issues (list[MetadataConventionIssue]): Individual problems with severity, location +1067 and message. +1068 """ +1069 +1070 def __init__(self, issues: list[MetadataConventionIssue]) -> None: +1071 self.issues = issues +1072 super().__init__( +1073 "Violates the metadata conventions: " +1074 + _listing([f"{issue.location}: {issue.message}" for issue in issues], 5) +1075 )
+ +1076 +1077 +1078def _error(location: str, message: str) -> MetadataConventionIssue: +1079 return MetadataConventionIssue( +1080 severity=MetadataIssueSeverity.ERROR, location=location, message=message +1081 ) +1082 +1083 +1084def _warning(location: str, message: str) -> MetadataConventionIssue: +1085 return MetadataConventionIssue( +1086 severity=MetadataIssueSeverity.WARNING, location=location, message=message +1087 ) +1088 +1089 +1090def _location(loc: tuple[int | str, ...]) -> str: +1091 """Format a pydantic error location like metrics[0].value_dimensions[1].column""" +1092 location = "" +1093 for part in loc: +1094 if isinstance(part, int): +1095 location += f"[{part}]" +1096 else: +1097 location += f".{part}" if location != "" else part +1098 return location +1099 +1100 +1101def _listing(items: list[str], max_items: int = 10) -> str: +1102 listed = ", ".join(f'"{item}"' for item in items[:max_items]) +1103 if len(items) > max_items: +1104 listed += f" and {len(items) - max_items} more" +1105 return listed +1106 +1107 +1108def _check_duplicate_columns( +1109 value_dimensions: list[dict[str, Any]], location: str +1110) -> list[MetadataConventionIssue]: +1111 counts = Counter(value_dimension["column"] for value_dimension in value_dimensions) +1112 return [ +1113 _error(location, f'Value dimension column "{column}" occurs more than once.') +1114 for column, count in counts.items() +1115 if count > 1 +1116 ] +1117 +1118 +1119def _check_interval( +1120 obj: dict[str, Any], location: str, start_key: str, end_key: str +1121) -> list[MetadataConventionIssue]: +1122 """Check an interval given by start and end fields of the object at location""" +1123 start = obj.get(start_key) +1124 end = obj.get(end_key) +1125 if (start is None) != (end is None): +1126 missing_key = start_key if start is None else end_key +1127 return [ +1128 _error( +1129 f"{location}.{missing_key}", +1130 f"Start and end of an interval must be provided together, but {missing_key}" +1131 " is missing.", +1132 ) +1133 ] +1134 if start is not None and end is not None and pd.Timestamp(start) > pd.Timestamp(end): +1135 return [_error(f"{location}.{start_key}", f"Start is after end {end}.")] +1136 return [] +1137 +1138 +1139@lru_cache(maxsize=1024) +1140def _is_frequency(value: str) -> bool: +1141 """Whether value is a Pandas date offset alias or a timedelta string +1142 +1143 A unit is required, since e.g. "1" would be a timedelta string of 1 nanosecond. +1144 """ +1145 if re.search("[A-Za-z]", value) is None: +1146 return False +1147 try: +1148 pd.tseries.frequencies.to_offset(value) +1149 except ValueError: +1150 try: +1151 # e.g. "nan" gives NaT +1152 return bool(pd.notna(pd.to_timedelta(value))) +1153 except ValueError: +1154 return False +1155 return True +1156 +1157 +1158@lru_cache(maxsize=1024) +1159def _duration(value: str) -> pd.Timedelta | None: +1160 """The fixed duration of a frequency, None if calendar based like "MS" or no frequency""" +1161 try: +1162 offset = pd.tseries.frequencies.to_offset(value) +1163 except ValueError: +1164 try: +1165 # e.g. "P1D" or "1 days" +1166 duration = pd.to_timedelta(value) +1167 except ValueError: +1168 return None +1169 return None if pd.isna(duration) else duration +1170 if isinstance(offset, pd.offsets.Day): +1171 # a calendar day since pandas 3, i.e. 24 hours in UTC +1172 return pd.Timedelta(days=offset.n) +1173 if isinstance(offset, pd.offsets.Tick): +1174 return pd.Timedelta(offset) +1175 # calendar based, e.g. months +1176 return None +1177 +1178 +1179@lru_cache(maxsize=1024) +1180def _offset_count(value: str) -> int: +1181 """The count n of a calendar based frequency, e.g. 3 for "3MS\"""" +1182 return int(pd.tseries.frequencies.to_offset(value).n) +1183 +1184 +1185def _not_a_frequency(location: str, value: str) -> MetadataConventionIssue: +1186 return _error( +1187 location, +1188 f'"{value}" is not a Pandas date offset alias or timedelta string, e.g. "15min".', +1189 ) +1190 +1191 +1192def _check_frequency( +1193 obj: dict[str, Any], field: str, location: str +1194) -> list[MetadataConventionIssue]: +1195 """Check that a frequency field is a positive frequency""" +1196 value = obj.get(field) +1197 if value is None: +1198 return [] +1199 if not _is_frequency(value): +1200 return [_not_a_frequency(f"{location}.{field}", value)] +1201 duration = _duration(value) +1202 positive = duration > pd.Timedelta(0) if duration is not None else _offset_count(value) > 0 +1203 return [] if positive else [_error(f"{location}.{field}", f'"{value}" is not positive.')] +1204 +1205 +1206def _check_offset( +1207 obj: dict[str, Any], frequency_field: str, offset_field: str, location: str +1208) -> list[MetadataConventionIssue]: +1209 """Check the offset of the timestamps against the intervals of a valid frequency""" +1210 offset = obj[offset_field] +1211 offset_location = f"{location}.{offset_field}" +1212 if not _is_frequency(offset): +1213 return [_not_a_frequency(offset_location, offset)] +1214 offset_duration = _duration(offset) +1215 if offset_duration is None or offset_duration < pd.Timedelta(0): +1216 return [ +1217 _error(offset_location, f'"{offset}" is not a non-negative timedelta, e.g. "4min".') +1218 ] +1219 # None for calendar based frequencies, which offsets cannot be compared with +1220 frequency_duration = _duration(obj[frequency_field]) +1221 if frequency_duration is not None and offset_duration >= frequency_duration: +1222 return [ +1223 _error( +1224 offset_location, +1225 f'"{offset}" is not smaller than {frequency_field} "{obj[frequency_field]}".', +1226 ) +1227 ] +1228 return [] +1229 +1230 +1231def _check_frequency_with_alignment( +1232 obj: dict[str, Any], frequency_field: str, offset_field: str, timezone_field: str, location: str +1233) -> list[MetadataConventionIssue]: +1234 """Check a frequency and the fields aligning its intervals, the offset and the time zone +1235 +1236 They are only meaningful together with the frequency on the same level, since the frequency +1237 of a metric falls back to the one of the dataset metadata as a whole. +1238 """ +1239 issues = _check_frequency(obj, frequency_field, location) +1240 valid_frequency = len(issues) == 0 and obj.get(frequency_field) is not None +1241 for field in (offset_field, timezone_field): +1242 if obj.get(field) is not None and obj.get(frequency_field) is None: +1243 issues.append(_error(f"{location}.{field}", f"Requires {frequency_field}.")) +1244 issues += _check_timezone(obj, location, timezone_field) +1245 if obj.get(offset_field) is not None and valid_frequency: +1246 issues += _check_offset(obj, frequency_field, offset_field, location) +1247 return issues +1248 +1249 +1250_UTC_OFFSET_PATTERN = re.compile(r"^[+-](0[0-9]|1[0-4]):[0-5][0-9]$") +1251 +1252 +1253@cache +1254def _timezone_names() -> frozenset[str]: +1255 """The IANA time zone names of the tzdata package, independent of the system +1256 +1257 Not zoneinfo.available_timezones, which also lists the files of the system's time zone +1258 database, e.g. "localtime" on Debian or Ubuntu, which is no time zone name. +1259 """ +1260 with resources.files("tzdata").joinpath("zones").open(encoding="utf-8") as zones: +1261 return frozenset(line.strip() for line in zones if line.strip() != "") +1262 +1263 +1264def _is_timezone(value: str) -> bool: +1265 """Whether value is an IANA time zone name or a fixed UTC offset, e.g. "+02:00" """ +1266 return _UTC_OFFSET_PATTERN.match(value) is not None or value in _timezone_names() +1267 +1268 +1269def _check_timezone( +1270 obj: dict[str, Any], location: str, field: str = "default_timezone" +1271) -> list[MetadataConventionIssue]: +1272 timezone = obj.get(field) +1273 if timezone is None or _is_timezone(timezone): +1274 return [] +1275 return [ +1276 _error( +1277 f"{location}.{field}", +1278 f'"{timezone}" is neither an IANA time zone name like "Europe/Berlin" nor a UTC' +1279 ' offset like "+02:00".', +1280 ) +1281 ] +1282 +1283 +1284def _check_recording( +1285 metric: dict[str, Any], dataset_metadata: dict[str, Any], location: str +1286) -> list[MetadataConventionIssue]: +1287 """Check that the recording information of a metric fits its recording mode""" +1288 issues = [] +1289 recording_mode = metric.get("recording_mode") +1290 if ( +1291 metric.get("recording_aggregation_method") is not None +1292 and recording_mode != RecordingMode.PERIODIC +1293 ): +1294 issues.append( +1295 _error( +1296 f"{location}.recording_aggregation_method", +1297 "Only meaningful for recording_mode periodic.", +1298 ) +1299 ) +1300 if ( +1301 recording_mode == RecordingMode.PERIODIC +1302 and metric.get("data_frequency") is None +1303 and dataset_metadata.get("ref_data_frequency") is None +1304 ): +1305 issues.append( +1306 _error( +1307 f"{location}.recording_mode", +1308 "periodic requires data_frequency or dataset_metadata.ref_data_frequency, the" +1309 " interval of the values.", +1310 ) +1311 ) +1312 if recording_mode != RecordingMode.ON_CHANGE: +1313 issues += [ +1314 _warning( +1315 f"{location}.value_dimensions[{vd_index}].deadband", +1316 "Only meaningful if the recording_mode of the metric is on_change.", +1317 ) +1318 for vd_index, value_dimension in enumerate(metric["value_dimensions"]) +1319 if value_dimension.get("deadband") is not None +1320 ] +1321 return issues +1322 +1323 +1324def _known_fields(model: type[BaseModel]) -> set[str]: +1325 return set(model.model_fields) +1326 +1327 +1328# hints for fields placed where the conventions do not expect them +1329_MISPLACED_FIELD_HINTS: dict[tuple[type[BaseModel], str], str] = { +1330 (MetricMetadata, "unit"): 'Provide the unit of the value dimension "value" under' +1331 " value_dimensions instead.", +1332 (MetricMetadata, "measurement"): 'Provide the measurement of the value dimension "value"' +1333 " under value_dimensions instead.", +1334} +1335 +1336 +1337def _unknown_field_issues( +1338 obj: dict[str, Any], model: type[BaseModel], location: str, ignored: set[str] | None = None +1339) -> list[MetadataConventionIssue]: +1340 """Warnings for fields not defined by the conventions, which may be misspelled""" +1341 known = _known_fields(model) +1342 issues = [] +1343 for field in obj: +1344 if field in known or (ignored is not None and field in ignored): +1345 continue +1346 hint = _MISPLACED_FIELD_HINTS.get((model, field)) +1347 if hint is None: +1348 snake_case = re.sub(r"(?<!^)(?=[A-Z])", "_", field).lower() +1349 close_matches = ( +1350 [snake_case] +1351 if snake_case in known +1352 else difflib.get_close_matches(field, known, n=1, cutoff=0.8) +1353 ) +1354 hint = ( +1355 f'Did you mean "{close_matches[0]}"?' +1356 if len(close_matches) > 0 +1357 else "Additional fields are allowed, but make sure it is not misspelled." +1358 ) +1359 issues.append( +1360 _warning( +1361 f"{location}.{field}" if location != "" else field, +1362 f'Unknown field "{field}". {hint}', +1363 ) +1364 ) +1365 return issues +1366 +1367 +1368def _dicts(value: Any) -> list[tuple[int, dict[str, Any]]]: +1369 """The dicts in value with their index, if value is a list""" +1370 if not isinstance(value, list): +1371 return [] +1372 return [(index, item) for index, item in enumerate(value) if isinstance(item, dict)] +1373 +1374 +1375def _check_unknown_fields( +1376 attrs: Any, model: type[_TimeseriesMetadataBase] +1377) -> list[MetadataConventionIssue]: +1378 """Warnings for fields not defined by the conventions +1379 +1380 Also applied to structurally invalid metadata as far as possible, since a misspelled field +1381 often explains a missing required one. +1382 """ +1383 if not isinstance(attrs, dict): +1384 return [] +1385 issues = _unknown_field_issues(attrs, model, "") +1386 +1387 dataset_metadata = attrs.get("dataset_metadata") +1388 metric_key = None +1389 if isinstance(dataset_metadata, dict): +1390 metric_key = dataset_metadata.get("metric_key") +1391 dataset_metadata_model = model.model_fields["dataset_metadata"].annotation +1392 assert isinstance(dataset_metadata_model, type) # for mypy # noqa: S101 +1393 issues += _unknown_field_issues( +1394 dataset_metadata, dataset_metadata_model, "dataset_metadata" +1395 ) +1396 if isinstance(dataset_metadata.get("signal_group"), dict): +1397 issues += _unknown_field_issues( +1398 dataset_metadata["signal_group"], +1399 SignalGroupMetadata, +1400 "dataset_metadata.signal_group", +1401 ) +1402 +1403 for index, value_dimension in _dicts(attrs.get("value_dimensions_shared")): +1404 issues += _unknown_field_issues( +1405 value_dimension, SharedValueDimensionMetadata, f"value_dimensions_shared[{index}]" +1406 ) +1407 for index, metric in _dicts(attrs.get("metrics")): +1408 location = f"metrics[{index}]" +1409 # the field identifying the metric may be any field +1410 issues += _unknown_field_issues(metric, MetricMetadata, location, ignored={str(metric_key)}) +1411 for vd_index, value_dimension in _dicts(metric.get("value_dimensions")): +1412 issues += _unknown_field_issues( +1413 value_dimension, ValueDimensionMetadata, f"{location}.value_dimensions[{vd_index}]" +1414 ) +1415 for comment_index, comment in _dicts(metric.get("comments")): +1416 comment_location = f"{location}.comments[{comment_index}]" +1417 issues += _unknown_field_issues(comment, MetricComment, comment_location) +1418 for reply_index, reply in _dicts(comment.get("replies")): +1419 issues += _unknown_field_issues( +1420 reply, MetricCommentReply, f"{comment_location}.replies[{reply_index}]" +1421 ) +1422 return issues +1423 +1424 +1425_DEPRECATED_METRIC_FIELDS = { +1426 name: field.deprecated +1427 for name, field in MetricMetadata.model_fields.items() +1428 if field.deprecated is not None +1429} +1430 +1431 +1432def _check_consistency(attrs: dict[str, Any]) -> list[MetadataConventionIssue]: +1433 """Check the rules of the conventions which the JSON Schema cannot express +1434 +1435 Expects attrs to be valid against the JSON Schema of the data type. +1436 """ +1437 issues: list[MetadataConventionIssue] = [] +1438 dataset_metadata = attrs["dataset_metadata"] +1439 metrics = attrs["metrics"] +1440 metric_key = dataset_metadata["metric_key"] +1441 +1442 metric_ids = [] +1443 for index, metric in enumerate(metrics): +1444 metric_id = metric.get(metric_key) +1445 if not isinstance(metric_id, str): +1446 issues.append( +1447 _error( +1448 f"metrics[{index}]", +1449 f'Metric has no string field "{metric_key}", which identifies metrics' +1450 " according to dataset_metadata.metric_key.", +1451 ) +1452 ) +1453 else: +1454 metric_ids.append(metric_id) +1455 issues += [ +1456 _warning(f"metrics[{index}].{name}", f"Deprecated. {deprecated}") +1457 for name, deprecated in _DEPRECATED_METRIC_FIELDS.items() +1458 if metric.get(name) is not None +1459 ] +1460 issues += _check_duplicate_columns( +1461 metric["value_dimensions"], f"metrics[{index}].value_dimensions" +1462 ) +1463 issues += _check_frequency_with_alignment( +1464 metric, +1465 "data_frequency", +1466 "data_frequency_offset", +1467 "data_frequency_timezone", +1468 f"metrics[{index}]", +1469 ) +1470 issues += _check_timezone(metric, f"metrics[{index}]") +1471 issues += _check_recording(metric, dataset_metadata, f"metrics[{index}]") +1472 for comment_index, comment in enumerate(metric.get("comments") or []): +1473 issues += _check_interval( +1474 comment, f"metrics[{index}].comments[{comment_index}]", "start_date", "end_date" +1475 ) +1476 issues += [ +1477 _error("metrics", f'Metric "{metric_id}" occurs more than once.') +1478 for metric_id, count in Counter(metric_ids).items() +1479 if count > 1 +1480 ] +1481 issues += _check_duplicate_columns( +1482 attrs.get("value_dimensions_shared") or [], "value_dimensions_shared" +1483 ) +1484 +1485 single_metric = dataset_metadata.get("single_metric") +1486 if single_metric is not None and len(metrics) > 1: +1487 issues.append( +1488 _error( +1489 "dataset_metadata.single_metric", +1490 f"Must not be set, since {len(metrics)} metrics are present under metrics.", +1491 ) +1492 ) +1493 elif single_metric is not None and len(metric_ids) == 1 and single_metric not in metric_ids: +1494 issues.append( +1495 _error( +1496 "dataset_metadata.single_metric", +1497 f'"{single_metric}" is not the "{metric_key}" of the metric under metrics.', +1498 ) +1499 ) +1500 elif single_metric is not None and single_metric not in dataset_metadata["queried_metrics"]: +1501 issues.append(_error("dataset_metadata.single_metric", "Missing in queried_metrics.")) +1502 +1503 if dataset_metadata.get("only_invalidate") is True and ( +1504 dataset_metadata.get("invalidate_dataset") is False +1505 ): +1506 issues.append( +1507 _error( +1508 "dataset_metadata.only_invalidate", +1509 "Cannot be true if invalidate_dataset is false.", +1510 ) +1511 ) +1512 +1513 if dataset_metadata.get("ref_dataset_discrete") is not True: +1514 issues += [ +1515 _error( +1516 f"dataset_metadata.{field}", +1517 "Required unless dataset_metadata.ref_dataset_discrete is true.", +1518 ) +1519 for field in REF_INTERVAL_FIELDS +1520 if dataset_metadata.get(field) is None +1521 ] +1522 issues += _check_interval( +1523 dataset_metadata, +1524 "dataset_metadata", +1525 "ref_interval_start_timestamp", +1526 "ref_interval_end_timestamp", +1527 ) +1528 issues += _check_interval( +1529 dataset_metadata, +1530 "dataset_metadata", +1531 "invalidation_interval_start", +1532 "invalidation_interval_end", +1533 ) +1534 issues += _check_frequency_with_alignment( +1535 dataset_metadata, +1536 "ref_data_frequency", +1537 "ref_data_frequency_offset", +1538 "ref_data_frequency_timezone", +1539 "dataset_metadata", +1540 ) +1541 issues += _check_frequency(dataset_metadata, "default_aggregation_interval", "dataset_metadata") +1542 issues += _check_timezone(dataset_metadata, "dataset_metadata") +1543 return issues +1544 +1545 +1546_CLOSED_BY_INTERVAL_TYPE: dict[str, Literal["both", "neither", "left", "right"]] = { +1547 "closed": "both", +1548 "open": "neither", +1549 "left_closed": "left", +1550 "right_open": "left", +1551 "right_closed": "right", +1552 "left_open": "right", +1553} +1554 +1555 +1556def _check_data_in_ref_interval( +1557 dataset_metadata: dict[str, Any], data: pd.Series | pd.DataFrame +1558) -> list[MetadataConventionIssue]: +1559 """Warn if the data has timestamps outside of the ref interval""" +1560 start = dataset_metadata.get("ref_interval_start_timestamp") +1561 end = dataset_metadata.get("ref_interval_end_timestamp") +1562 interval_type = dataset_metadata.get("ref_interval_type") +1563 timestamps = data.index if isinstance(data, pd.Series) else data.get("timestamp") +1564 if ( +1565 dataset_metadata.get("ref_dataset_discrete") is True +1566 or start is None +1567 or end is None +1568 or interval_type is None +1569 or pd.Timestamp(start) > pd.Timestamp(end) +1570 or timestamps is None +1571 or len(timestamps) == 0 +1572 or not pd.api.types.is_datetime64_any_dtype(timestamps) +1573 ): +1574 return [] +1575 ref_interval = pd.Interval( +1576 pd.Timestamp(start), pd.Timestamp(end), closed=_CLOSED_BY_INTERVAL_TYPE[interval_type] +1577 ) +1578 # naive timestamps are considered to be UTC +1579 first, last = ( +1580 timestamp if timestamp.tzinfo is not None else timestamp.tz_localize("UTC") +1581 for timestamp in (timestamps.min(), timestamps.max()) +1582 ) +1583 if first in ref_interval and last in ref_interval: +1584 return [] +1585 return [ +1586 _warning( +1587 "dataset_metadata", +1588 f"The data has timestamps outside of the {interval_type} ref interval: The first is" +1589 f" {first.isoformat()}, the last {last.isoformat()}.", +1590 ) +1591 ] +1592 +1593 +1594def _check_metrics_of_data( +1595 attrs: dict[str, Any], data_type: TimeseriesDataType, data: pd.Series | pd.DataFrame | None +1596) -> list[MetadataConventionIssue]: +1597 """Check that the metrics of the data and the queried metrics are described""" +1598 issues: list[MetadataConventionIssue] = [] +1599 dataset_metadata = attrs["dataset_metadata"] +1600 # metrics without a string id are reported by _check_consistency +1601 metric_ids = { +1602 metric_id +1603 for metric in attrs["metrics"] +1604 if isinstance(metric_id := metric.get(dataset_metadata["metric_key"]), str) +1605 } +1606 data_metrics: list[str] = [] +1607 if data_type == "MULTITSFRAME" and isinstance(data, pd.DataFrame) and "metric" in data.columns: +1608 data_metrics = [str(metric) for metric in data["metric"].unique()] +1609 if len(missing := sorted(set(data_metrics) - metric_ids)) > 0: +1610 issues.append( +1611 _error( +1612 "metrics", +1613 f"Metrics of the metric column missing under metrics: {_listing(missing)}", +1614 ) +1615 ) +1616 queried_metrics = dataset_metadata["queried_metrics"] +1617 if len(unqueried := sorted(set(data_metrics) - set(queried_metrics))) > 0: +1618 issues.append( +1619 _error( +1620 "dataset_metadata.queried_metrics", +1621 f"Metrics of the metric column missing in queried_metrics:" +1622 f" {_listing(unqueried)}", +1623 ) +1624 ) +1625 # queried metrics occurring in the data are reported as missing under metrics above +1626 undescribed_metrics = sorted( +1627 set(dataset_metadata["queried_metrics"]) - metric_ids - set(data_metrics) +1628 ) +1629 if len(undescribed_metrics) > 0: +1630 issues.append( +1631 _warning( +1632 "metrics", +1633 f"Queried metrics without metadata under metrics: {_listing(undescribed_metrics)}", +1634 ) +1635 ) +1636 return issues +1637 +1638 +1639def _check_consistency_with_data_type( +1640 attrs: dict[str, Any], data_type: TimeseriesDataType, data: pd.Series | pd.DataFrame | None +1641) -> list[MetadataConventionIssue]: +1642 """Check the rules specific to the data type and whether the metadata matches the data""" +1643 metrics = attrs["metrics"] +1644 issues = _check_metrics_of_data(attrs, data_type, data) +1645 if data is not None: +1646 issues += _check_data_in_ref_interval(attrs["dataset_metadata"], data) +1647 +1648 # the value columns of frames are only known from their data +1649 value_columns: set[str] | None = None +1650 if data_type == "SERIES": +1651 value_columns = {"value"} +1652 elif isinstance(data, pd.DataFrame): +1653 value_columns = set(data.columns) - ( +1654 {"timestamp", "metric"} if data_type == "MULTITSFRAME" else {"timestamp"} +1655 ) +1656 if value_columns is None: +1657 return issues +1658 +1659 for index, metric in enumerate(metrics): +1660 described_columns = { +1661 value_dimension["column"] for value_dimension in metric["value_dimensions"] +1662 } +1663 if len(undescribed := sorted(value_columns - described_columns)) > 0: +1664 issues.append( +1665 _error( +1666 f"metrics[{index}].value_dimensions", +1667 f"Value columns of the {data_type} without value dimension:" +1668 f" {_listing(undescribed)}", +1669 ) +1670 ) +1671 +1672 value_dimension_lists = [ +1673 (f"metrics[{index}].value_dimensions", metric["value_dimensions"]) +1674 for index, metric in enumerate(metrics) +1675 ] + [("value_dimensions_shared", attrs.get("value_dimensions_shared") or [])] +1676 for location, value_dimensions in value_dimension_lists: +1677 for index, value_dimension in enumerate(value_dimensions): +1678 if value_dimension["column"] not in value_columns: +1679 issues.append( +1680 _warning( +1681 f"{location}[{index}].column", +1682 f'Column "{value_dimension["column"]}" is not a value column of the' +1683 f" {data_type}.", +1684 ) +1685 ) +1686 return issues +1687 +1688 +1689_SKIP_RULES = "skip_rules_of_metadata_conventions" +1690 +1691 +1692def _rule_errors( +1693 data: Any, model: _TimeseriesMetadataBase, data_type: TimeseriesDataType +1694) -> list[MetadataConventionIssue]: +1695 """The errors of the rules which JSON Schema cannot express, for valid structure +1696 +1697 The rules are checked on the input if it is in JSON form, otherwise on the model's dump, e.g. +1698 for input with nested models. +1699 """ +1700 +1701 def errors(attrs: dict[str, Any]) -> list[MetadataConventionIssue]: +1702 issues = _check_consistency(attrs) + _check_consistency_with_data_type( +1703 attrs, data_type, None +1704 ) +1705 return [issue for issue in issues if issue.severity == MetadataIssueSeverity.ERROR] +1706 +1707 if isinstance(data, dict): +1708 try: +1709 return errors(data) +1710 except (AttributeError, KeyError, TypeError): +1711 pass +1712 return errors(model.model_dump(mode="json")) +1713 +1714 +1715def _metric_rule_errors( +1716 meta: _TimeseriesMetadataBase, metric: MetricMetadata +1717) -> list[MetadataConventionIssue]: +1718 """The rule errors concerning a single metric, together with the dataset metadata""" +1719 attrs = { +1720 "dataset_metadata": meta.dataset_metadata.model_dump(mode="json"), +1721 "metrics": [metric.model_dump(mode="json")], +1722 } +1723 if meta.value_dimensions_shared is not None: +1724 attrs["value_dimensions_shared"] = [ +1725 value_dimension.model_dump(mode="json") +1726 for value_dimension in meta.value_dimensions_shared +1727 ] +1728 issues = _check_consistency(attrs) + _check_consistency_with_data_type( +1729 attrs, meta._data_type, None +1730 ) +1731 return [issue for issue in issues if issue.severity == MetadataIssueSeverity.ERROR] +1732 +1733 +
+[docs] +1734def check_metadata_conventions( +1735 obj: pd.Series | pd.DataFrame | Mapping[str, Any], +1736 data_type: TimeseriesDataType | None = None, +1737) -> list[MetadataConventionIssue]: +1738 """Report metadata errors and warnings, optionally checking against pandas data. +1739 +1740 For pandas inputs, also check metric IDs, value columns and time bounds. An empty list +1741 means no issues; warnings alone do not make metadata invalid. +1742 +1743 Args: +1744 obj: Series/DataFrame to check, or a mapping containing metadata. +1745 data_type: +1746 Convention to check; required for mappings. None infers it from pandas input; +1747 see :ref:`metadata-type-selection`. +1748 +1749 Returns: +1750 list[MetadataConventionIssue]: +1751 Issues with severity, location and message. Invalid metadata is reported as +1752 issues. +1753 +1754 Raises: +1755 ValueError: A metadata mapping is provided without data_type. +1756 +1757 Code example: +1758 +1759 Using :ref:`metadata-example-data`, validate the data and its metadata together, or pass +1760 plain metadata with an explicit data type. Issues describe the severity, location and cause: +1761 +1762 .. doctest:: metadata.check_metadata_conventions +1763 +1764 >>> md.check_metadata_conventions(series) +1765 [] +1766 >>> md.check_metadata_conventions(series.attrs, "SERIES") +1767 [] +1768 >>> invalid = series.copy() +1769 >>> invalid.attrs["metrics"][0]["default_timezone"] = "Mars/Base" +1770 >>> issues = md.check_metadata_conventions(invalid) +1771 >>> [(issue.severity.value, issue.location) for issue in issues] +1772 [('error', 'metrics[0].default_timezone')] +1773 >>> print(issues[0].message) # doctest: +ELLIPSIS +1774 "Mars/Base" is neither an IANA time zone name like "Europe/Berlin" nor a UTC offset... +1775 """ +1776 # imported here, since hdhelpers.metadata.attached imports this module +1777 from hdhelpers.metadata.attached import attrs_to_json # noqa: PLC0415 +1778 +1779 data = obj if isinstance(obj, pd.Series | pd.DataFrame) else None +1780 attrs = data.attrs if data is not None else obj +1781 if isinstance(attrs, Mapping): +1782 # attached metadata in its JSON form +1783 attrs = attrs_to_json(attrs) +1784 if data_type is None and isinstance(data, pd.Series): +1785 data_type = "SERIES" +1786 elif data_type is None and isinstance(data, pd.DataFrame): +1787 data_type = "MULTITSFRAME" if "metric" in data.columns else "SINGLETSFRAME" +1788 elif data_type is None: +1789 raise ValueError("The data type is required for checking metadata without data.") +1790 model = METADATA_MODELS[data_type] +1791 +1792 try: +1793 attrs_json = json.dumps(attrs, allow_nan=False) +1794 except (TypeError, ValueError) as e: +1795 return [ +1796 _error( +1797 "", +1798 f"Metadata is not JSON serializable, so it cannot be exchanged with adapters: {e}", +1799 ) +1800 ] +1801 +1802 try: +1803 model.model_validate_json(attrs_json, context={_SKIP_RULES: True}) +1804 except ValidationError as e: +1805 issues = [] +1806 for error in e.errors(): +1807 message = error["msg"].removeprefix("Value error, ") +1808 if (error.get("ctx") or {}).get("pattern") == UTC_TIMESTAMP_PATTERN: +1809 message = ( +1810 "Should be a UTC isoformat timestamp with explicit offset, i.e. ending with Z" +1811 f" or +00:00, got {error['input']!r}." +1812 ) +1813 elif ( +1814 error["loc"] == ("metrics",) +1815 and isinstance(attrs, Mapping) +1816 and isinstance(attrs.get("metrics"), dict) +1817 ): +1818 message += ( +1819 ". Metrics as a mapping is an older metadata convention, the current one" +1820 " expects a list of metric objects." +1821 ) +1822 issues.append(_error(_location(error["loc"]), message)) +1823 return issues + _check_unknown_fields(json.loads(attrs_json), model) +1824 +1825 json_attrs = json.loads(attrs_json) +1826 return ( +1827 _check_consistency(json_attrs) +1828 + _check_consistency_with_data_type(json_attrs, data_type, data) +1829 + _check_unknown_fields(json_attrs, model) +1830 )
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/metadata/helpers.html b/docs/_modules/hdhelpers/metadata/helpers.html new file mode 100644 index 0000000..f5145e1 --- /dev/null +++ b/docs/_modules/hdhelpers/metadata/helpers.html @@ -0,0 +1,1064 @@ + + + + + + + hdhelpers.metadata.helpers — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.metadata.helpers

+  1"""Functions accessing the metadata of SERIES, SINGLETSFRAMEs and MULTITSFRAMEs
+  2
+  3The functions are thin wrappers over the methods of the models of the metadata conventions,
+  4see read_metadata. The methods resolve the fallbacks of the conventions, e.g. a missing display
+  5name falls back to the name, see hdhelpers.metadata._reading.
+  6
+  7The metadata is the model attached to the object (see attach_metadata), whatever data type it
+  8belongs to, e.g. the metadata of a SINGLETSFRAME on a Series taken from it. Otherwise the
+  9metadata in attrs is parsed as the data type of the function. Metadata which does not follow
+ 10the conventions gives no information: None or empty defaultdicts. For a SERIES or SINGLETSFRAME,
+ 11the single metric is the one named by dataset_metadata.single_metric, otherwise the only one.
+ 12
+ 13Fields of the functions taking one can be dotted paths, e.g. "inherited.site".
+ 14"""
+ 15
+ 16import datetime
+ 17from collections import defaultdict
+ 18from collections.abc import Callable
+ 19from typing import Any
+ 20
+ 21import pandas as pd
+ 22
+ 23from hdhelpers.metadata._reading import (
+ 24    DISPLAY_NAME_FIELDS,
+ 25    SHORT_DISPLAY_NAME_FIELDS,
+ 26    Recording,
+ 27    dataset_default_timezone,
+ 28    dataset_recording,
+ 29)
+ 30from hdhelpers.metadata.attached import META_KEY, AnyMetadata, infer_data_type, read_metadata
+ 31from hdhelpers.metadata.conventions import TimeseriesDataType, _TimeseriesMetadataBase
+ 32
+ 33_DEFAULT_AGGREGATION_METHOD = "MEAN"
+ 34
+ 35
+ 36def check_dataframe(data: pd.DataFrame) -> None:
+ 37    if not isinstance(data, pd.DataFrame):
+ 38        raise TypeError("Input is not a pd.DataFrame")
+ 39
+ 40
+ 41def check_series(series: pd.Series) -> None:
+ 42    if not isinstance(series, pd.Series):
+ 43        raise TypeError("Input is not a pd.Series.")
+ 44
+ 45
+ 46def check_series_or_dataframe(data: pd.Series | pd.DataFrame) -> None:
+ 47    if not isinstance(data, pd.Series) and not isinstance(data, pd.DataFrame):
+ 48        raise TypeError("Input is not a pd.Series or pd.DataFrame.")
+ 49
+ 50
+ 51def _metadata(data: pd.Series | pd.DataFrame, data_type: TimeseriesDataType) -> AnyMetadata | None:
+ 52    """The attached metadata of any data type, otherwise the attrs parsed as data_type"""
+ 53    attached = data.attrs.get(META_KEY)
+ 54    if isinstance(attached, _TimeseriesMetadataBase):
+ 55        return attached  # type: ignore[return-value]
+ 56    return read_metadata(data, data_type)
+ 57
+ 58
+ 59def _single_metric(meta: AnyMetadata | None) -> str | None:
+ 60    """The single metric: dataset_metadata.single_metric if described, otherwise the only one"""
+ 61    if meta is None:
+ 62        return None
+ 63    metric_ids = meta.metric_ids()
+ 64    single_metric = meta.dataset_metadata.single_metric
+ 65    if single_metric is not None:
+ 66        return single_metric if single_metric in metric_ids else None
+ 67    return metric_ids[0] if len(metric_ids) == 1 else None
+ 68
+ 69
+ 70def _by_metric_by_column(
+ 71    data: pd.DataFrame, fields: str | tuple[str, ...], include_inherited: bool
+ 72) -> defaultdict[str, defaultdict[str, Any]]:
+ 73    check_dataframe(data)
+ 74    meta = _metadata(data, "MULTITSFRAME")
+ 75    infos = (
+ 76        {}
+ 77        if meta is None
+ 78        else meta.value_dimension_infos(fields, include_inherited=include_inherited)
+ 79    )
+ 80    return defaultdict(
+ 81        lambda: defaultdict(lambda: None),
+ 82        {metric: defaultdict(lambda: None, info) for metric, info in infos.items()},
+ 83    )
+ 84
+ 85
+ 86def _single_metric_by_column(
+ 87    data: pd.DataFrame, fields: str | tuple[str, ...], include_inherited: bool
+ 88) -> defaultdict[str, Any]:
+ 89    check_dataframe(data)
+ 90    meta = _metadata(data, "SINGLETSFRAME")
+ 91    metric = _single_metric(meta)
+ 92    if meta is None or metric is None:
+ 93        return defaultdict(lambda: None)
+ 94    return defaultdict(
+ 95        lambda: None,
+ 96        meta.value_dimension_infos(fields, include_inherited=include_inherited)[metric],
+ 97    )
+ 98
+ 99
+100def _series_value(series: pd.Series, fields: str | tuple[str, ...], include_inherited: bool) -> Any:
+101    check_series(series)
+102    meta = _metadata(series, "SERIES")
+103    metric = _single_metric(meta)
+104    if meta is None or metric is None:
+105        return None
+106    return meta.value_dimension_info(fields, metric, "value", include_inherited=include_inherited)
+107
+108
+109def _single_metric_or_dataset(
+110    data: pd.Series | pd.DataFrame,
+111    data_type: TimeseriesDataType,
+112    of_metric: Callable[[AnyMetadata, str], Any],
+113    of_dataset: Callable[[AnyMetadata], Any],
+114    empty: Any,
+115) -> Any:
+116    """Information of the single metric, of the dataset metadata if the metric is not found"""
+117    meta = _metadata(data, data_type)
+118    if meta is None:
+119        return empty
+120    metric = _single_metric(meta)
+121    return of_dataset(meta) if metric is None else of_metric(meta, metric)
+122
+123
+124# value dimensions of MULTITSFRAMEs
+125
+126
+
+[docs] +127def get_units( +128 multitsframe: pd.DataFrame, *, include_inherited: bool = False +129) -> defaultdict[str, defaultdict[str, str | None]]: +130 """Read units for each metric and value column. +131 +132 Args: +133 multitsframe: DataFrame with per-metric metadata in attrs. +134 include_inherited: Use inherited fields last, only for the "value" column. +135 +136 Returns: +137 defaultdict[str, defaultdict[str, str | None]]: +138 Units as result[metric_id][column]; unavailable units are None. +139 +140 Code example: +141 +142 .. doctest:: metadata.get_units +143 +144 >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE +145 >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE +146 >>> units = md.get_units(multitsframe) +147 >>> units["plant_a.temperature"]["value"] +148 '°C' +149 >>> units["unknown metric"]["value"] is None +150 True +151 +152 See :ref:`metadata-getter-values` for shared examples and return shapes. +153 """ # noqa: E501 +154 return _by_metric_by_column(multitsframe, "unit", include_inherited)
+ +155 +156 +
+[docs] +157def get_names( +158 multitsframe: pd.DataFrame, *, include_inherited: bool = False +159) -> defaultdict[str, defaultdict[str, str | None]]: +160 """Read metadata names for each metric and value column. +161 +162 For the "value" column, try the metric's name after the column's own name. +163 +164 Args: +165 multitsframe: DataFrame with per-metric metadata in attrs. +166 include_inherited: Use inherited fields last, only for the "value" column. +167 +168 Returns: +169 defaultdict[str, defaultdict[str, str | None]]: +170 Names as result[metric_id][column]; unavailable names are None. +171 +172 Code example: +173 +174 .. doctest:: metadata.get_names +175 +176 >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE +177 >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE +178 >>> md.get_names(multitsframe)["plant_a.pressure"]["value"] +179 'Plant A pressure' +180 +181 See :ref:`metadata-getter-names` for shared examples and return shapes. +182 """ +183 return _by_metric_by_column(multitsframe, "name", include_inherited)
+ +184 +185 +
+[docs] +186def get_display_names( +187 multitsframe: pd.DataFrame, *, include_inherited: bool = False +188) -> defaultdict[str, defaultdict[str, str | None]]: +189 """Read display labels for each metric and value column. +190 +191 Use display_name, then name. +192 +193 Args: +194 multitsframe: DataFrame with per-metric metadata in attrs. +195 include_inherited: Use inherited fields last, only for the "value" column. +196 +197 Returns: +198 defaultdict[str, defaultdict[str, str | None]]: +199 Labels as result[metric_id][column]; unavailable labels are None. +200 +201 See :ref:`metadata-getter-names` for shared examples and return shapes. +202 """ +203 return _by_metric_by_column(multitsframe, DISPLAY_NAME_FIELDS, include_inherited)
+ +204 +205 +
+[docs] +206def get_short_display_names( +207 multitsframe: pd.DataFrame, *, include_inherited: bool = False +208) -> defaultdict[str, defaultdict[str, str | None]]: +209 """Read short display labels for each metric and value column. +210 +211 Use short_display_name, then display_name, then name. +212 +213 Args: +214 multitsframe: DataFrame with per-metric metadata in attrs. +215 include_inherited: Use inherited fields last, only for the "value" column. +216 +217 Returns: +218 defaultdict[str, defaultdict[str, str | None]]: +219 Labels as result[metric_id][column]; unavailable labels are None. +220 +221 See :ref:`metadata-getter-names` for shared examples and return shapes. +222 """ +223 return _by_metric_by_column(multitsframe, SHORT_DISPLAY_NAME_FIELDS, include_inherited)
+ +224 +225 +
+[docs] +226def get_measurements( +227 multitsframe: pd.DataFrame, *, include_inherited: bool = False +228) -> defaultdict[str, defaultdict[str, str | None]]: +229 """Read physical quantities for each metric and value column. +230 +231 For example, "temperature" is a measurement and "°C" is its unit. +232 +233 Args: +234 multitsframe: DataFrame with per-metric metadata in attrs. +235 include_inherited: Use inherited fields last, only for the "value" column. +236 +237 Returns: +238 defaultdict[str, defaultdict[str, str | None]]: +239 Quantities as result[metric_id][column]; unavailable quantities are None. +240 +241 See :ref:`metadata-getter-values` for shared examples and return shapes. +242 """ +243 return _by_metric_by_column(multitsframe, "measurement", include_inherited)
+ +244 +245 +
+[docs] +246def get_metric_info( +247 multitsframe: pd.DataFrame, metric_info: str, *, include_inherited: bool = True +248) -> defaultdict[str, Any]: +249 """Read a metadata field for each metric. +250 +251 Search the metric, its "value" column, shared "value" metadata, then inherited fields +252 (if enabled). +253 +254 Args: +255 multitsframe: DataFrame with per-metric metadata in attrs. +256 metric_info: Field name or dotted path, e.g. "sensor.model". +257 include_inherited: Use inherited fields as a last fallback. +258 +259 Returns: +260 defaultdict[str, Any]: Field values by metric ID; unresolved fields are None. +261 +262 Code example: +263 +264 .. doctest:: metadata.get_metric_info +265 +266 >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE +267 >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE +268 >>> md.get_metric_info(multitsframe, "recording_mode")["plant_a.temperature"] +269 'on_change' +270 +271 See :ref:`metadata-getter-fields` for shared examples and return shapes. +272 """ +273 check_dataframe(multitsframe) +274 meta = _metadata(multitsframe, "MULTITSFRAME") +275 infos = ( +276 {} if meta is None else meta.metric_infos(metric_info, include_inherited=include_inherited) +277 ) +278 return defaultdict(lambda: None, infos)
+ +279 +280 +281# the value dimension "value" of SERIES +282 +283 +
+[docs] +284def get_series_info( +285 series: pd.Series, value_dim_info: str, *, include_inherited: bool = True +286) -> Any: +287 """Read a metadata field for this Series. +288 +289 Search the "value" column, the metric, shared "value" metadata, then inherited fields +290 (if enabled). +291 +292 Args: +293 series: Series with metadata in attrs. +294 value_dim_info: Field name or dotted path, e.g. "sensor.model". +295 include_inherited: Use inherited fields as a last fallback. +296 +297 Returns: +298 Any: Field value, or None when unavailable. +299 +300 Code example: +301 +302 .. doctest:: metadata.get_series_info +303 +304 >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE +305 >>> series.attrs = SERIES_METADATA_EXAMPLE +306 >>> md.get_series_info(series, "decimals") +307 2 +308 +309 See :ref:`metadata-getter-fields` for shared examples and return shapes. +310 """ +311 return _series_value(series, value_dim_info, include_inherited)
+ +312 +313 +
+[docs] +314def get_series_unit(series: pd.Series, *, include_inherited: bool = False) -> str | None: +315 """Read this Series' unit from metadata. +316 +317 Args: +318 series: Series with metadata in attrs. +319 include_inherited: Use inherited fields as a last fallback. +320 +321 Returns: +322 str | None: Unit string; "" means dimensionless and None means unavailable. +323 +324 Code example: +325 +326 .. doctest:: metadata.get_series_unit +327 +328 >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE +329 >>> series.attrs = SERIES_METADATA_EXAMPLE +330 >>> md.get_series_unit(series) +331 'm' +332 +333 See :ref:`metadata-getter-values` for shared examples and return shapes. +334 """ +335 return _series_value(series, "unit", include_inherited) # type: ignore[no-any-return]
+ +336 +337 +
+[docs] +338def get_series_name(series: pd.Series, *, include_inherited: bool = False) -> str | None: +339 """Read this Series' name from metadata. +340 +341 Try the "value" column's name, then the metric's name. +342 +343 Args: +344 series: Series with metadata in attrs. +345 include_inherited: Use inherited fields as a last fallback. +346 +347 Returns: +348 str | None: Resolved name, or None when unavailable. +349 +350 Code example: +351 +352 .. doctest:: metadata.get_series_name +353 +354 >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE +355 >>> series.attrs = SERIES_METADATA_EXAMPLE +356 >>> md.get_series_name(series) +357 'Water level north' +358 +359 See :ref:`metadata-getter-names` for shared examples and return shapes. +360 """ +361 return _series_value(series, "name", include_inherited) # type: ignore[no-any-return]
+ +362 +363 +
+[docs] +364def get_series_display_name(series: pd.Series, *, include_inherited: bool = False) -> str | None: +365 """Read the display label for this Series. +366 +367 Use display_name, then name. +368 +369 Args: +370 series: Series with metadata in attrs. +371 include_inherited: Use inherited fields as a last fallback. +372 +373 Returns: +374 str | None: Resolved label, or None when unavailable. +375 +376 See :ref:`metadata-getter-names` for shared examples and return shapes. +377 """ +378 return _series_value( # type: ignore[no-any-return] +379 series, DISPLAY_NAME_FIELDS, include_inherited +380 )
+ +381 +382 +
+[docs] +383def get_series_short_display_name( +384 series: pd.Series, *, include_inherited: bool = False +385) -> str | None: +386 """Read the short display label for this Series. +387 +388 Use short_display_name, then display_name, then name. +389 +390 Args: +391 series: Series with metadata in attrs. +392 include_inherited: Use inherited fields as a last fallback. +393 +394 Returns: +395 str | None: Resolved label, or None when unavailable. +396 +397 See :ref:`metadata-getter-names` for shared examples and return shapes. +398 """ +399 return _series_value( # type: ignore[no-any-return] +400 series, SHORT_DISPLAY_NAME_FIELDS, include_inherited +401 )
+ +402 +403 +
+[docs] +404def get_series_measurement(series: pd.Series, *, include_inherited: bool = False) -> str | None: +405 """Read this Series' physical quantity from metadata. +406 +407 For example, "temperature" describes the quantity and "°C" describes its unit. +408 +409 Args: +410 series: Series with metadata in attrs. +411 include_inherited: Use inherited fields as a last fallback. +412 +413 Returns: +414 str | None: Quantity name, or None when unavailable. +415 +416 See :ref:`metadata-getter-values` for shared examples and return shapes. +417 """ +418 return _series_value(series, "measurement", include_inherited) # type: ignore[no-any-return]
+ +419 +420 +421# value dimensions of SINGLETSFRAMEs +422 +423 +
+[docs] +424def get_singlets_info( +425 singletsframe: pd.DataFrame, value_dim_info: str, *, include_inherited: bool = True +426) -> defaultdict[str, Any]: +427 """Read a metadata field for each value column of a single-metric frame. +428 +429 For "value", search column, metric, shared and inherited metadata (if enabled), in that +430 order. Other columns use their own and shared metadata. +431 +432 Args: +433 singletsframe: DataFrame with one metric's metadata in attrs. +434 value_dim_info: Field name or dotted path, e.g. "sensor.model". +435 include_inherited: Use inherited fields last, only for the "value" column. +436 +437 Returns: +438 defaultdict[str, Any]: Field values as result[column]; unresolved fields are None. +439 +440 Code example: +441 +442 .. doctest:: metadata.get_singlets_info +443 +444 >>> from hdhelpers.metadata.conventions import SINGLETSFRAME_METADATA_EXAMPLE +445 >>> dataframe.attrs = SINGLETSFRAME_METADATA_EXAMPLE +446 >>> md.get_singlets_info(dataframe, "name")["state"] +447 'measurement state' +448 +449 See :ref:`metadata-getter-fields` for shared examples and return shapes. +450 """ +451 return _single_metric_by_column(singletsframe, value_dim_info, include_inherited)
+ +452 +453 +
+[docs] +454def get_singlets_units( +455 singletsframe: pd.DataFrame, *, include_inherited: bool = False +456) -> defaultdict[str, str | None]: +457 """Read units for each value column of a single-metric frame. +458 +459 Args: +460 singletsframe: DataFrame with one metric's metadata in attrs. +461 include_inherited: Use inherited fields last, only for the "value" column. +462 +463 Returns: +464 defaultdict[str, str | None]: +465 Units as result[column]; "" means dimensionless and None means unavailable. +466 +467 Code example: +468 +469 .. doctest:: metadata.get_singlets_units +470 +471 >>> from hdhelpers.metadata.conventions import SINGLETSFRAME_METADATA_EXAMPLE +472 >>> dataframe.attrs = SINGLETSFRAME_METADATA_EXAMPLE +473 >>> md.get_singlets_units(dataframe)["value"] +474 '°C' +475 +476 See :ref:`metadata-getter-values` for shared examples and return shapes. +477 """ +478 return _single_metric_by_column(singletsframe, "unit", include_inherited)
+ +479 +480 +
+[docs] +481def get_singlets_names( +482 singletsframe: pd.DataFrame, *, include_inherited: bool = False +483) -> defaultdict[str, str | None]: +484 """Read metadata names for each value column of a single-metric frame. +485 +486 For "value", try the metric's name after the column's own name. +487 +488 Args: +489 singletsframe: DataFrame with one metric's metadata in attrs. +490 include_inherited: Use inherited fields last, only for the "value" column. +491 +492 Returns: +493 defaultdict[str, str | None]: Names as result[column]; unavailable names are None. +494 +495 See :ref:`metadata-getter-names` for shared examples and return shapes. +496 """ +497 return _single_metric_by_column(singletsframe, "name", include_inherited)
+ +498 +499 +
+[docs] +500def get_singlets_display_names( +501 singletsframe: pd.DataFrame, *, include_inherited: bool = False +502) -> defaultdict[str, str | None]: +503 """Read display labels for each value column of a single-metric frame. +504 +505 Use display_name, then name. +506 +507 Args: +508 singletsframe: DataFrame with one metric's metadata in attrs. +509 include_inherited: Use inherited fields last, only for the "value" column. +510 +511 Returns: +512 defaultdict[str, str | None]: Labels as result[column]; unavailable labels are None. +513 +514 See :ref:`metadata-getter-names` for shared examples and return shapes. +515 """ +516 return _single_metric_by_column(singletsframe, DISPLAY_NAME_FIELDS, include_inherited)
+ +517 +518 +
+[docs] +519def get_singlets_short_display_names( +520 singletsframe: pd.DataFrame, *, include_inherited: bool = False +521) -> defaultdict[str, str | None]: +522 """Read short display labels for each value column of a single-metric frame. +523 +524 Use short_display_name, then display_name, then name. +525 +526 Args: +527 singletsframe: DataFrame with one metric's metadata in attrs. +528 include_inherited: Use inherited fields last, only for the "value" column. +529 +530 Returns: +531 defaultdict[str, str | None]: Labels as result[column]; unavailable labels are None. +532 +533 See :ref:`metadata-getter-names` for shared examples and return shapes. +534 """ +535 return _single_metric_by_column(singletsframe, SHORT_DISPLAY_NAME_FIELDS, include_inherited)
+ +536 +537 +
+[docs] +538def get_singlets_measurements( +539 singletsframe: pd.DataFrame, *, include_inherited: bool = False +540) -> defaultdict[str, str | None]: +541 """Read physical quantities for each value column of a single-metric frame. +542 +543 For example, "temperature" is a measurement and "°C" is its unit. +544 +545 Args: +546 singletsframe: DataFrame with one metric's metadata in attrs. +547 include_inherited: Use inherited fields last, only for the "value" column. +548 +549 Returns: +550 defaultdict[str, str | None]: +551 Quantities as result[column]; unavailable quantities are None. +552 +553 See :ref:`metadata-getter-values` for shared examples and return shapes. +554 """ +555 return _single_metric_by_column(singletsframe, "measurement", include_inherited)
+ +556 +557 +
+[docs] +558def get_singlets_metric_info( +559 singletsframe: pd.DataFrame, metric_info: str, *, include_inherited: bool = True +560) -> Any: +561 """Read one metadata field for the frame's single metric. +562 +563 Search the metric, its "value" column, shared "value" metadata, then inherited fields +564 (if enabled). +565 +566 Args: +567 singletsframe: DataFrame with one metric's metadata in attrs. +568 metric_info: Field name or dotted path, e.g. "sensor.model". +569 include_inherited: Use inherited fields as a last fallback. +570 +571 Returns: +572 Any: One field value, or None when unavailable. +573 +574 Code example: +575 +576 Using :ref:`metadata-example-data`, compare a field of the metric with fields of its value +577 dimensions. See :ref:`metadata-getter-fields` for the related getters: +578 +579 .. doctest:: metadata.get_singlets_metric_info +580 +581 >>> md.get_singlets_metric_info(dataframe, "name") +582 'ABC temperature' +583 >>> md.get_singlets_info(dataframe, "name")["state"] +584 'measurement state' +585 """ +586 check_dataframe(singletsframe) +587 return _single_metric_or_dataset( +588 singletsframe, +589 "SINGLETSFRAME", +590 lambda meta, metric: meta.metric_info( +591 metric_info, metric, include_inherited=include_inherited +592 ), +593 lambda _meta: None, +594 empty=None, +595 )
+ +596 +597 +598# queried interval, time zones, recording and aggregation +599 +600 +
+[docs] +601def get_queried_interval( +602 data: pd.Series | pd.DataFrame, +603) -> tuple[datetime.datetime | None, datetime.datetime | None]: +604 """Read the requested UTC time interval from dataset metadata. +605 +606 Args: +607 data: Series or DataFrame with dataset metadata in attrs. +608 +609 Returns: +610 tuple[datetime.datetime | None, datetime.datetime | None]: +611 (start, end) as timezone-aware datetimes, or (None, None) if unavailable. +612 +613 Code example: +614 +615 .. doctest:: metadata.get_queried_interval +616 +617 >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE +618 >>> series.attrs = SERIES_METADATA_EXAMPLE +619 >>> start, end = md.get_queried_interval(series) +620 >>> start.isoformat() +621 '2026-01-01T00:00:00+00:00' +622 +623 See :ref:`metadata-getter-values` for shared examples and return shapes. +624 """ +625 check_series_or_dataframe(data) +626 meta = _metadata(data, infer_data_type(data)) +627 return (None, None) if meta is None else meta.queried_interval()
+ +628 +629 +
+[docs] +630def get_default_timezones(multitsframe: pd.DataFrame) -> defaultdict[str, str | None]: +631 """Read the time zone recommended for calculations on each metric. +632 +633 Use the metric's default_timezone, then the dataset's default_timezone. +634 +635 Args: +636 multitsframe: DataFrame with per-metric metadata in attrs. +637 +638 Returns: +639 defaultdict[str, str | None]: +640 Zone names or UTC offsets by metric ID, or None for unresolved zones. Missing +641 IDs use the dataset default. +642 +643 Code example: +644 +645 .. doctest:: metadata.get_default_timezones +646 +647 >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE +648 >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE +649 >>> md.get_default_timezones(multitsframe)["plant_a.pressure"] +650 'Europe/Berlin' +651 +652 See :ref:`metadata-getter-timezones` for shared examples and return shapes. +653 """ +654 check_dataframe(multitsframe) +655 meta = _metadata(multitsframe, "MULTITSFRAME") +656 if meta is None: +657 return defaultdict(lambda: None) +658 dataset_timezone = dataset_default_timezone(meta) +659 return defaultdict(lambda: dataset_timezone, meta.default_timezones())
+ +660 +661 +
+[docs] +662def get_series_default_timezone(series: pd.Series) -> str | None: +663 """Read the time zone recommended for calculations on this Series. +664 +665 Use the metric's default_timezone, then the dataset's default_timezone. +666 +667 Args: +668 series: Series with metadata in attrs. +669 +670 Returns: +671 str | None: Zone name or UTC offset, or None when neither source provides one. +672 +673 See :ref:`metadata-getter-timezones` for shared examples and return shapes. +674 """ +675 check_series(series) +676 return _single_metric_or_dataset( # type: ignore[no-any-return] +677 series, +678 "SERIES", +679 lambda meta, metric: meta.default_timezone(metric), +680 dataset_default_timezone, +681 empty=None, +682 )
+ +683 +684 +
+[docs] +685def get_singlets_default_timezone(singletsframe: pd.DataFrame) -> str | None: +686 """Read the time zone recommended for calculations on a single-metric frame. +687 +688 Use the metric's default_timezone, then the dataset's default_timezone. +689 +690 Args: +691 singletsframe: DataFrame with one metric's metadata in attrs. +692 +693 Returns: +694 str | None: Zone name or UTC offset, or None when neither source provides one. +695 +696 See :ref:`metadata-getter-timezones` for shared examples and return shapes. +697 """ +698 check_dataframe(singletsframe) +699 return _single_metric_or_dataset( # type: ignore[no-any-return] +700 singletsframe, +701 "SINGLETSFRAME", +702 lambda meta, metric: meta.default_timezone(metric), +703 dataset_default_timezone, +704 empty=None, +705 )
+ +706 +707 +
+[docs] +708def get_recordings(multitsframe: pd.DataFrame) -> defaultdict[str, Recording]: +709 """Read recording settings for each metric. +710 +711 Use the metric's frequency, offset and time zone together. If its frequency is missing, +712 use the dataset's three fields instead. +713 +714 Args: +715 multitsframe: DataFrame with per-metric metadata in attrs. +716 +717 Returns: +718 defaultdict[str, Recording]: +719 Settings by metric ID; missing IDs use dataset frequency settings. +720 +721 Code example: +722 +723 .. doctest:: metadata.get_recordings +724 +725 >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE +726 >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE +727 >>> md.get_recordings(multitsframe)["plant_a.temperature"].recording_mode +728 'on_change' +729 +730 See :ref:`metadata-getter-recordings` for shared examples and return shapes. +731 """ +732 check_dataframe(multitsframe) +733 meta = _metadata(multitsframe, "MULTITSFRAME") +734 if meta is None: +735 return defaultdict(Recording) +736 no_metadata = dataset_recording(meta) +737 return defaultdict(lambda: no_metadata, meta.recordings())
+ +738 +739 +
+[docs] +740def get_series_recording(series: pd.Series) -> Recording: +741 """Read recording settings for this Series. +742 +743 Use the metric's frequency, offset and time zone together. If its frequency is missing, +744 use the dataset's three fields instead. +745 +746 Args: +747 series: Series with metadata in attrs. +748 +749 Returns: +750 Recording: Recording object; absent metadata yields its default field values. +751 +752 See :ref:`metadata-getter-recordings` for shared examples and return shapes. +753 """ +754 check_series(series) +755 return _single_metric_or_dataset( # type: ignore[no-any-return] +756 series, +757 "SERIES", +758 lambda meta, metric: meta.recording(metric), +759 dataset_recording, +760 empty=Recording(), +761 )
+ +762 +763 +
+[docs] +764def get_singlets_recording(singletsframe: pd.DataFrame) -> Recording: +765 """Read recording settings for a single-metric frame. +766 +767 Use the metric's frequency, offset and time zone together. If its frequency is missing, +768 use the dataset's three fields instead. +769 +770 Args: +771 singletsframe: DataFrame with one metric's metadata in attrs. +772 +773 Returns: +774 Recording: Recording object; absent metadata yields its default field values. +775 +776 See :ref:`metadata-getter-recordings` for shared examples and return shapes. +777 """ +778 check_dataframe(singletsframe) +779 return _single_metric_or_dataset( # type: ignore[no-any-return] +780 singletsframe, +781 "SINGLETSFRAME", +782 lambda meta, metric: meta.recording(metric), +783 dataset_recording, +784 empty=Recording(), +785 )
+ +786 +787 +
+[docs] +788def get_default_aggregation_methods(multitsframe: pd.DataFrame) -> defaultdict[str, str]: +789 """Read the default aggregation method for each metric. +790 +791 Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata +792 value_data_type selects LAST for boolean/string and MEAN for other types. +793 +794 Args: +795 multitsframe: DataFrame with per-metric metadata in attrs. +796 +797 Returns: +798 defaultdict[str, str]: Method names by metric ID; missing IDs default to MEAN. +799 +800 Code example: +801 +802 .. doctest:: metadata.get_default_aggregation_methods +803 +804 >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE +805 >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE +806 >>> methods = md.get_default_aggregation_methods(multitsframe) +807 >>> methods["plant_a.temperature"], methods["plant_a.pressure"] +808 ('MAX', 'MEAN') +809 +810 See :ref:`metadata-getter-aggregation` for shared examples and return shapes. +811 """ +812 check_dataframe(multitsframe) +813 meta = _metadata(multitsframe, "MULTITSFRAME") +814 methods = {} if meta is None else meta.default_aggregation_methods() +815 return defaultdict(lambda: _DEFAULT_AGGREGATION_METHOD, methods)
+ +816 +817 +
+[docs] +818def get_series_default_aggregation_method(series: pd.Series) -> str: +819 """Read the default aggregation method for this Series. +820 +821 Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata +822 value_data_type selects LAST for boolean/string and MEAN for other types. +823 +824 Args: +825 series: Series with metadata in attrs. +826 +827 Returns: +828 str: Method name; MEAN if metadata is unavailable. +829 +830 See :ref:`metadata-getter-aggregation` for shared examples and return shapes. +831 """ +832 check_series(series) +833 return _single_metric_or_dataset( # type: ignore[no-any-return] +834 series, +835 "SERIES", +836 lambda meta, metric: meta.default_aggregation_method(metric), +837 lambda _meta: _DEFAULT_AGGREGATION_METHOD, +838 empty=_DEFAULT_AGGREGATION_METHOD, +839 )
+ +840 +841 +
+[docs] +842def get_singlets_default_aggregation_method(singletsframe: pd.DataFrame) -> str: +843 """Read the default aggregation method for a single-metric frame. +844 +845 Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata +846 value_data_type selects LAST for boolean/string and MEAN for other types. +847 +848 Args: +849 singletsframe: DataFrame with one metric's metadata in attrs. +850 +851 Returns: +852 str: Method name; MEAN if metadata is unavailable. +853 +854 See :ref:`metadata-getter-aggregation` for shared examples and return shapes. +855 """ +856 check_dataframe(singletsframe) +857 return _single_metric_or_dataset( # type: ignore[no-any-return] +858 singletsframe, +859 "SINGLETSFRAME", +860 lambda meta, metric: meta.default_aggregation_method(metric), +861 lambda _meta: _DEFAULT_AGGREGATION_METHOD, +862 empty=_DEFAULT_AGGREGATION_METHOD, +863 )
+ +864 +865 +
+[docs] +866def copy_without_metadata[T: (pd.Series, pd.DataFrame)](data: T) -> T: +867 """Copy a Series or DataFrame with all attrs removed. +868 +869 Data is shared until either object is modified (pandas copy on write). +870 +871 Args: +872 data (pd.Series | pd.DataFrame): Source object; left unchanged. +873 +874 Returns: +875 pd.Series | pd.DataFrame: Copy with empty attrs. +876 +877 Code example: +878 +879 .. doctest:: metadata.copy_without_metadata +880 +881 >>> multitsframe.attrs = {"dataset_metadata": {"metric_key": "id"}, "metrics": []} +882 >>> data = md.copy_without_metadata(multitsframe) +883 >>> data.attrs +884 {} +885 >>> multitsframe.attrs +886 {'dataset_metadata': {'metric_key': 'id'}, 'metrics': []} +887 """ +888 check_series_or_dataframe(data) +889 # The constructor does not copy attrs, in contrast to e.g. copy(deep=False) +890 copy = type(data)(data) +891 copy.attrs = {} +892 return copy
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/plotting/color.html b/docs/_modules/hdhelpers/plotting/color.html new file mode 100644 index 0000000..e885154 --- /dev/null +++ b/docs/_modules/hdhelpers/plotting/color.html @@ -0,0 +1,145 @@ + + + + + + + hdhelpers.plotting.color — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.plotting.color

+ 1FUSEKI_COLORS = {
+ 2    "ki.vision": "#eb7c45",  # orange
+ 3    "ki.change": "#2fae53",  # green
+ 4    "ki.contrast": "#232326",  # black
+ 5    "ki.insight": "#e5cf64",  # yellow / gold
+ 6    "ki.tech": "#80b0ec",  # blue
+ 7    "ki.shade": "#8c8c98",  # gray
+ 8    "ki.vision.bright": "#ffb058",  # light orange
+ 9    "ki.change.bright": "#89ce6e",  # light green
+10    "ki.light": "#f8f8f8",  # off-white / light gray
+11    "ki.energy": "#eb6962",  # red / coral
+12    "ki.science": "#bd7abb",  # purple
+13    # more telling color names:
+14    "ki.orange": "#eb7c45",  # orange
+15    "ki.green": "#2fae53",  # green
+16    "ki.black": "#232326",  # black
+17    "ki.yellow": "#e5cf64",  # yellow / gold
+18    "ki.blue": "#80b0ec",  # blue
+19    "ki.gray": "#8c8c98",  # gray
+20    "ki.lightyellow": "#ffb058",  # light orange
+21    "ki.lightorange": "#ffb058",  # light orange
+22    "ki.lightgreen": "#89ce6e",  # light green
+23    "ki.white": "#f8f8f8",  # off-white / light gray
+24    "ki.red": "#eb6962",  # red / coral
+25    "ki.purple": "#bd7abb",  # purple
+26}
+27
+28FUSEKI_COLOR_CYCLE = [
+29    FUSEKI_COLORS["ki.green"],
+30    FUSEKI_COLORS["ki.orange"],
+31    FUSEKI_COLORS["ki.blue"],
+32    FUSEKI_COLORS["ki.red"],
+33    FUSEKI_COLORS["ki.purple"],
+34    FUSEKI_COLORS["ki.yellow"],
+35    FUSEKI_COLORS["ki.gray"],
+36    FUSEKI_COLORS["ki.lightgreen"],
+37    FUSEKI_COLORS["ki.lightorange"],
+38    FUSEKI_COLORS["ki.lightyellow"],
+39]
+40
+41
+
+[docs] +42def resolve_color(color_str: str) -> str: +43 """Resolve a Fuseki palette name to a color string. +44 +45 Args: +46 color_str: Palette name, e.g. "ki.energy", or any other string. +47 +48 Returns: +49 str: Resolved color; other strings are returned unchanged. +50 +51 Code example: +52 +53 .. doctest:: +54 +55 >>> resolve_color("red") +56 'red' +57 >>> resolve_color("ki.energy") +58 '#eb6962' +59 """ +60 return FUSEKI_COLORS.get(color_str, color_str)
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/plotting/plotly_theme.html b/docs/_modules/hdhelpers/plotting/plotly_theme.html new file mode 100644 index 0000000..98de34d --- /dev/null +++ b/docs/_modules/hdhelpers/plotting/plotly_theme.html @@ -0,0 +1,150 @@ + + + + + + + hdhelpers.plotting.plotly_theme — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.plotting.plotly_theme

+ 1import plotly.graph_objects as go
+ 2import plotly.io as pio
+ 3
+ 4NEUTRAL = "rgba(128,128,128,0.4)"
+ 5NEUTRAL_GRID = "rgba(128,128,128,0.25)"
+ 6NEUTRAL_TEXT = "rgba(128,128,128,0.9)"
+ 7
+ 8theme_agnostic_template = go.layout.Template(
+ 9    layout=go.Layout(
+10        paper_bgcolor="rgba(0, 0, 0, 0)",
+11        plot_bgcolor="rgba(128, 128, 128, 0)",
+12        # plot_bg_color not 0,0,0,0 because zoom highlighting then does not work properly!
+13        # Must be >=128 to actuall work in light mode!
+14        font={"color": NEUTRAL_TEXT, "family": "Inter, Helvetica, sans-serif", "size": 12},
+15        xaxis={
+16            "showgrid": True,
+17            "gridcolor": NEUTRAL_GRID,
+18            "zerolinecolor": NEUTRAL,
+19            "linecolor": NEUTRAL,
+20            "tickfont": {"color": NEUTRAL_TEXT},
+21        },
+22        yaxis={
+23            "showgrid": True,
+24            "gridcolor": NEUTRAL_GRID,
+25            "zerolinecolor": NEUTRAL,
+26            "linecolor": NEUTRAL,
+27            "tickfont": {"color": NEUTRAL_TEXT},
+28        },
+29        hoverlabel={
+30            "bgcolor": "rgba(128,128,128,0.85)",
+31            "bordercolor": "rgba(0,0,0,0.08)",
+32            "font": {"color": "#fff", "size": 12, "family": "Inter, Helvetica, sans-serif"},
+33            "namelength": 0,
+34        },
+35        modebar={
+36            "bgcolor": "rgba(0,0,0,0)",
+37            "color": NEUTRAL_TEXT,
+38            "activecolor": "rgba(128,128,128,1)",
+39        },
+40    )
+41)
+42
+43pio.templates["theme_agnostic"] = theme_agnostic_template
+44
+45pio.templates.default = "theme_agnostic"
+46
+47
+
+[docs] +48def set_agnostic_theme(fig: go.Figure) -> None: +49 """Apply a Plotly theme suitable for light and dark backgrounds. +50 +51 Args: +52 fig: Figure to style. +53 +54 Returns: +55 None: The supplied figure is updated in place. +56 +57 Code example: +58 +59 .. doctest:: +60 +61 >>> import plotly.graph_objects as go +62 >>> fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[1, 4, 9])) +63 >>> set_agnostic_theme(fig) +64 """ +65 fig.update_layout(template=theme_agnostic_template)
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/hdhelpers/plotting/timestamp_ticks.html b/docs/_modules/hdhelpers/plotting/timestamp_ticks.html new file mode 100644 index 0000000..ce19ebb --- /dev/null +++ b/docs/_modules/hdhelpers/plotting/timestamp_ticks.html @@ -0,0 +1,131 @@ + + + + + + + hdhelpers.plotting.timestamp_ticks — hdhelpers - documentation + + + + + + + + + + + + + + +
+
+
+
+ +

Source code for hdhelpers.plotting.timestamp_ticks

+ 1"""Language agnostic multi-zoom-level x axis time range ticks for plotly
+ 2
+ 3Usage:
+ 4
+ 5    fig.update_xaxes(
+ 6        tickformatstops=TICKFORMATSTOPS
+ 7    )
+ 8"""
+ 9
+10import plotly.graph_objects as go
+11
+12TICKFORMATSTOPS = [
+13    # language agnostic, isoformat inspired, plotl x axis timestamp formatting
+14    # defined for multiple zoom levels
+15    # always showing a tick with the current day somewhere for reference
+16    {"dtickrange": [None, 1000], "value": "%H:%M:%S.%L\n%Y-%m-%d"},
+17    {"dtickrange": [1000, 60000], "value": "%H:%M:%S\n%Y-%m-%d"},
+18    {"dtickrange": [60000, 3600000], "value": "%H:%M\n%Y-%m-%d"},
+19    {"dtickrange": [3600000, 86400000], "value": "%H:%M\n%Y-%m-%d"},
+20    {"dtickrange": [86400000, 604800000], "value": "%H:%M\n%Y-%m-%d"},
+21    {"dtickrange": [604800000, "M1"], "value": "%Y-%m-%d"},
+22    {"dtickrange": ["M1", "M12"], "value": "%Y-%m"},
+23    {"dtickrange": ["M12", None], "value": "%Y"},
+24]
+25
+26
+
+[docs] +27def set_dt_ticks(fig: go.Figure) -> None: +28 """Set zoom-dependent, locale-independent date/time labels on all x axes. +29 +30 Update tickformatstops; timestamps and time zones are unchanged. +31 +32 Args: +33 fig: Figure to format. +34 +35 Returns: +36 None: The supplied figure is updated in place. +37 +38 Code example: +39 +40 .. doctest:: +41 +42 >>> import plotly.graph_objects as go +43 >>> fig = go.Figure(data=go.Scatter(x=["2024-01-01", "2024-01-02"], y=[1, 4])) +44 >>> set_dt_ticks(fig) +45 """ +46 fig.update_xaxes(tickformatstops=TICKFORMATSTOPS)
+ +
+ +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/docs/_modules/index.html b/docs/_modules/index.html new file mode 100644 index 0000000..486c15c --- /dev/null +++ b/docs/_modules/index.html @@ -0,0 +1,90 @@ + + + + + + + Overview: module code — hdhelpers - documentation + + + + + + + + + + + + + + +
+ + +
+
+ + + + \ No newline at end of file diff --git a/docs/_sources/first_steps.rst.txt b/docs/_sources/first_steps.rst.txt index 24dab44..d69764a 100644 --- a/docs/_sources/first_steps.rst.txt +++ b/docs/_sources/first_steps.rst.txt @@ -2,6 +2,9 @@ First steps ####################### +See :ref:`metadata-example-data` for sample pandas inputs and examples of the +related getters. + How to get metadata with hdhelpers? =================================== @@ -17,16 +20,18 @@ In hetida designer this series can be represented as json for *direct provisioni "dataset_metadata": { "metric_key": "external_id", "single_metric": "water.north.level", + "queried_metrics": ["water.north.level"], "ref_interval_start_timestamp": "2020-01-01T08:10:00+00:00", "ref_interval_end_timestamp": "2020-01-01T08:20:00+00:00", "ref_interval_type": "closed" }, "metrics": [ { + "id": "4c1f0e2a-7b3d-4e5f-9a8b-1c2d3e4f5a6b", "external_id": "water.north.level", "name": "Water level north", "short_display_name": "Water Level", - "value_dimensions": [{"column": "value", "unit": "cm"}], + "value_dimensions": [{"column": "value", "unit": "cm", "measurement": "level"}], "inherited": {"latitude": 51.43, "longitude": 7.58} } ] @@ -55,8 +60,65 @@ We can retrieve the name, unit and latitude of the series with the following cod ... +or, equivalently, via the metadata as model: + +.. code-block:: python + + from hdhelpers.metadata import read_metadata + + def main(*, series): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + meta = read_metadata(series) # None if the series has no valid metadata + if meta is not None: + name = meta.name() # "Water level north" + unit = meta.unit() # "cm" + latitude = meta.metric_info("latitude") # 51.43 + + ... + .. _hetida designer metadata conventions: https://hetida.github.io/hetida-designer/user_guide/attached_metadata/ +Metadata as model +================= + +``hdhelpers.metadata.conventions`` holds the models of the metadata conventions, one per data +type: ``SeriesMetadata``, ``SingleTSFrameMetadata`` and ``MultiTSFrameMetadata``. They are the +contract of the conventions: Parsing fails for metadata which does not follow them. +``check_metadata_conventions`` reports the issues of such metadata at their locations. + +``read_metadata(obj)`` gives the metadata of a Series or DataFrame as model, or None if there is +no valid metadata. With ``required=True`` it raises a ``MetadataConventionError`` listing the +issues instead, for components which cannot work without metadata. The methods of the models +resolve the fallbacks described below, e.g. ``unit()``, ``display_name()``, ``metric_info()``, +``default_timezone()``, ``recording()`` and ``queried_interval()``. For a SERIES or +SINGLETSFRAME, the metric can be omitted. For a MULTITSFRAME, the methods take the metric, and +methods in plural like ``units()`` return the information of all metrics. + +The hetida designer runtime parses the metadata of the workflow inputs once and attaches the +model to them (``attach_metadata``). The models are immutable, so that all objects pandas +derives from an input share the model instead of copying the metadata, which saves a lot of +time for metadata of many metrics. To change metadata, create a changed version, which shares +all unchanged parts, and set it on your result: + +.. code-block:: python + + from hdhelpers.metadata import read_metadata, set_metadata + + def main(*, series): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + result = series / 100 + meta = read_metadata(series, required=True) + set_metadata(result, meta.with_value_dimension(None, "value", unit="m")) + + return {"result": result} + +Since the model is no JSON value, ``json.dumps(obj.attrs)`` or ``obj.to_parquet()`` fail for +objects with attached metadata. Use ``attrs_to_json(obj.attrs)`` to get attrs in their JSON form. + Where does the information come from? ===================================== @@ -91,43 +153,36 @@ In contrast, the functions for dedicated information do not use them by default, inherited property with the same label usually does not describe the metric itself, e.g. a property "unit" of a plant. Pass ``include_inherited=True`` to use them as last resort anyway. -Entries which do not adhere to the conventions are skipped: metrics which are no objects or -whose field named by the metric key is no string, and value dimensions which are no objects or -whose column is no string. ``"value_dimensions"`` and ``"value_dimensions_shared"`` which are no -lists count as empty, a metric key which is no string as missing, i.e. ``"id"`` is used. +Metadata which does not follow the conventions provides no information at all: The functions +return None or empty results for it. How to query nested information? ================================ The information requested from ``get_metric_info``, ``get_singlets_metric_info``, -``get_series_info`` and ``get_singlets_info`` is interpreted as a `glom`_ spec. A string is a -path in which dots separate the keys, so it also reaches into sub dictionaries. Let's say the -metric of the series above additionally provides comments and information on its sensor (the -conventions allow additional fields at every level), and the label of one of its inherited -properties contains a dot: +``get_series_info`` and ``get_singlets_info`` (and from the corresponding methods of the models) +is a field or a path of fields separated by dots, which reaches into sub objects. Let's say the +metric of the series above additionally provides information on its sensor (the conventions +allow additional fields at every level): .. code-block:: json { + "id": "4c1f0e2a-7b3d-4e5f-9a8b-1c2d3e4f5a6b", "external_id": "water.north.level", "name": "Water level north", "sensor": { "manufacturer": "Acme", "calibration": {"date": "2026-03-01", "offset": 0.02} }, - "comments": [ - {"content": "Gauge maintenance"}, - {"content": "Sensor replaced"} - ], - "value_dimensions": [{"column": "value", "unit": "cm"}], - "inherited": {"latitude": 51.43, "operator.name": "Ruhrverband"} + "value_dimensions": [{"column": "value", "unit": "cm", "measurement": "level"}], + "inherited": {"latitude": 51.43} } We can retrieve the nested information with the following code .. code-block:: python - from glom import Path from hdhelpers.metadata import get_series_info def main(*, series): @@ -139,30 +194,21 @@ We can retrieve the nested information with the following code latitude = get_series_info(series, "inherited.latitude") # 51.43, never the metric's own firmware = get_series_info(series, "sensor.firmware") # None, not provided - # any other glom spec, e.g. the content of each comment - comments = get_series_info(series, ("comments", ["content"])) - # ["Gauge maintenance", "Sensor replaced"] - - # a key which itself contains dots requires a glom Path - operator = get_series_info(series, Path("operator.name")) # "Ruhrverband" - ... For a MULTITSFRAME, ``get_metric_info(multitsframe, "sensor.manufacturer")`` provides the manufacturer keyed by metric accordingly. -The fallbacks described above apply to the whole path, e.g. ``"sensor.manufacturer"`` is looked -up in the metric first, then in its value dimension ``"value"`` and so on. A path which is not -present yields None instead of an error. Note that a plain string like ``"operator.name"`` is -always split at its dots, so it does not find the inherited property ``"operator.name"``. - -A spec may also transform the information, e.g. ``("name", str.upper)``. Since information -provided as null counts as missing, such a spec may receive null: on the metric itself and, if -the metric does not provide the information, on the levels it falls back to. Errors raised by -the spec are not caught. So either let it handle None, or rather transform the information -after retrieving it. +The fallbacks described above apply to the whole path. ``get_series_info`` and +``get_singlets_info`` search the value dimension first, then the metric (only for +``"value"``), then shared metadata. ``get_metric_info`` and ``get_singlets_metric_info`` +search the metric first, then its ``"value"`` dimension, then shared ``"value"`` metadata. +Each lookup can use inherited properties as a final fallback when enabled. -.. _glom: https://glom.readthedocs.io/ +A missing path yields None. An explicit path such as ``"inherited.latitude"`` accesses +that property directly, even with ``include_inherited=False``. Paths cannot address list +items or keys containing dots. Access those through the models, e.g. +``read_metadata(series, required=True).metric().comments``; see :doc:`metadata_models`. Which time zone to compute in? ============================== diff --git a/docs/_sources/index.rst.txt b/docs/_sources/index.rst.txt index cf00ef9..a5424d1 100644 --- a/docs/_sources/index.rst.txt +++ b/docs/_sources/index.rst.txt @@ -29,7 +29,10 @@ Further Information .. toctree:: :maxdepth: 2 + installation first_steps + metadata_examples + metadata_models Functions ========= @@ -37,6 +40,10 @@ Functions metadata ----------------- +The examples below use :ref:`metadata-example-data`. See :doc:`metadata_examples` +for the return shapes and shared examples of the related getters. +See :ref:`metadata-lookup-basics` for metric selection and missing-field behavior. + .. automodule:: hdhelpers.metadata :members: :show-inheritance: @@ -63,6 +70,9 @@ plotting exceptions ------------------- +For metadata validation errors, see :class:`hdhelpers.metadata.MetadataConventionError` +in the metadata API. Its ``issues`` attribute contains the individual validation issues. + .. automodule:: hdhelpers.exceptions :members: :show-inheritance: diff --git a/docs/_sources/installation.rst.txt b/docs/_sources/installation.rst.txt new file mode 100644 index 0000000..7f7c615 --- /dev/null +++ b/docs/_sources/installation.rst.txt @@ -0,0 +1,41 @@ +Installation +============ + +hdhelpers requires Python 3.12 or newer and is included in the hetida designer runtime. +To use it in your own Python project, run the following commands in your project's +Python environment. + +Core package +------------ + +Install the metadata and time zone helpers: + +.. code-block:: console + + python -m pip install hdhelpers + +For a project managed with uv, use ``uv add hdhelpers`` instead. +The core package works without Plotly. + +Plotting helpers +---------------- + +Install the optional ``plotting`` extra to use ``hdhelpers.plotting`` and the +plotting functions exported by ``hdhelpers``: + +.. code-block:: console + + python -m pip install "hdhelpers[plotting]" + +With uv, use ``uv add "hdhelpers[plotting]"``. This also enables +``from hdhelpers import *``, which includes plotting exports. + +Verify the installation +----------------------- + +.. code-block:: console + + python -c "import hdhelpers; print(hdhelpers.__version__)" + +Continue with :doc:`first_steps` for component examples or :doc:`metadata_examples` +for sample pandas data to use in a Python session. diff --git a/docs/_sources/metadata_examples.rst.txt b/docs/_sources/metadata_examples.rst.txt new file mode 100644 index 0000000..1bf04d8 --- /dev/null +++ b/docs/_sources/metadata_examples.rst.txt @@ -0,0 +1,216 @@ +Metadata examples +================= + +.. _metadata-example-data: + +Shared data setup +----------------- + +The following examples use pandas objects with metadata. In hetida designer, these +are supplied as component inputs. To try the examples in a Python session, create +them using the shared data setup below. + +The setup creates three independent pandas objects with complete metadata following +the current conventions. Use these objects for the API reference examples as well: + +* ``series``: a SERIES with one value per timestamp, measured in metres. +* ``dataframe``: a SINGLETSFRAME describing one metric with the value columns + ``value`` (temperature) and ``state`` (measurement state). +* ``multitsframe``: a MULTITSFRAME with pressure and temperature metrics, whose + identifiers are stored in the ``metric`` column. + +``md`` is the imported ``hdhelpers.metadata`` module. ``deepcopy`` keeps changes to +the example metadata independent of the supplied templates. The same setup is run +for each documentation test group. To start an example again with unchanged data, +rerun the setup. + +.. literalinclude:: _examples/metadata_setup.py + :language: python + +See :doc:`first_steps` for examples within components. + +.. _metadata-lookup-basics: + +Metadata lookup basics +---------------------- + +A metric is one measured signal. A value column (called a *value dimension* in the +schema) holds values or related information, such as measurement state. Metadata +is stored in ``attrs`` as convention fields or as a model under ``hd_metadata``. +Shared metadata in ``value_dimensions_shared`` supplies defaults for matching +columns across metrics. + +The metadata models are immutable and allow additional fields at every level. +Metadata timestamps are UTC ISO strings with an explicit ``Z`` or ``+00:00`` offset. +Adapters exchange convention fields; :func:`hdhelpers.metadata.attrs_to_json` converts +an attached model back to those fields for export. + +Series and SINGLETSFRAME getters select ``dataset_metadata.single_metric``, or the +only metric described if no identifier is set. They do not select by ``Series.name``. +Without a unique selection, metric-specific information is unavailable. +Absent fields and fields set to None count as missing. Inherited properties are supplied +by the source system for the metric or its parent assets. They are used as fallbacks when +``include_inherited=True``. Explicit paths such as ``"inherited.site"`` access them directly, +even when this fallback is disabled. +Missing or invalid metadata gives empty mappings for mapping getters; missing-key +lookups still use their documented defaults. + +.. _metadata-type-selection: + +Automatic metadata type selection +--------------------------------- + +When parsing raw attrs, ``read_metadata``, ``attach_metadata`` and +``check_metadata_conventions`` infer the type as follows: + +* A Series selects ``SERIES``. +* A DataFrame with a ``metric`` column selects ``MULTITSFRAME``. +* Other DataFrames select ``SINGLETSFRAME``. + +An explicit ``data_type`` overrides this selection. ``read_metadata`` also checks +the type of an attached model when requested; ``attach_metadata`` keeps an already +attached model unchanged. + +.. _metadata-getter-values: + +Units, measurements and return shapes +------------------------------------- + +SERIES getters return a single value. SINGLETSFRAME getters return information by +value column; MULTITSFRAME getters return it by metric and value column. The +measurement describes the physical quantity, whereas the unit describes its scale. + +.. doctest:: metadata.examples.values + + >>> md.get_series_unit(series), md.get_series_measurement(series) + ('m', 'level') + >>> dict(md.get_singlets_units(dataframe)) + {'value': '°C', 'state': None} + >>> md.get_singlets_measurements(dataframe)["value"] + 'temperature' + >>> md.get_units(multitsframe)["plant_a.pressure"]["value"] + 'bar' + >>> md.get_measurements(multitsframe)["plant_a.pressure"]["value"] + 'pressure' + >>> md.get_units(multitsframe)["unknown metric"]["value"] is None + True + >>> start, end = md.get_queried_interval(series) + >>> start.isoformat(), end.isoformat() + ('2026-01-01T00:00:00+00:00', '2026-01-02T00:00:00+00:00') + +.. _metadata-getter-names: + +Names and display-name fallbacks +-------------------------------- + +These getters use the same return shapes. A short display name falls back to the +display name, which falls back to the name. The templates omit display names, so +the examples show that fallback. See :doc:`first_steps` for the lookup order and +the optional ``include_inherited=True`` fallback to inherited properties. + +.. doctest:: metadata.examples.names + + >>> md.get_series_name(series) + 'Water level north' + >>> md.get_series_display_name(series), md.get_series_short_display_name(series) + ('Water level north', 'Water level north') + >>> md.get_singlets_names(dataframe)["state"] + 'measurement state' + >>> md.get_singlets_display_names(dataframe)["state"] + 'measurement state' + >>> md.get_singlets_short_display_names(dataframe)["state"] + 'measurement state' + >>> md.get_names(multitsframe)["plant_a.pressure"]["value"] + 'Plant A pressure' + >>> md.get_display_names(multitsframe)["plant_a.pressure"]["value"] + 'Plant A pressure' + >>> md.get_short_display_names(multitsframe)["plant_a.pressure"]["value"] + 'Plant A pressure' + +.. _metadata-getter-fields: + +Metric fields and value-dimension fields +---------------------------------------- + +``get_singlets_info`` returns information for each value column, whereas +``get_singlets_metric_info`` returns one value for the metric. The Series variant +looks up its single value dimension; ``get_metric_info`` returns values by metric. +The field argument may also be a dotted path into nested metadata. + +.. doctest:: metadata.examples.fields + + >>> md.get_series_info(series, "decimals") + 2 + >>> md.get_singlets_info(dataframe, "name")["state"] + 'measurement state' + >>> md.get_singlets_metric_info(dataframe, "name") + 'ABC temperature' + >>> md.get_metric_info(multitsframe, "name")["plant_a.pressure"] + 'Plant A pressure' + +.. _metadata-getter-recordings: + +Recording information +--------------------- + +Single-metric getters return a ``Recording`` object; ``get_recordings`` returns +these objects by metric. Access its fields to distinguish samples, periodic +recording and recording on change. Frequency, offset and time zone fall back to +the dataset metadata together, as described by :func:`hdhelpers.metadata.get_recordings`. + +.. doctest:: metadata.examples.recordings + + >>> recording = md.get_series_recording(series) + >>> recording.recording_mode, recording.data_frequency + ('periodic', '15min') + >>> md.get_singlets_recording(dataframe).data_frequency is None + True + >>> md.get_recordings(multitsframe)["plant_a.temperature"].recording_mode + 'on_change' + +.. _metadata-getter-timezones: + +Default time zones +------------------ + +A metric's time zone falls back to the dataset's default, otherwise to None. +Use an explicit fallback such as ``or "UTC"`` when choosing a time zone for a +computation. See :doc:`first_steps` for a daily aggregation example and the +distinction between computation and display time zones. + +.. doctest:: metadata.examples.timezones + + >>> md.get_series_default_timezone(series) + 'Europe/Berlin' + >>> md.get_singlets_default_timezone(dataframe) is None + True + >>> md.get_singlets_default_timezone(dataframe) or "UTC" + 'UTC' + >>> md.get_default_timezones(multitsframe)["plant_a.pressure"] + 'Europe/Berlin' + +.. _metadata-getter-aggregation: + +Default aggregation methods +--------------------------- + +The getters return a method name, or method names by metric. An explicit method +wins; ``AVG`` is normalized to ``MEAN``. Otherwise, ``value_data_type`` in metadata +selects ``LAST`` for boolean/string and ``MEAN`` for other types. + +.. doctest:: metadata.examples.aggregation + + >>> md.get_series_default_aggregation_method(series) + 'MEAN' + >>> md.get_singlets_default_aggregation_method(dataframe) + 'MEAN' + >>> md.get_default_aggregation_methods(multitsframe)["plant_a.temperature"] + 'MAX' + +For the metadata lifecycle, start with :func:`hdhelpers.metadata.check_metadata_conventions`, +then :func:`hdhelpers.metadata.read_metadata` or :func:`hdhelpers.metadata.attach_metadata`. +Use :func:`hdhelpers.metadata.set_metadata` to attach changed metadata to a result +and :func:`hdhelpers.metadata.attrs_to_json` before serialization. Each function +has an example using the shared data setup. + +See :doc:`metadata_models` for the model classes and their reading, update and conversion methods. diff --git a/docs/_sources/metadata_models.rst.txt b/docs/_sources/metadata_models.rst.txt new file mode 100644 index 0000000..8aece16 --- /dev/null +++ b/docs/_sources/metadata_models.rst.txt @@ -0,0 +1,186 @@ +Metadata model reference +======================== + +.. currentmodule:: hdhelpers.metadata.conventions + +Read models with :func:`hdhelpers.metadata.read_metadata`. The returned type depends +on the metadata's data type; see :ref:`metadata-type-selection`. +All three root models are immutable, allow additional fields and provide the same +reading and update methods. + +.. list-table:: Root models + :header-rows: 1 + :widths: 30 35 35 + + * - Model + - Data + - Value dimensions + * - :class:`SeriesMetadata` + - One metric in a pandas Series + - One, named ``value`` + * - :class:`SingleTSFrameMetadata` + - One metric in a pandas DataFrame + - One or more value columns + * - :class:`MultiTSFrameMetadata` + - Metrics identified by a DataFrame's ``metric`` column + - Value columns for each metric + +The root fields are ``dataset_metadata`` (settings for the dataset), ``metrics`` +(metric descriptions) and optional ``value_dimensions_shared`` (column defaults). +See :ref:`metadata-lookup-basics` for storage, missing values and shared field rules. + +Reading and updating +-------------------- + +Using :ref:`metadata-example-data`, read a model and attach changed metadata to a +converted result. Updates return a new model; the original remains unchanged. + +.. doctest:: metadata.models + + >>> meta = md.read_metadata(series, required=True) + >>> meta.unit() + 'm' + >>> result = series * 100 + >>> changed = meta.with_value_dimension(None, "value", unit="cm") + >>> md.set_metadata(result, changed) + >>> md.get_series_unit(result), meta.unit() + ('cm', 'm') + >>> multi = md.read_metadata(multitsframe, required=True) + >>> multi.unit("plant_a.pressure") + 'bar' + >>> multi.narrowed_to("plant_a.pressure").unit() + 'bar' + +``metric_id`` is the value of the field selected by ``dataset_metadata.metric_key``. +It can be omitted for SeriesMetadata and SingleTSFrameMetadata; MultiTSFrameMetadata +requires it for methods reading one metric, even if only one metric is present. +``column`` selects a value dimension and defaults to ``"value"``. + +Methods in the plural return dictionaries by metric ID; value-dimension methods +return nested dictionaries as ``result[metric_id][column]``. Missing fields return +None; ``metric()`` raises KeyError for an unknown metric. See +:ref:`metadata-getter-timezones`, :ref:`metadata-getter-recordings` and +:ref:`metadata-getter-aggregation` for their specific defaults. + +``with_metric``, ``with_value_dimension`` and ``with_dataset_metadata`` accept field +changes as keyword arguments and validate the changed metadata. Use these methods +for updates; ``model_copy(update=...)`` does not validate replacement values. +Use :func:`hdhelpers.metadata.attrs_to_json` when exporting pandas attrs, or +``model_dump(mode="json")`` to obtain the model's convention fields directly. + +Root classes and methods +------------------------ + +The shared methods are documented once under SeriesMetadata below. They are also +available on SingleTSFrameMetadata and MultiTSFrameMetadata. Conversion methods +change only metadata; convert the pandas data separately to match it. + +.. autoclass:: SeriesMetadata(**data) + :members: metric_ids, metric, value_dimension_info, value_dimension_infos, unit, units, measurement, measurements, name, names, display_name, display_names, short_display_name, short_display_names, metric_info, metric_infos, default_timezone, default_timezones, recording, recordings, default_aggregation_method, default_aggregation_methods, queried_interval, with_metric, with_value_dimension, with_dataset_metadata, as_singletsframe + :undoc-members: + :member-order: bysource + +.. autoclass:: SingleTSFrameMetadata(**data) + :members: as_series, as_multitsframe + :member-order: bysource + +.. autoclass:: MultiTSFrameMetadata(**data) + :members: narrowed_to + +Reading method guide +-------------------- + +.. list-table:: Shared methods + :header-rows: 1 + :widths: 40 60 + + * - Methods + - Purpose + * - :meth:`SeriesMetadata.metric_ids`, :meth:`~SeriesMetadata.metric` + - List metric identifiers or retrieve a metric's metadata. + * - :meth:`SeriesMetadata.value_dimension_info`, :meth:`~SeriesMetadata.value_dimension_infos` + - Read a field or dotted path for one value dimension, or for all metrics and columns. + An ordered sequence of fields specifies fallbacks, e.g. ``("display_name", "name")``. + * - :meth:`SeriesMetadata.unit`, :meth:`~SeriesMetadata.units`, + :meth:`~SeriesMetadata.measurement`, :meth:`~SeriesMetadata.measurements` + - Read units or measured quantities, e.g. ``"°C"`` or ``"temperature"``. + * - :meth:`SeriesMetadata.name`, :meth:`~SeriesMetadata.names`, + :meth:`~SeriesMetadata.display_name`, :meth:`~SeriesMetadata.display_names`, + :meth:`~SeriesMetadata.short_display_name`, :meth:`~SeriesMetadata.short_display_names` + - Read names or labels with the :ref:`documented fallbacks `. + * - :meth:`SeriesMetadata.metric_info`, :meth:`~SeriesMetadata.metric_infos` + - Read a field or dotted path, searching the metric before its ``value`` dimension. + Inherited properties are a final fallback when enabled. + * - :meth:`SeriesMetadata.default_timezone`, :meth:`~SeriesMetadata.default_timezones` + - Read the time zone for calculations, falling back to the dataset default. + * - :meth:`SeriesMetadata.recording`, :meth:`~SeriesMetadata.recordings` + - Read :class:`~hdhelpers.metadata.Recording` objects describing how values are recorded. + * - :meth:`SeriesMetadata.default_aggregation_method`, :meth:`~SeriesMetadata.default_aggregation_methods` + - Read explicit or inferred aggregation methods, normalizing ``AVG`` to ``MEAN``. + * - :meth:`SeriesMetadata.queried_interval` + - Read the requested interval as UTC datetimes. + +Nested metadata +--------------- + +These classes describe the root models' fields. Construct them from convention +fields with ``ModelName.model_validate(mapping)``, or access them through a root +model, e.g. ``meta.metric().value_dimensions``. Required fields may accept None +where the conventions permit unknown values. + +.. autoclass:: MetricMetadata(**data) + + Required fields include ``id``, ``name`` and ``value_dimensions``. Other fields + describe labels, recording, default time zone, aggregation, comments and inherited properties. + +.. autoclass:: ValueDimensionMetadata(**data) + + Required fields are ``column``, ``unit`` and ``measurement``. Unit and measurement + may be None. Optional fields include names, data type, deadband and display precision. + +.. autoclass:: SharedValueDimensionMetadata(**data) + + ``column`` identifies the value dimension. Its other fields supply shared defaults. + +.. autoclass:: SingleMetricDatasetMetadata(**data) + + ``single_metric`` identifies the sole metric using ``metric_key``. + +.. autoclass:: MultiTSFrameDatasetMetadata(**data) + + Both dataset models include ``metric_key``, ``queried_metrics``, the requested interval, + default settings and adapter invalidation settings. Here ``single_metric`` is optional + and applies only to a dataset containing one metric. + +.. autoclass:: InheritedMetricMetadata(**data) + +.. autoclass:: MetricComment(**data) + +.. autoclass:: MetricCommentReply(**data) + +.. autoclass:: SignalGroupMetadata(**data) + +Allowed values and validation issues +---------------------------------------- + +.. autoclass:: IntervalType + :members: + +.. autoclass:: AggregationMethod + :members: + +.. autoclass:: ValueDataType + :members: + +.. autoclass:: RecordingMode + :members: + +.. autoclass:: MetadataIssueSeverity + :members: + +.. autoclass:: MetadataConventionIssue(**data) + +Obtain validation issues with :func:`hdhelpers.metadata.check_metadata_conventions`. +When required metadata cannot be read, :func:`hdhelpers.metadata.read_metadata` +raises :class:`~hdhelpers.metadata.MetadataConventionError`, whose ``issues`` attribute +contains these issue objects. diff --git a/docs/_static/basic.css b/docs/_static/basic.css index 4738b2e..114f80e 100644 --- a/docs/_static/basic.css +++ b/docs/_static/basic.css @@ -48,7 +48,7 @@ div.sphinxsidebarwrapper { div.sphinxsidebar { float: left; - width: 230px; + width: 350px; margin-left: -100%; font-size: 90%; word-wrap: break-word; @@ -211,8 +211,8 @@ table.modindextable td { /* -- general body styles --------------------------------------------------- */ div.body { - min-width: 360px; - max-width: 800px; + min-width: 0; + max-width: 1600px; } div.body p, div.body dd, div.body li, div.body blockquote { diff --git a/docs/_static/classic.css b/docs/_static/classic.css index 8851cb7..8d855c9 100644 --- a/docs/_static/classic.css +++ b/docs/_static/classic.css @@ -31,7 +31,7 @@ div.documentwrapper { } div.bodywrapper { - margin: 0 0 0 230px; + margin: 0 0 0 350px; } div.body { diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico new file mode 100644 index 0000000..75a4b30 Binary files /dev/null and b/docs/_static/favicon.ico differ diff --git a/docs/_static/nature.css b/docs/_static/nature.css deleted file mode 100644 index e26d936..0000000 --- a/docs/_static/nature.css +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Sphinx stylesheet -- nature theme. - */ - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: Arial, sans-serif; - font-size: 100%; - background-color: #fff; - color: #555; - margin: 0; - padding: 0; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 230px; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.document { - background-color: #eee; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 30px 30px; - font-size: 0.9em; -} - -div.footer { - color: #555; - width: 100%; - padding: 13px 0; - text-align: center; - font-size: 75%; -} - -div.footer a { - color: #444; - text-decoration: underline; -} - -div.related { - background-color: #6BA81E; - line-height: 32px; - color: #fff; - text-shadow: 0px 1px 0 #444; - font-size: 0.9em; -} - -div.related a { - color: #E2F3CC; -} - -div.sphinxsidebar { - font-size: 0.75em; - line-height: 1.5em; -} - -div.sphinxsidebarwrapper{ - padding: 20px 0; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: Arial, sans-serif; - color: #222; - font-size: 1.2em; - font-weight: normal; - margin: 0; - padding: 5px 10px; - background-color: #ddd; - text-shadow: 1px 1px 0 white -} - -div.sphinxsidebar h4{ - font-size: 1.1em; -} - -div.sphinxsidebar h3 a { - color: #444; -} - - -div.sphinxsidebar p { - color: #888; - padding: 5px 20px; -} - -div.sphinxsidebar p.topless { -} - -div.sphinxsidebar ul { - margin: 10px 20px; - padding: 0; - color: #000; -} - -div.sphinxsidebar a { - color: #444; -} - -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: sans-serif; - font-size: 1em; -} - -div.sphinxsidebar .searchformwrapper { - margin-left: 20px; - margin-right: 20px; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #005B81; - text-decoration: none; -} - -a:hover { - color: #E32E00; - text-decoration: underline; -} - -a:visited { - color: #551A8B; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: Arial, sans-serif; - background-color: #BED4EB; - font-weight: normal; - color: #212224; - margin: 30px 0px 10px 0px; - padding: 5px 0 5px 10px; - text-shadow: 0px 1px 0 white -} - -div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; } -div.body h2 { font-size: 150%; background-color: #C8D5E3; } -div.body h3 { font-size: 120%; background-color: #D8DEE3; } -div.body h4 { font-size: 110%; background-color: #D8DEE3; } -div.body h5 { font-size: 100%; background-color: #D8DEE3; } -div.body h6 { font-size: 100%; background-color: #D8DEE3; } - -a.headerlink { - color: #c60f0f; - font-size: 0.8em; - padding: 0 4px 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - background-color: #c60f0f; - color: white; -} - -div.body p, div.body dd, div.body li { - line-height: 1.5em; -} - -div.admonition p.admonition-title + p { - display: inline; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -nav.contents, -aside.topic, -div.topic { - background-color: #eee; -} - -div.warning { - background-color: #ffe4e4; - border: 1px solid #f66; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre { - padding: 10px; - line-height: 1.2em; - border: 1px solid #C6C9CB; - font-size: 1.1em; - margin: 1.5em 0 1.5em 0; - -webkit-box-shadow: 1px 1px 1px #d8d8d8; - -moz-box-shadow: 1px 1px 1px #d8d8d8; -} - -code { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ - font-size: 1.1em; - font-family: monospace; -} - -.viewcode-back { - font-family: Arial, sans-serif; -} - -div.viewcode-block:target { - background-color: #f4debf; - border-top: 1px solid #ac9; - border-bottom: 1px solid #ac9; -} - -div.code-block-caption { - background-color: #ddd; - color: #222; - border: 1px solid #C6C9CB; -} \ No newline at end of file diff --git a/docs/_static/stylesheet.css b/docs/_static/stylesheet.css index 8de6a21..80131a6 100644 --- a/docs/_static/stylesheet.css +++ b/docs/_static/stylesheet.css @@ -1,15 +1,55 @@ - -/* if content is to big in x or y direction specifiy scroll for overflow-y or overflow-x */ +/* Sidebar scrollable, no horizontal scrollbar */ .sphinxsidebarwrapper { overflow-y: auto; - overflow-x: auto; + overflow-x: hidden; +} + +/* Truncate long function names */ +.sphinxsidebarwrapper li > a { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -div.sphinxsidebar { - width: 300px; +/* Keep long signatures and code inside the content column. */ +div.documentwrapper { + min-width: 0; } -div.bodywrapper { - margin: 0 0 0 300px; +div.body { + overflow-wrap: anywhere; +} + +div.body .sig .pre { + white-space: normal; +} + +/* On narrow screens, give the content the full width and move navigation below it. */ +@media (max-width: 900px) { + div.document { + flex-direction: column; + } + + div.bodywrapper { + margin: 0; + } + + div.sphinxsidebar { + float: none; + width: auto; + margin: 0; + } + + div.body table.docutils { + display: block; + max-width: 100%; + overflow-x: auto; + } + + div.body p, div.body dd, div.body li { + text-align: left; + } } diff --git a/docs/first_steps.html b/docs/first_steps.html index a6623f6..585e91c 100644 --- a/docs/first_steps.html +++ b/docs/first_steps.html @@ -7,16 +7,18 @@ First steps — hdhelpers - documentation - - - + + + - + + - + + +
- +

First steps

+

See Shared data setup for sample pandas inputs and examples of the +related getters.

How to get metadata with hdhelpers?

Let’s say we want to retrieve the metadata of a timeseries, which follows the hetida designer metadata conventions. In hetida designer this series can be represented as json for direct provisioning

{
-    "__hd_wrapped_data_object__": "SERIES",
-    "__metadata__": {
-        "dataset_metadata": {
-            "metric_key": "external_id",
-            "single_metric": "water.north.level",
-            "ref_interval_start_timestamp": "2020-01-01T08:10:00+00:00",
-            "ref_interval_end_timestamp": "2020-01-01T08:20:00+00:00",
-            "ref_interval_type": "closed"
+    "__hd_wrapped_data_object__": "SERIES",
+    "__metadata__": {
+        "dataset_metadata": {
+            "metric_key": "external_id",
+            "single_metric": "water.north.level",
+            "queried_metrics": ["water.north.level"],
+            "ref_interval_start_timestamp": "2020-01-01T08:10:00+00:00",
+            "ref_interval_end_timestamp": "2020-01-01T08:20:00+00:00",
+            "ref_interval_type": "closed"
         },
-        "metrics": [
+        "metrics": [
             {
-                "external_id": "water.north.level",
-                "name": "Water level north",
-                "short_display_name": "Water Level",
-                "value_dimensions": [{"column": "value", "unit": "cm"}],
-                "inherited": {"latitude": 51.43, "longitude": 7.58}
+                "id": "4c1f0e2a-7b3d-4e5f-9a8b-1c2d3e4f5a6b",
+                "external_id": "water.north.level",
+                "name": "Water level north",
+                "short_display_name": "Water Level",
+                "value_dimensions": [{"column": "value", "unit": "cm", "measurement": "level"}],
+                "inherited": {"latitude": 51.43, "longitude": 7.58}
             }
         ]
     },
-    "__data__": {
-        "2020-01-01T08:10:00+00:00": 1,
-        "2020-01-01T08:15:00+00:00": 2,
-        "2020-01-01T08:16:00+00:00": 3,
-        "2020-01-01T08:17:00+00:00": 4
+    "__data__": {
+        "2020-01-01T08:10:00+00:00": 1,
+        "2020-01-01T08:15:00+00:00": 2,
+        "2020-01-01T08:16:00+00:00": 3,
+        "2020-01-01T08:17:00+00:00": 4
     }
 }
 
@@ -83,14 +92,64 @@

How to get metadata with hdhelpers?# entrypoint function for this component # ***** DO NOT EDIT LINES ABOVE ***** - name = get_series_name(series) # "Water level north" - unit = get_series_unit(series) # "cm" - latitude = get_series_info(series, "latitude") # 51.43, inherited from the hierarchy + name = get_series_name(series) # "Water level north" + unit = get_series_unit(series) # "cm" + latitude = get_series_info(series, "latitude") # 51.43, inherited from the hierarchy + + ... +

+
+

or, equivalently, via the metadata as model:

+
from hdhelpers.metadata import read_metadata
+
+def main(*, series):
+    # entrypoint function for this component
+    # ***** DO NOT EDIT LINES ABOVE *****
+
+    meta = read_metadata(series)  # None if the series has no valid metadata
+    if meta is not None:
+        name = meta.name()  # "Water level north"
+        unit = meta.unit()  # "cm"
+        latitude = meta.metric_info("latitude")  # 51.43
 
     ...
 
+
+

Metadata as model

+

hdhelpers.metadata.conventions holds the models of the metadata conventions, one per data +type: SeriesMetadata, SingleTSFrameMetadata and MultiTSFrameMetadata. They are the +contract of the conventions: Parsing fails for metadata which does not follow them. +check_metadata_conventions reports the issues of such metadata at their locations.

+

read_metadata(obj) gives the metadata of a Series or DataFrame as model, or None if there is +no valid metadata. With required=True it raises a MetadataConventionError listing the +issues instead, for components which cannot work without metadata. The methods of the models +resolve the fallbacks described below, e.g. unit(), display_name(), metric_info(), +default_timezone(), recording() and queried_interval(). For a SERIES or +SINGLETSFRAME, the metric can be omitted. For a MULTITSFRAME, the methods take the metric, and +methods in plural like units() return the information of all metrics.

+

The hetida designer runtime parses the metadata of the workflow inputs once and attaches the +model to them (attach_metadata). The models are immutable, so that all objects pandas +derives from an input share the model instead of copying the metadata, which saves a lot of +time for metadata of many metrics. To change metadata, create a changed version, which shares +all unchanged parts, and set it on your result:

+
from hdhelpers.metadata import read_metadata, set_metadata
+
+def main(*, series):
+    # entrypoint function for this component
+    # ***** DO NOT EDIT LINES ABOVE *****
+
+    result = series / 100
+    meta = read_metadata(series, required=True)
+    set_metadata(result, meta.with_value_dimension(None, "value", unit="m"))
+
+    return {"result": result}
+
+
+

Since the model is no JSON value, json.dumps(obj.attrs) or obj.to_parquet() fail for +objects with attached metadata. Use attrs_to_json(obj.attrs) to get attrs in their JSON form.

+

Where does the information come from?

The metadata conventions allow to provide information on different levels. The functions @@ -120,69 +179,55 @@

Where does the information come from?include_inherited=True to use them as last resort anyway.

-

Entries which do not adhere to the conventions are skipped: metrics which are no objects or -whose field named by the metric key is no string, and value dimensions which are no objects or -whose column is no string. "value_dimensions" and "value_dimensions_shared" which are no -lists count as empty, a metric key which is no string as missing, i.e. "id" is used.

+

Metadata which does not follow the conventions provides no information at all: The functions +return None or empty results for it.

How to query nested information?

The information requested from get_metric_info, get_singlets_metric_info, -get_series_info and get_singlets_info is interpreted as a glom spec. A string is a -path in which dots separate the keys, so it also reaches into sub dictionaries. Let’s say the -metric of the series above additionally provides comments and information on its sensor (the -conventions allow additional fields at every level), and the label of one of its inherited -properties contains a dot:

+get_series_info and get_singlets_info (and from the corresponding methods of the models) +is a field or a path of fields separated by dots, which reaches into sub objects. Let’s say the +metric of the series above additionally provides information on its sensor (the conventions +allow additional fields at every level):

{
-    "external_id": "water.north.level",
-    "name": "Water level north",
-    "sensor": {
-        "manufacturer": "Acme",
-        "calibration": {"date": "2026-03-01", "offset": 0.02}
+    "id": "4c1f0e2a-7b3d-4e5f-9a8b-1c2d3e4f5a6b",
+    "external_id": "water.north.level",
+    "name": "Water level north",
+    "sensor": {
+        "manufacturer": "Acme",
+        "calibration": {"date": "2026-03-01", "offset": 0.02}
     },
-    "comments": [
-        {"content": "Gauge maintenance"},
-        {"content": "Sensor replaced"}
-    ],
-    "value_dimensions": [{"column": "value", "unit": "cm"}],
-    "inherited": {"latitude": 51.43, "operator.name": "Ruhrverband"}
+    "value_dimensions": [{"column": "value", "unit": "cm", "measurement": "level"}],
+    "inherited": {"latitude": 51.43}
 }
 

We can retrieve the nested information with the following code

-
from glom import Path
-from hdhelpers.metadata import get_series_info
+
from hdhelpers.metadata import get_series_info
 
 def main(*, series):
     # entrypoint function for this component
     # ***** DO NOT EDIT LINES ABOVE *****
 
-    manufacturer = get_series_info(series, "sensor.manufacturer")  # "Acme"
-    calibration_date = get_series_info(series, "sensor.calibration.date")  # "2026-03-01"
-    latitude = get_series_info(series, "inherited.latitude")  # 51.43, never the metric's own
-    firmware = get_series_info(series, "sensor.firmware")  # None, not provided
-
-    # any other glom spec, e.g. the content of each comment
-    comments = get_series_info(series, ("comments", ["content"]))
-    # ["Gauge maintenance", "Sensor replaced"]
-
-    # a key which itself contains dots requires a glom Path
-    operator = get_series_info(series, Path("operator.name"))  # "Ruhrverband"
+    manufacturer = get_series_info(series, "sensor.manufacturer")  # "Acme"
+    calibration_date = get_series_info(series, "sensor.calibration.date")  # "2026-03-01"
+    latitude = get_series_info(series, "inherited.latitude")  # 51.43, never the metric's own
+    firmware = get_series_info(series, "sensor.firmware")  # None, not provided
 
     ...
 

For a MULTITSFRAME, get_metric_info(multitsframe, "sensor.manufacturer") provides the manufacturer keyed by metric accordingly.

-

The fallbacks described above apply to the whole path, e.g. "sensor.manufacturer" is looked -up in the metric first, then in its value dimension "value" and so on. A path which is not -present yields None instead of an error. Note that a plain string like "operator.name" is -always split at its dots, so it does not find the inherited property "operator.name".

-

A spec may also transform the information, e.g. ("name", str.upper). Since information -provided as null counts as missing, such a spec may receive null: on the metric itself and, if -the metric does not provide the information, on the levels it falls back to. Errors raised by -the spec are not caught. So either let it handle None, or rather transform the information -after retrieving it.

+

The fallbacks described above apply to the whole path. get_series_info and +get_singlets_info search the value dimension first, then the metric (only for +"value"), then shared metadata. get_metric_info and get_singlets_metric_info +search the metric first, then its "value" dimension, then shared "value" metadata. +Each lookup can use inherited properties as a final fallback when enabled.

+

A missing path yields None. An explicit path such as "inherited.latitude" accesses +that property directly, even with include_inherited=False. Paths cannot address list +items or keys containing dots. Access those through the models, e.g. +read_metadata(series, required=True).metric().comments; see Metadata model reference.

Which time zone to compute in?

@@ -212,10 +257,10 @@

Which time zone to compute in?# ***** DO NOT EDIT LINES ABOVE ***** # compute: each day starts at midnight of the default time zone of the data, - # e.g. at midnight in "Europe/Berlin", and has 23 or 25 hours when switching + # e.g. at midnight in "Europe/Berlin", and has 23 or 25 hours when switching # from or to daylight saving time - timezone = get_series_default_timezone(series) or "UTC" - daily_sums = modify_timezone(series, to_timezone=timezone).resample("1D").sum() + timezone = get_series_default_timezone(series) or "UTC" + daily_sums = modify_timezone(series, to_timezone=timezone).resample("1D").sum() # view: show the timestamps in the time zone of the plot target settings daily_sums = modify_timezone(daily_sums) @@ -235,11 +280,11 @@

Which time zone to compute in?timezones = get_default_timezones(multitsframe) daily_sums = { - metric: group.set_index("timestamp")["value"] - .tz_convert(timezones[metric] or "UTC") - .resample("1D") + metric: group.set_index("timestamp")["value"] + .tz_convert(timezones[metric] or "UTC") + .resample("1D") .sum() - for metric, group in multitsframe.groupby("metric") + for metric, group in multitsframe.groupby("metric") } ... @@ -266,18 +311,18 @@

How to use hdhelpers for plotting? (tbd)series = modify_timezone(series) - fig = go.Figure([go.Scatter(x=series.index, y=series.values, marker={"color": resolve_color("ki.vision")})]) + fig = go.Figure([go.Scatter(x=series.index, y=series.values, marker={"color": resolve_color("ki.vision")})]) start, end = get_queried_interval(series) fig.update_xaxes(range=(start, end)) - full_title = get_series_name(series) or "Level" + full_title = get_series_name(series) or "Level" fig.update_layout(yaxis_title=full_title) set_dt_ticks(fig) set_agnostic_theme(fig) - return {"plot": fig} + return {"plot": fig}

@@ -293,12 +338,13 @@

How to use hdhelpers for plotting? (tbd)Table of Contents

Previous topic

-

hdhelpers

+

Installation

+
+
+

Next topic

+

Metadata examples

This Page

@@ -340,15 +391,18 @@

Navigation

modules |
  • - next |
  • +
  • + previous |
  • - +
    \ No newline at end of file diff --git a/docs/genindex.html b/docs/genindex.html index 42823ec..e354b26 100644 --- a/docs/genindex.html +++ b/docs/genindex.html @@ -6,15 +6,16 @@ Index — hdhelpers - documentation - - - + + + - + + - + +
    - +

    Index

    - D + A + | C + | D + | E | G | H | I + | L | M + | N + | Q | R | S + | U + | V | W - +
    -

    D

    +

    A

    +
    + +

    C

    + + + +
    + +

    D

    + + + +
    + +

    E

    + + +
    @@ -172,7 +237,23 @@

    H

    I

    + +
    + +

    L

    + +
    @@ -180,6 +261,34 @@

    I

    M

    + +
    +
  • MultiTSFrameDatasetMetadata (class in hdhelpers.metadata.conventions) +
  • +
  • MultiTSFrameMetadata (class in hdhelpers.metadata.conventions) +
  • +
    + +

    N

    + + + +
    + +

    Q

    + +

    R

    \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index f303374..2417a38 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,16 +7,17 @@ hdhelpers — hdhelpers - documentation - - - + + + - + + - + +
    - +

    hdhelpers

    @@ -57,45 +58,92 @@

    Introduction

    Functions

    -
    -

    metadata

    -

    Collection of functions to access metadata information from timeseries objects. -Metadata information can follow varying conventions as the package glom is used to -extract the requested information.

    +
    +

    metadata

    +

    The examples below use Shared data setup. See Metadata examples +for the return shapes and shared examples of the related getters. +See Metadata lookup basics for metric selection and missing-field behavior.

    +

    Access to the metadata of SERIES, SINGLETSFRAMEs and MULTITSFRAMEs

    +

    The metadata follows the hetida designer metadata conventions, see +hdhelpers.metadata.conventions for its models. read_metadata gives the metadata of an object as +model, whose methods resolve the fallbacks of the conventions. The get_* functions are thin +wrappers over these methods.

    +
    +
    +exception hdhelpers.metadata.MetadataConventionError(issues: list[MetadataConventionIssue])[source]
    +

    Bases: ValueError

    +

    Raised when required metadata is missing, invalid or of the wrong type.

    +
    +
    +issues
    +

    Individual problems with severity, location +and message.

    +
    +
    Type:
    +

    list[MetadataConventionIssue]

    +
    +
    +
    + +
    +
    -class hdhelpers.metadata.Recording(recording_mode=None, data_frequency=None, data_frequency_offset=None, data_frequency_timezone=None, recording_aggregation_method=None)
    +class hdhelpers.metadata.Recording(recording_mode: str | None = None, data_frequency: str | None = None, data_frequency_offset: str | None = None, data_frequency_timezone: str | None = None, recording_aggregation_method: str | None = None)[source]

    Bases: object

    -

    How the values of a metric are recorded, as given in its metadata

    -

    The fields mean the following:

    -
    -
    Parameters:
    -
      -
    • recording_mode (str | None)

    • -
    • data_frequency (str | None)

    • -
    • data_frequency_offset (str | None)

    • -
    • data_frequency_timezone (str | None)

    • -
    • recording_aggregation_method (str | None)

    • +

      Immutable recording settings for a metric: mode, frequency and interval alignment.

      +

      Metadata lookups use dataset frequency settings when the metric provides none. +Recording modes describe when values are recorded:

      +
        +
      • periodic: one value per interval, timestamped at its start. Missing values are gaps.

      • +
      • on_change: record changes exceeding the value dimension’s deadband. A value holds +until the next one, provided the heartbeat interval is not exceeded.

      • +
      • irregular: values at arbitrary points in time.

      -
    -
    recording_mode
    -

    “periodic”: one value per interval of data_frequency, timestamped with the start of the interval. A missing value is a gap. “on_change”: a value is recorded when it changes by more than the deadband of its value dimension and holds until the next one. Missing timestamps are no gaps. “irregular”: values at arbitrary points in time. None if unknown.

    +

    One of the modes above; None if unknown.

    Type:

    str | None

    @@ -106,7 +154,9 @@

    Functions
    data_frequency
    -

    Frequency as Pandas date offset alias or timedelta string, e.g. “15min”, “1D” or “1MS”. For “on_change”, the interval after which the value is recorded again at the latest, also without a change (heartbeat).

    +

    Pandas offset alias or timedelta string, e.g. “15min” +or “1MS”. For on_change, the maximum time between recordings (heartbeat), +even without a change. None if unknown.

    Type:

    str | None

    @@ -117,7 +167,8 @@

    Functions
    data_frequency_offset
    -

    Timedelta string by which the intervals of data_frequency are shifted, e.g. “5min”. None means 0.

    +

    Shift of the frequency intervals as a timedelta +string, e.g. “5min”. None means zero when a frequency is set.

    Type:

    str | None

    @@ -128,7 +179,8 @@

    Functions
    data_frequency_timezone
    -

    Time zone the intervals of data_frequency are aligned to, e.g. “Europe/Berlin”. None means UTC.

    +

    Time zone for aligning frequency intervals, +e.g. “Europe/Berlin”. None means UTC when a frequency is set.

    Type:

    str | None

    @@ -139,7 +191,8 @@

    Functions
    recording_aggregation_method
    -

    Only for “periodic”: the method each value aggregates its interval with, e.g. “MEAN” or “MAX”. None if the values are samples, e.g. the value holding at the timestamp.

    +

    For periodic values, how each interval +is aggregated, e.g. “MEAN” or “MAX”. None for samples taken at the timestamp.

    Type:

    str | None

    @@ -152,1301 +205,856 @@

    Functions -
    -hdhelpers.metadata.get_default_aggregation_methods(multitsframe)
    -

    Gets the method each metric of a MultiTSFrame should be aggregated with by default

    -

    E.g. for plotting or reporting it in coarser intervals. It is the “default_aggregation_method” -of the metric, with “AVG” normalized to its alias “MEAN”. If the metric does not provide one, -it is “LAST” for values of the data type “boolean” or “string” (the “value_data_type” of the -value dimension “value”), i.e. the state holding at the end of the interval, and “MEAN” -otherwise. It is not taken from inherited properties.

    +
    +hdhelpers.metadata.attach_metadata(obj: Series | DataFrame, data_type: Literal['SERIES', 'SINGLETSFRAME', 'MULTITSFRAME'] | None = None) → SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata | None[source]
    +

    Parse metadata in attrs and attach the resulting immutable model.

    +

    Replace convention fields with attrs[“hd_metadata”]; preserve other attrs. An already +attached model is returned unchanged.

    Parameters:
    -

    multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    +
      +
    • obj – Series or DataFrame to update in place.

    • +
    • data_type – Convention for raw attrs, or None to infer it; see +Automatic metadata type selection. Used only when no model is attached.

    • +
    Returns:
    -

    Dictionary, where keys are the entries of the metrics metadata specified via “metric_key” in “dataset_metadata” and values are their default aggregation methods, e.g. “MEAN”, “SUM” or “LAST”. Defaults to “MEAN” for metrics without metadata.

    +

    SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata | None – Attached model, or None for absent/invalid metadata. Failure leaves attrs +unchanged.

    -
    Return type:
    -

    defaultdict[str, str]

    +
    +

    Code example:

    +

    Using Shared data setup, attach a model while preserving unrelated attrs:

    +
    >>> data = series.copy()
    +>>> data.attrs["source"] = "example"
    +>>> meta = md.attach_metadata(data)
    +>>> meta.unit()
    +'m'
    +>>> sorted(data.attrs)
    +['hd_metadata', 'source']
    +>>> md.read_metadata(data) is meta
    +True
    +>>> md.attach_metadata(data) is meta
    +True
    +>>> md.attach_metadata(pd.Series([1.0])) is None
    +True
    +
    +
    +

    + +
    +
    +hdhelpers.metadata.attrs_to_json(attrs: Mapping[Any, Any]) → dict[Any, Any][source]
    +

    Replace attached metadata with its JSON fields in a new attrs dictionary.

    +

    Keep unrelated attrs as-is. Omit optional metadata fields set to None or their defaults.

    +
    +
    Parameters:
    +

    attrs – Pandas attrs mapping; left unchanged.

    -
    Raises:
    -

    TypeError – If multitsframe is not a DataFrame.

    +
    Returns:
    +

    dict[Any, Any] – Dictionary for json.dumps or export. Unrelated attrs must also be +JSON-compatible.

    Code example:

    -
    >>> multitsframe.attrs = {
    -...     "dataset_metadata": {"metric_key": "id"},
    -...     "metrics": [
    -...         {"id": "level", "default_aggregation_method": "AVG"},
    -...         {"id": "rainfall", "default_aggregation_method": "SUM"},
    -...         {
    -...             "id": "pump_running",
    -...             "value_dimensions": [{"column": "value", "value_data_type": "boolean"}],
    -...         },
    -...     ],
    -... }
    ->>> methods = get_default_aggregation_methods(multitsframe)
    ->>> methods["level"], methods["rainfall"], methods["pump_running"]
    -('MEAN', 'SUM', 'LAST')
    ->>> methods["metric_without_metadata"]
    -'MEAN'
    +

    Using Shared data setup, convert attached models to JSON-compatible dictionaries. +This function returns a dictionary; json.dumps produces the JSON string:

    +
    >>> import json
    +>>> data = series.copy()
    +>>> _ = md.attach_metadata(data)
    +>>> attrs = md.attrs_to_json(data.attrs)
    +>>> isinstance(attrs, dict), "hd_metadata" in attrs
    +(True, False)
    +>>> encoded = json.dumps(attrs)
    +>>> json.loads(encoded)["metrics"][0]["value_dimensions"][0]["unit"]
    +'m'
    +>>> export = data.copy()
    +>>> export.attrs = md.attrs_to_json(export.attrs)
    +>>> "hd_metadata" in data.attrs, "hd_metadata" in export.attrs
    +(True, False)
     
    +

    The export copy can now be written with export.to_frame().to_parquet("series.parquet") +if a pandas Parquet engine is installed. The original keeps its attached model.

    -
    -hdhelpers.metadata.get_default_timezones(multitsframe)
    -

    Gets the default time zone of each metric of a MultiTSFrame

    -

    The default time zone is meant for computations: It is the time zone the timeseries should -be evaluated in by default, e.g. for bucketing days when aggregating, so that each day -starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of -the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the -time zone of the plot target settings is for, which e.g. hdhelpers.helpers.modify_timezone() -uses by default. See “Which time zone to compute in?” in the first steps.

    -

    The time zone is an IANA time zone name like “Europe/Berlin” or a fixed UTC offset like -“+02:00”, both accepted by pandas, e.g. by tz_convert.

    -

    The default time zone of a metric falls back to the one of the dataset metadata, which -applies to all metrics without their own. It does not fall back to value dimensions or -inherited properties.

    +
    +hdhelpers.metadata.check_metadata_conventions(obj: Series | DataFrame | Mapping[str, Any], data_type: Literal['SERIES', 'SINGLETSFRAME', 'MULTITSFRAME'] | None = None) → list[MetadataConventionIssue][source]
    +

    Report metadata errors and warnings, optionally checking against pandas data.

    +

    For pandas inputs, also check metric IDs, value columns and time bounds. An empty list +means no issues; warnings alone do not make metadata invalid.

    Parameters:
    -

    multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    +
      +
    • obj – Series/DataFrame to check, or a mapping containing metadata.

    • +
    • data_type – Convention to check; required for mappings. None infers it from pandas input; +see Automatic metadata type selection.

    • +
    Returns:
    -

    Dictionary, where keys are the entries of the metrics metadata specified via “metric_key” in “dataset_metadata” and values are their default time zones. Defaults to the default time zone of the dataset metadata, also for metrics without metadata, and to None if that is missing as well.

    +

    list[MetadataConventionIssue] – Issues with severity, location and message. Invalid metadata is reported as +issues.

    -
    Return type:
    -

    defaultdict[str, str | None]

    -
    -
    Raises:
    -

    TypeError – If multitsframe is not a DataFrame.

    +
    Raises:
    +

    ValueError – A metadata mapping is provided without data_type.

    Code example:

    -
    >>> multitsframe.attrs = {
    -...     "dataset_metadata": {"metric_key": "id", "default_timezone": "Europe/Berlin"},
    -...     "metrics": [
    -...         {"id": "new_york_sensor", "default_timezone": "America/New_York"},
    -...         {"id": "berlin_sensor"},
    -...     ],
    -... }
    ->>> timezones = get_default_timezones(multitsframe)
    ->>> timezones["new_york_sensor"]
    -'America/New_York'
    ->>> timezones["berlin_sensor"]
    -'Europe/Berlin'
    ->>> timezones["metric_without_metadata"]
    -'Europe/Berlin'
    +

    Using Shared data setup, validate the data and its metadata together, or pass +plain metadata with an explicit data type. Issues describe the severity, location and cause:

    +
    >>> md.check_metadata_conventions(series)
    +[]
    +>>> md.check_metadata_conventions(series.attrs, "SERIES")
    +[]
    +>>> invalid = series.copy()
    +>>> invalid.attrs["metrics"][0]["default_timezone"] = "Mars/Base"
    +>>> issues = md.check_metadata_conventions(invalid)
    +>>> [(issue.severity.value, issue.location) for issue in issues]
    +[('error', 'metrics[0].default_timezone')]
    +>>> print(issues[0].message)
    +"Mars/Base" is neither an IANA time zone name like "Europe/Berlin" nor a UTC offset...
     
    -
    -hdhelpers.metadata.get_display_names(multitsframe, *, include_inherited=False)
    -

    Gets display names of the MTSF metrics from the metadata

    +
    +hdhelpers.metadata.copy_without_metadata(data: T) → T[source]
    +

    Copy a Series or DataFrame with all attrs removed.

    +

    Data is shared until either object is modified (pandas copy on write).

    Parameters:
    -
      -
    • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • -
    +

    data (pd.Series | pd.DataFrame) – Source object; left unchanged.

    Returns:
    -

    Dictionary of metrics containing the display names. -If the display name of the metrics is not present it returns the result of hdhelpers.metadata.get_names(). -For metadata following the current convention, the display name is looked up on the value dimension, on -the metric (for the value dimension “value”) and in “value_dimensions_shared” before falling back to the -name. Fields provided as null count as missing.

    +

    pd.Series | pd.DataFrame – Copy with empty attrs.

    -
    Return type:
    -

    defaultdict[str, defaultdict[str, str | None]]

    +
    +

    Code example:

    +
    >>> multitsframe.attrs = {"dataset_metadata": {"metric_key": "id"}, "metrics": []}
    +>>> data = md.copy_without_metadata(multitsframe)
    +>>> data.attrs
    +{}
    +>>> multitsframe.attrs
    +{'dataset_metadata': {'metric_key': 'id'}, 'metrics': []}
    +
    +
    +
    + +
    +
    +hdhelpers.metadata.get_default_aggregation_methods(multitsframe: DataFrame) → defaultdict[str, str][source]
    +

    Read the default aggregation method for each metric.

    +

    Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata +value_data_type selects LAST for boolean/string and MEAN for other types.

    +
    +
    Parameters:
    +

    multitsframe – DataFrame with per-metric metadata in attrs.

    -
    Raises:
    -

    TypeError – If multitsframe is not a DataFrame.

    +
    Returns:
    +

    defaultdict[str, str] – Method names by metric ID; missing IDs default to MEAN.

    Code example:

    -
    >>> attr = { "by_metric": { "metric1": {"metric": {"display_name": "display_name_of_metric1"}},
    -...                         "metric2": {"metric": {"name": "name_of_metric2"}}}}
    ->>> dataframe.attrs = attr
    ->>> result = get_display_names(dataframe)
    ->>> result["metric1"]["value"]
    -'display_name_of_metric1'
    ->>> result["metric2"]["value"]
    -'name_of_metric2'
    +
    >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE
    +>>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE
    +>>> methods = md.get_default_aggregation_methods(multitsframe)
    +>>> methods["plant_a.temperature"], methods["plant_a.pressure"]
    +('MAX', 'MEAN')
     
    -

    Lets try another MTSF format:

    -
    >>> attr = { "dataset_metadata": {"metric_key": "external_id"},
    -...          "metrics": [{"external_id": "ruhr-temperature",
    -...                       "name": "Ruhr temperature [°C]",
    -...                       "display_name": "temperature [°C]",
    -...                       "short_display_name": "[°C]",
    -...                       "value_dimensions": [{"column": "temp", "measurement": "temperature", "unit": "°C"}]}]}
    ->>> dataframe.attrs = attr
    ->>> result = get_display_names(dataframe)
    ->>> result["ruhr-temperature"]["value"]
    -'temperature [°C]'
    +

    See Default aggregation methods for shared examples and return shapes.

    +
    + +
    +
    +hdhelpers.metadata.get_default_timezones(multitsframe: DataFrame) → defaultdict[str, str | None][source]
    +

    Read the time zone recommended for calculations on each metric.

    +

    Use the metric’s default_timezone, then the dataset’s default_timezone.

    +
    +
    Parameters:
    +

    multitsframe – DataFrame with per-metric metadata in attrs.

    +
    +
    Returns:
    +

    defaultdict[str, str | None] – Zone names or UTC offsets by metric ID, or None for unresolved zones. Missing +IDs use the dataset default.

    +
    +
    +

    Code example:

    +
    >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE
    +>>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE
    +>>> md.get_default_timezones(multitsframe)["plant_a.pressure"]
    +'Europe/Berlin'
     
    +

    See Default time zones for shared examples and return shapes.

    -
    -hdhelpers.metadata.get_measurements(multitsframe, *, include_inherited=False)
    -

    Gets measurement (type) of the MTSF metrics from the metadata

    +
    +hdhelpers.metadata.get_display_names(multitsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, defaultdict[str, str | None]][source]
    +

    Read display labels for each metric and value column.

    +

    Use display_name, then name.

    Parameters:
      -
    • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • multitsframe – DataFrame with per-metric metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of metrics containing the measurement (type) of the MTSF metrics. -If the short measurement (type) of the MTSF metrics is not present it returns None.

    +

    defaultdict[str, defaultdict[str, str | None]] – Labels as result[metric_id][column]; unavailable labels are None.

    -
    Return type:
    -

    defaultdict[str, defaultdict[str, str | None]]

    +
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    +
    + +
    +
    +hdhelpers.metadata.get_measurements(multitsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, defaultdict[str, str | None]][source]
    +

    Read physical quantities for each metric and value column.

    +

    For example, “temperature” is a measurement and “°C” is its unit.

    +
    +
    Parameters:
    +
      +
    • multitsframe – DataFrame with per-metric metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    • +
    -
    Raises:
    -

    TypeError – If multitsframe is not a DataFrame.

    +
    Returns:
    +

    defaultdict[str, defaultdict[str, str | None]] – Quantities as result[metric_id][column]; unavailable quantities are None.

    -

    Code example:

    -
    >>> attr = { "dataset_metadata": {"metric_key": "external_id"},
    -...          "metrics": [{"external_id": "column_name", "value_dimensions": [{"column": "temp", "measurement": "temperature"}]}]}
    ->>> dataframe.attrs = attr
    ->>> result = get_measurements(dataframe)
    ->>> result["column_name"]["temp"]
    -'temperature'
    -
    -
    +

    See Units, measurements and return shapes for shared examples and return shapes.

    -hdhelpers.metadata.get_metric_info(multitsframe, metric_info, *, include_inherited=True)
    -

    Gets a dictionary of metadata associated to metrics.

    -

    In contrast to metadata associated to concrete value dimensions, this -function abstracts access to metadata associated to the underlying metric.

    -

    The information is looked up in the metric itself, then in its value dimension “value”, -then in “value_dimensions_shared” and finally in the properties the metric inherits from -objects higher up in an asset hierarchy (given under “inherited” in the metric metadata), -e.g. the latitude of the plant a sensor belongs to. The latter can be switched off via -include_inherited. Information provided as null counts as missing. Note that in contrast -to this function, the functions for dedicated information like get_units() or -get_display_names() do not fall back to inherited properties by default.

    +hdhelpers.metadata.get_metric_info(multitsframe: DataFrame, metric_info: str, *, include_inherited: bool = True) → defaultdict[str, Any][source] +

    Read a metadata field for each metric.

    +

    Search the metric, its “value” column, shared “value” metadata, then inherited fields +(if enabled).

    Parameters:
      -
    • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    • -
    • metric_info (str | Spec) – Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like “sensor.manufacturer” into sub dictionaries or any other glom Spec. See “How to query nested information?” in the first steps.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the metric, its value dimension “value” nor “value_dimensions_shared” provide the information. Defaults to True.

    • +
    • multitsframe – DataFrame with per-metric metadata in attrs.

    • +
    • metric_info – Field name or dotted path, e.g. “sensor.model”.

    • +
    • include_inherited – Use inherited fields as a last fallback.

    Returns:
    -

    Dictionary, where keys are the entries of the metrics metadata specified via “metric_key” in “dataset_metadata” and values are the entries specified via “metric_info” in the metrics metadata. Defaults to None, also if there is no metric metadata at all.

    -
    -
    Return type:
    -

    defaultdict[str, Any]

    +

    defaultdict[str, Any] – Field values by metric ID; unresolved fields are None.

    Code example:

    -
    >>> multitsframe.attrs = {
    -...    "dataset_metadata": {
    -...        "metric_key": "id"
    -...    },
    -...    "metrics": [
    -...        {
    -...            "id": "first",
    -...            "external_id": "external_first",
    -...            "unit": "m",
    -...            "display_name": "first display name",
    -...            "sensor": {"manufacturer": "Acme"},
    -...            "value_dimensions": [
    -...                {
    -...                    "column": "temp",
    -...                    "unit": "C",
    -...                    "measurement": "temperature"
    -...                }
    -...            ]
    -...        },
    -...        {
    -...            "id": "second",
    -...            "name": "second name",
    -...            "external_id": "external_second",
    -...            "value_dimensions": [
    -...                {
    -...                    "column": "temp",
    -...                    "unit": "C"
    -...                }
    -...            ]
    -...        }
    -...    ]
    -... }
    ->>> result = get_metric_info(multitsframe, "external_id")
    ->>> result["first"]
    -'external_first'
    ->>> result["second"]
    -'external_second'
    ->>> result["not-given"] is None
    -True
    -
    -
    -

    A dotted path reaches into sub dictionaries:

    -
    >>> result = get_metric_info(multitsframe, "sensor.manufacturer")
    ->>> result["first"]
    -'Acme'
    ->>> result["second"] is None
    -True
    +
    >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE
    +>>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE
    +>>> md.get_metric_info(multitsframe, "recording_mode")["plant_a.temperature"]
    +'on_change'
     
    +

    See Metric fields and value-dimension fields for shared examples and return shapes.

    -hdhelpers.metadata.get_names(multitsframe, *, include_inherited=False)
    -

    Gets names of the MTSF metrics from the metadata

    +hdhelpers.metadata.get_names(multitsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, defaultdict[str, str | None]][source] +

    Read metadata names for each metric and value column.

    +

    For the “value” column, try the metric’s name after the column’s own name.

    Parameters:
      -
    • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • multitsframe – DataFrame with per-metric metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of metrics containing the names. If the name is not present for a metric the corresponding value is None.

    -
    -
    Return type:
    -

    dict[str, str | None]

    -
    -
    Raises:
    -

    TypeError – If multitsframe is not a DataFrame.

    +

    defaultdict[str, defaultdict[str, str | None]] – Names as result[metric_id][column]; unavailable names are None.

    Code example:

    -
    >>> attr = { "by_metric": { "metric1": {"metric": {"name": "name_of_metric1"}},
    -...                        "metric2": {"metric": {"name": None }} }}
    ->>> dataframe.attrs = attr
    ->>> result = get_names(dataframe)
    ->>> result["metric1"]["value"]
    -'name_of_metric1'
    ->>> result["metric2"]["value"] is None
    -True
    -
    -
    -

    Lets try another MTSF format:

    -
    >>> attr = { "dataset_metadata": {"metric_key": "external_id"},
    -...          "metrics": [{"external_id": "ruhr-temperature",
    -...                       "name": "Ruhr temperature [°C]",
    -...                       "display_name": "temperature [°C]",
    -...                       "short_display_name": "[°C]",
    -...                       "value_dimensions": [{"column": "temp", "measurement": "temperature", "unit": "°C"}]}]}
    ->>> dataframe.attrs = attr
    ->>> result = get_names(dataframe)
    ->>> result["ruhr-temperature"]["value"]
    -'Ruhr temperature [°C]'
    +
    >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE
    +>>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE
    +>>> md.get_names(multitsframe)["plant_a.pressure"]["value"]
    +'Plant A pressure'
     
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    -hdhelpers.metadata.get_queried_interval(data)
    -

    Get queried interval from metadata

    +hdhelpers.metadata.get_queried_interval(data: Series | DataFrame) → tuple[datetime | None, datetime | None][source] +

    Read the requested UTC time interval from dataset metadata.

    Parameters:
    -

    data (pd.Series | pd.DataFrame) – Series or Dataframe with metadata following the convention

    +

    data – Series or DataFrame with dataset metadata in attrs.

    Returns:
    -

    Tuple of available start and end date of requested interval.

    -
    -
    Return type:
    -

    tuple[datetime.datetime | None, datetime.datetime | None]

    -
    -
    Raises:
    -
      -
    • ValueError – If metadata of data is not None and not convertible to a datetime-object (ISO-format is expected).

    • -
    • TypeError – If data is not a Series or Dataframe.

    • -
    +

    tuple[datetime.datetime | None, datetime.datetime | None] – (start, end) as timezone-aware datetimes, or (None, None) if unavailable.

    Code example:

    -
    >>> attr = {
    -...        "dataset_metadata": {
    -...        "ref_interval_start_timestamp": "2025-11-05T13:28:00Z",
    -...        "ref_interval_end_timestamp": "2025-11-06T13:28:00Z"
    -...    }
    -... }
    ->>> series.attrs = attr
    ->>> start, end = get_queried_interval(series)
    +
    >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE
    +>>> series.attrs = SERIES_METADATA_EXAMPLE
    +>>> start, end = md.get_queried_interval(series)
     >>> start.isoformat()
    -'2025-11-05T13:28:00+00:00'
    ->>> end.isoformat()
    -'2025-11-06T13:28:00+00:00'
    +'2026-01-01T00:00:00+00:00'
     
    +

    See Units, measurements and return shapes for shared examples and return shapes.

    -hdhelpers.metadata.get_recordings(multitsframe)
    -

    Gets how the values of each metric of a MultiTSFrame are recorded

    -

    The recording consists of the “recording_mode” of the metric (e.g. “periodic” or -“on_change”), the frequency of its values with offset and time zone and its -“recording_aggregation_method”, see Recording for what they mean.

    -

    The frequency falls back as a whole: If the metric has a “data_frequency”, it applies -together with the metric’s “data_frequency_offset” and “data_frequency_timezone”. Otherwise -“ref_data_frequency” of the dataset metadata applies together with -“ref_data_frequency_offset” and “ref_data_frequency_timezone”. The values are provided as -given in the metadata.

    +hdhelpers.metadata.get_recordings(multitsframe: DataFrame) → defaultdict[str, Recording][source] +

    Read recording settings for each metric.

    +

    Use the metric’s frequency, offset and time zone together. If its frequency is missing, +use the dataset’s three fields instead.

    Parameters:
    -

    multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    +

    multitsframe – DataFrame with per-metric metadata in attrs.

    Returns:
    -

    Dictionary, where keys are the entries of the metrics metadata specified via “metric_key” in “dataset_metadata” and values are their recordings. Defaults to a recording with the frequency of the dataset metadata, also for metrics without metadata.

    -
    -
    Return type:
    -

    defaultdict[str, Recording]

    -
    -
    Raises:
    -

    TypeError – If multitsframe is not a DataFrame.

    +

    defaultdict[str, Recording] – Settings by metric ID; missing IDs use dataset frequency settings.

    Code example:

    -
    >>> multitsframe.attrs = {
    -...     "dataset_metadata": {"metric_key": "id", "ref_data_frequency": "1h"},
    -...     "metrics": [
    -...         {
    -...             "id": "average_level",
    -...             "recording_mode": "periodic",
    -...             "data_frequency": "15min",
    -...             "recording_aggregation_method": "MEAN",
    -...         },
    -...         {"id": "pump_state", "recording_mode": "on_change"},
    -...     ],
    -... }
    ->>> recordings = get_recordings(multitsframe)
    ->>> recordings["average_level"].data_frequency
    -'15min'
    ->>> recordings["pump_state"].recording_mode
    -'on_change'
    ->>> recordings["pump_state"].data_frequency
    -'1h'
    +
    >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE
    +>>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE
    +>>> md.get_recordings(multitsframe)["plant_a.temperature"].recording_mode
    +'on_change'
     
    +

    See Recording information for shared examples and return shapes.

    -hdhelpers.metadata.get_series_default_aggregation_method(series)
    -

    Gets the method a Series should be aggregated with by default

    -

    See get_default_aggregation_methods() for the method and its default.

    +hdhelpers.metadata.get_series_default_aggregation_method(series: Series) → str[source] +

    Read the default aggregation method for this Series.

    +

    Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata +value_data_type selects LAST for boolean/string and MEAN for other types.

    Parameters:
    -

    series (pd.Series) – Series with metadata following the convention.

    +

    series – Series with metadata in attrs.

    Returns:
    -

    The default aggregation method, e.g. “MEAN”, “SUM” or “LAST”.

    -
    -
    Return type:
    -

    str

    -
    -
    Raises:
    -

    TypeError – If series is not a Series.

    +

    str – Method name; MEAN if metadata is unavailable.

    -

    Code example:

    -
    >>> series.attrs = {
    -...     "dataset_metadata": {"single_metric": "rainfall"},
    -...     "metrics": [{"id": "rainfall", "default_aggregation_method": "SUM"}],
    -... }
    ->>> get_series_default_aggregation_method(series)
    -'SUM'
    ->>> series.attrs = {}
    ->>> get_series_default_aggregation_method(series)
    -'MEAN'
    -
    -
    +

    See Default aggregation methods for shared examples and return shapes.

    -hdhelpers.metadata.get_series_default_timezone(series)
    -

    Gets the default time zone of a Series

    -

    The default time zone is meant for computations: It is the time zone the timeseries should -be evaluated in by default, e.g. for bucketing days when aggregating, so that each day -starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of -the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the -time zone of the plot target settings is for, which e.g. hdhelpers.helpers.modify_timezone() -uses by default. See “Which time zone to compute in?” in the first steps.

    -

    The time zone is an IANA time zone name like “Europe/Berlin” or a fixed UTC offset like -“+02:00”, both accepted by pandas, e.g. by tz_convert.

    -

    The default time zone of the metric falls back to the one of the dataset metadata.

    +hdhelpers.metadata.get_series_default_timezone(series: Series) → str | None[source] +

    Read the time zone recommended for calculations on this Series.

    +

    Use the metric’s default_timezone, then the dataset’s default_timezone.

    Parameters:
    -

    series (pd.Series) – Series with metadata following the convention.

    +

    series – Series with metadata in attrs.

    Returns:
    -

    The default time zone, or None if neither the metric nor the dataset metadata provide one.

    -
    -
    Return type:
    -

    str | None

    -
    -
    Raises:
    -

    TypeError – If series is not a Series.

    +

    str | None – Zone name or UTC offset, or None when neither source provides one.

    -

    Code example:

    -
    >>> series.attrs = {
    -...     "dataset_metadata": {"single_metric": "water.north.level", "metric_key": "external_id"},
    -...     "metrics": [{"external_id": "water.north.level", "default_timezone": "Europe/Berlin"}],
    -... }
    ->>> get_series_default_timezone(series)
    -'Europe/Berlin'
    ->>> series.attrs = {}
    ->>> get_series_default_timezone(series) is None
    -True
    -
    -
    +

    See Default time zones for shared examples and return shapes.

    -hdhelpers.metadata.get_series_display_name(series, *, include_inherited=False)
    -

    Gets display name of the series from metadata

    +hdhelpers.metadata.get_series_display_name(series: Series, *, include_inherited: bool = False) → str | None[source] +

    Read the display label for this Series.

    +

    Use display_name, then name.

    Parameters:
      -
    • series (pd.Series) – Series with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • series – Series with metadata in attrs.

    • +
    • include_inherited – Use inherited fields as a last fallback.

    Returns:
    -

    Returns the display name of the value. -If the display name of the metric is not present it returns the display name of the value. -If the metric display name is not present it returns the result of hdhelpers.metadata.get_series_name().

    -
    -
    Return type:
    -

    str | None

    -
    -
    Raises:
    -

    TypeError – If series is not a Series.

    +

    str | None – Resolved label, or None when unavailable.

    -

    Code example:

    -
    >>> attr = { "by_metric": { "series": {"metric": {"display_name": "display_name_of_series"}}}}
    ->>> series.attrs = attr
    ->>> get_series_display_name(series)
    -'display_name_of_series'
    -
    -
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    -hdhelpers.metadata.get_series_info(series, value_dim_info, *, include_inherited=True)
    -

    Gets an arbitrary series info

    -

    Since a series has only one value dimension named “value”, this information is -equivalent to information on the metric.

    +hdhelpers.metadata.get_series_info(series: Series, value_dim_info: str, *, include_inherited: bool = True) → Any[source] +

    Read a metadata field for this Series.

    +

    Search the “value” column, the metric, shared “value” metadata, then inherited fields +(if enabled).

    Parameters:
      -
    • series (pd.Series) – Series with metadata following the convention.

    • -
    • value_dim_info (str | Spec) – Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like “sensor.manufacturer” into sub dictionaries or any other glom Spec. See “How to query nested information?” in the first steps.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to True.

    • +
    • series – Series with metadata in attrs.

    • +
    • value_dim_info – Field name or dotted path, e.g. “sensor.model”.

    • +
    • include_inherited – Use inherited fields as a last fallback.

    Returns:
    -

    Retrieved information defined by value_dim_info

    -
    -
    Return type:
    -

    Any

    +

    Any – Field value, or None when unavailable.

    Code example:

    -
    >>> attr = { "by_metric": { "series": {"value_dimensions": {"value": {"unit": "m"}}}}}
    ->>> series.attrs = attr
    ->>> get_series_info(series, "unit")
    -'m'
    ->>> get_series_info(series, "not-given") is None
    -True
    +
    >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE
    +>>> series.attrs = SERIES_METADATA_EXAMPLE
    +>>> md.get_series_info(series, "decimals")
    +2
     
    +

    See Metric fields and value-dimension fields for shared examples and return shapes.

    -hdhelpers.metadata.get_series_measurement(series, *, include_inherited=False)
    -

    Gets measurement (type) of the Series from metadata

    +hdhelpers.metadata.get_series_measurement(series: Series, *, include_inherited: bool = False) → str | None[source] +

    Read this Series’ physical quantity from metadata.

    +

    For example, “temperature” describes the quantity and “°C” describes its unit.

    Parameters:
      -
    • series (pd.Series) – Series with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • series – Series with metadata in attrs.

    • +
    • include_inherited – Use inherited fields as a last fallback.

    Returns:
    -

    Returns the measurement (type) of the value. -If “measurement” is not given in the metadata, None is returned.

    -
    -
    Return type:
    -

    str | None

    -
    -
    Raises:
    -

    TypeError – If series is not a Series.

    +

    str | None – Quantity name, or None when unavailable.

    -

    Code example:

    -
    >>> attr = { "by_metric": { "series": { "metric": {"measurement": "temperature"}}}}
    ->>> series.attrs = attr
    ->>> get_series_measurement(series)
    -'temperature'
    -
    -
    +

    See Units, measurements and return shapes for shared examples and return shapes.

    -hdhelpers.metadata.get_series_name(series, *, include_inherited=False)
    -

    Gets name of the series from metadata

    +hdhelpers.metadata.get_series_name(series: Series, *, include_inherited: bool = False) → str | None[source] +

    Read this Series’ name from metadata.

    +

    Try the “value” column’s name, then the metric’s name.

    Parameters:
      -
    • series (pd.Series) – Series with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • series – Series with metadata in attrs.

    • +
    • include_inherited – Use inherited fields as a last fallback.

    Returns:
    -

    Returns the name of the value. -If the name of the metric is not present it returns the name of the value. -If the value name is not present it returns None.

    -
    -
    Return type:
    -

    str | None

    -
    -
    Raises:
    -

    TypeError – If series is not a Series.

    +

    str | None – Resolved name, or None when unavailable.

    -

    Let’s test what happens if series has name in value_dimensions:

    -
    >>> attr = { "by_metric": { "series": {"value_dimensions": {"value": {"name": "value_name_of_series"}}}}}
    ->>> series.attrs = attr
    ->>> get_series_name(series)
    -'value_name_of_series'
    -
    -
    -

    Let’s test what happens if series has name in metric:

    -
    >>> attr = { "by_metric": { "series": {"metric": {"name": "name_of_series_1"}}}}
    ->>> series.attrs = attr
    ->>> get_series_name(series)
    -'name_of_series_1'
    -
    -
    -

    Let’s test what happens if series has name in metric and value_dimensions:

    -
    >>> attr = { "single_metric_metadata": { "structured_metadata": {"metric": {"name": "name_of_series_2"}},
    -...                                                              "value_dimensions": {"value": {"name": "value_name_of_series"}}}}
    ->>> series.attrs = attr
    ->>> get_series_name(series)
    -'name_of_series_2'
    +

    Code example:

    +
    >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE
    +>>> series.attrs = SERIES_METADATA_EXAMPLE
    +>>> md.get_series_name(series)
    +'Water level north'
     
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    -hdhelpers.metadata.get_series_recording(series)
    -

    Gets how the values of a Series are recorded

    -

    See get_recordings() for the recording and how its frequency falls back to the one of -the dataset metadata.

    +hdhelpers.metadata.get_series_recording(series: Series) → Recording[source] +

    Read recording settings for this Series.

    +

    Use the metric’s frequency, offset and time zone together. If its frequency is missing, +use the dataset’s three fields instead.

    Parameters:
    -

    series (pd.Series) – Series with metadata following the convention.

    +

    series – Series with metadata in attrs.

    Returns:
    -

    The recording of the metric, with the frequency of the dataset metadata if the metric does not provide one.

    -
    -
    Return type:
    -

    Recording

    -
    -
    Raises:
    -

    TypeError – If series is not a Series.

    +

    Recording – Recording object; absent metadata yields its default field values.

    -

    Code example:

    -
    >>> series.attrs = {
    -...     "dataset_metadata": {"single_metric": "water.north.level", "metric_key": "external_id"},
    -...     "metrics": [
    -...         {
    -...             "external_id": "water.north.level",
    -...             "recording_mode": "periodic",
    -...             "data_frequency": "2h",
    -...             "data_frequency_timezone": "Europe/Berlin",
    -...         }
    -...     ],
    -... }
    ->>> recording = get_series_recording(series)
    ->>> recording.data_frequency, recording.data_frequency_offset, recording.data_frequency_timezone
    -('2h', None, 'Europe/Berlin')
    -
    -
    +

    See Recording information for shared examples and return shapes.

    -hdhelpers.metadata.get_series_short_display_name(series, *, include_inherited=False)
    -

    Gets short display name of the Series from metadata

    +hdhelpers.metadata.get_series_short_display_name(series: Series, *, include_inherited: bool = False) → str | None[source] +

    Read the short display label for this Series.

    +

    Use short_display_name, then display_name, then name.

    Parameters:
      -
    • series (pd.Series) – Series with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • series – Series with metadata in attrs.

    • +
    • include_inherited – Use inherited fields as a last fallback.

    Returns:
    -

    Returns the short display name of the value. -If the short display name of the metric is not present it returns the short display name of the value. -If the metric short display name is not present it returns the result of hdhelpers.metadata.get_series_display_name().

    -
    -
    Return type:
    -

    str | None

    -
    -
    Raises:
    -

    TypeError – If series is not a Series.

    +

    str | None – Resolved label, or None when unavailable.

    -

    Code example:

    -
    >>> attr = { "by_metric": { "series": { "metric": {"short_display_name": "short_display_name_of_series"}}}}
    ->>> series.attrs = attr
    ->>> get_series_short_display_name(series)
    -'short_display_name_of_series'
    -
    -
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    -hdhelpers.metadata.get_series_unit(series, *, include_inherited=False)
    -

    Gets name of the series from metadata

    +hdhelpers.metadata.get_series_unit(series: Series, *, include_inherited: bool = False) → str | None[source] +

    Read this Series’ unit from metadata.

    Parameters:
      -
    • series (pd.Series) – Series with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • series – Series with metadata in attrs.

    • +
    • include_inherited – Use inherited fields as a last fallback.

    Returns:
    -

    Returns the unit of series. -If the unit of the series is not present it returns None.

    -
    -
    Return type:
    -

    str | None

    -
    -
    Raises:
    -

    TypeError – If series is not a Series.

    +

    str | None – Unit string; “” means dimensionless and None means unavailable.

    -

    Let’s test what happens if series has no attr:

    -
    >>> series.attrs = {}
    ->>> get_series_unit(series) is None
    -True
    -
    -
    -

    Let’s test what happens if series has attr but no entry for unit:

    -
    >>> attr = { "by_metric": { "series": {"value_dimensions": {"value": {"unit":None}}}}}
    ->>> series.attrs = attr
    ->>> get_series_unit(series) is None
    -True
    -
    -
    -

    Let’s test what happens if series has unit in attr:

    -
    >>> attr = { "by_metric": { "series": {"value_dimensions": {"value": {"unit":"m/s"}}}}}
    ->>> series.attrs = attr
    ->>> get_series_unit(series)
    -'m/s'
    +

    Code example:

    +
    >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE
    +>>> series.attrs = SERIES_METADATA_EXAMPLE
    +>>> md.get_series_unit(series)
    +'m'
     
    +

    See Units, measurements and return shapes for shared examples and return shapes.

    -hdhelpers.metadata.get_short_display_names(multitsframe, *, include_inherited=False)
    -

    Gets short display names of the MTSF metrics from the metadata

    +hdhelpers.metadata.get_short_display_names(multitsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, defaultdict[str, str | None]][source] +

    Read short display labels for each metric and value column.

    +

    Use short_display_name, then display_name, then name.

    Parameters:
      -
    • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • multitsframe – DataFrame with per-metric metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of metrics containing the short display names. -If the short display name of the metrics is not present it returns the result of hdhelpers.metadata.get_display_names(). -For metadata following the current convention, each of short display name, display name and name is -looked up on the value dimension, on the metric (for the value dimension “value”) and in -“value_dimensions_shared” before falling back to the next one. Fields provided as null count as missing.

    -
    -
    Return type:
    -

    defaultdict[str, defaultdict[str, str | None]]

    -
    -
    Raises:
    -

    TypeError – If multitsframe is not a DataFrame.

    +

    defaultdict[str, defaultdict[str, str | None]] – Labels as result[metric_id][column]; unavailable labels are None.

    -

    Code example:

    -
    >>> attr = { "by_metric": { "metric1": {"metric": {"short_display_name": "short_display_name_of_metric1"}},
    -...                         "metric2": {"metric": {"name": "name_of_metric2"}},
    -...                         "metric3": {"metric": {"name": None}} }}
    ->>> dataframe.attrs = attr
    ->>> result = get_short_display_names(dataframe)
    ->>> result["metric1"]["value"]
    -'short_display_name_of_metric1'
    ->>> result["metric2"]["value"]
    -'name_of_metric2'
    ->>> result["metric3"]["value"] is None
    -True
    -
    -
    -

    Lets try another MTSF format:

    -
    >>> attr = { "dataset_metadata": {"metric_key": "external_id"},
    -...          "metrics": [{"external_id": "ruhr-temperature",
    -...                       "name": "Ruhr temperature [°C]",
    -...                       "display_name": "temperature [°C]",
    -...                       "short_display_name": "temp. [°C]",
    -...                       "value_dimensions": [{"column": "temp", "measurement": "temperature", "unit": "°C"}]}]}
    ->>> dataframe.attrs = attr
    ->>> result = get_short_display_names(dataframe)
    ->>> result["ruhr-temperature"]["value"]
    -'temp. [°C]'
    -
    -
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    -hdhelpers.metadata.get_singlets_default_aggregation_method(singletsframe)
    -

    Gets the method the single metric of a SingleTSFrame should be aggregated with by default

    -

    See get_default_aggregation_methods() for the method and its default.

    +hdhelpers.metadata.get_singlets_default_aggregation_method(singletsframe: DataFrame) → str[source] +

    Read the default aggregation method for a single-metric frame.

    +

    Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata +value_data_type selects LAST for boolean/string and MEAN for other types.

    Parameters:
    -

    singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    +

    singletsframe – DataFrame with one metric’s metadata in attrs.

    Returns:
    -

    The default aggregation method, e.g. “MEAN”, “SUM” or “LAST”.

    -
    -
    Return type:
    -

    str

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    str – Method name; MEAN if metadata is unavailable.

    -

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "valve"},
    -...     "metrics": [
    -...         {
    -...             "id": "valve",
    -...             "value_dimensions": [{"column": "value", "value_data_type": "string"}],
    -...         }
    -...     ],
    -... }
    ->>> get_singlets_default_aggregation_method(dataframe)
    -'LAST'
    -
    -
    +

    See Default aggregation methods for shared examples and return shapes.

    -hdhelpers.metadata.get_singlets_default_timezone(singletsframe)
    -

    Gets the default time zone of the single metric of a SingleTSFrame

    -

    The default time zone is meant for computations: It is the time zone the timeseries should -be evaluated in by default, e.g. for bucketing days when aggregating, so that each day -starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of -the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the -time zone of the plot target settings is for, which e.g. hdhelpers.helpers.modify_timezone() -uses by default. See “Which time zone to compute in?” in the first steps.

    -

    The time zone is an IANA time zone name like “Europe/Berlin” or a fixed UTC offset like -“+02:00”, both accepted by pandas, e.g. by tz_convert.

    -

    The default time zone of the metric falls back to the one of the dataset metadata.

    +hdhelpers.metadata.get_singlets_default_timezone(singletsframe: DataFrame) → str | None[source] +

    Read the time zone recommended for calculations on a single-metric frame.

    +

    Use the metric’s default_timezone, then the dataset’s default_timezone.

    Parameters:
    -

    singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    +

    singletsframe – DataFrame with one metric’s metadata in attrs.

    Returns:
    -

    The default time zone, or None if neither the metric nor the dataset metadata provide one.

    -
    -
    Return type:
    -

    str | None

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    str | None – Zone name or UTC offset, or None when neither source provides one.

    -

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp", "default_timezone": "+02:00"},
    -...     "metrics": [{"id": "abc.temp", "default_timezone": None}],
    -... }
    ->>> get_singlets_default_timezone(dataframe)
    -'+02:00'
    -
    -
    +

    See Default time zones for shared examples and return shapes.

    -hdhelpers.metadata.get_singlets_display_names(singletsframe, *, include_inherited=False)
    -

    Gets display names of the value dimensions of a SingleTSFrame from metadata

    -

    Falls back to the name of the value dimension if no display name is given. The display name -is looked up on the value dimension, on the metric (for the value dimension “value”) and in -“value_dimensions_shared” before falling back to the name. Fields provided as null count as -missing.

    +hdhelpers.metadata.get_singlets_display_names(singletsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, str | None][source] +

    Read display labels for each value column of a single-metric frame.

    +

    Use display_name, then name.

    Parameters:
      -
    • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • singletsframe – DataFrame with one metric’s metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of value dimensions containing the display names. If neither display name nor name is present for a value dimension the corresponding value is None.

    -
    -
    Return type:
    -

    defaultdict[str, str | None]

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    defaultdict[str, str | None] – Labels as result[column]; unavailable labels are None.

    -

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"},
    -...     "metrics": [
    -...         {
    -...             "external_id": "abc.temp",
    -...             "value_dimensions": [
    -...                 {"column": "value", "name": "temperature", "display_name": "temp"},
    -...                 {"column": "state", "name": "measurement state"},
    -...             ],
    -...         }
    -...     ],
    -... }
    ->>> result = get_singlets_display_names(dataframe)
    ->>> result["value"]
    -'temp'
    ->>> result["state"]
    -'measurement state'
    -
    -
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    -hdhelpers.metadata.get_singlets_info(singletsframe, value_dim_info, *, include_inherited=True)
    -

    Gets an arbitrary info of the value dimensions of a SingleTSFrame

    -

    A SingleTSFrame (STSF) holds exactly one metric - like a Series - but arbitrarily many -value dimensions - like a MultiTSFrame. Therefore, in contrast to the MTSF functions, -the result is keyed by value dimension only and not by metric first.

    -

    The single metric is identified via “single_metric” in “dataset_metadata”. If that is -missing but the metadata contains exactly one metric, that metric is used, since a STSF -cannot be ambiguous in this respect.

    +hdhelpers.metadata.get_singlets_info(singletsframe: DataFrame, value_dim_info: str, *, include_inherited: bool = True) → defaultdict[str, Any][source] +

    Read a metadata field for each value column of a single-metric frame.

    +

    For “value”, search column, metric, shared and inherited metadata (if enabled), in that +order. Other columns use their own and shared metadata.

    Parameters:
      -
    • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    • -
    • value_dim_info (str | Spec) – Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like “sensor.manufacturer” into sub dictionaries or any other glom Spec. See “How to query nested information?” in the first steps.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to True.

    • +
    • singletsframe – DataFrame with one metric’s metadata in attrs.

    • +
    • value_dim_info – Field name or dotted path, e.g. “sensor.model”.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of value dimensions containing the retrieved information. If the information is not present for a value dimension the corresponding value is None.

    -
    -
    Return type:
    -

    defaultdict[str, Any]

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    defaultdict[str, Any] – Field values as result[column]; unresolved fields are None.

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"},
    -...     "metrics": [
    -...         {
    -...             "external_id": "abc.temp",
    -...             "value_dimensions": [
    -...                 {"column": "value", "unit": "°C"},
    -...                 {"column": "state"},
    -...             ],
    -...         }
    -...     ],
    -... }
    ->>> result = get_singlets_info(dataframe, "unit")
    ->>> result["value"]
    -'°C'
    ->>> result["state"] is None
    -True
    +
    >>> from hdhelpers.metadata.conventions import SINGLETSFRAME_METADATA_EXAMPLE
    +>>> dataframe.attrs = SINGLETSFRAME_METADATA_EXAMPLE
    +>>> md.get_singlets_info(dataframe, "name")["state"]
    +'measurement state'
     
    +

    See Metric fields and value-dimension fields for shared examples and return shapes.

    -hdhelpers.metadata.get_singlets_measurements(singletsframe, *, include_inherited=False)
    -

    Gets measurements (types) of the value dimensions of a SingleTSFrame from metadata

    +hdhelpers.metadata.get_singlets_measurements(singletsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, str | None][source] +

    Read physical quantities for each value column of a single-metric frame.

    +

    For example, “temperature” is a measurement and “°C” is its unit.

    Parameters:
      -
    • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • singletsframe – DataFrame with one metric’s metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of value dimensions containing the measurements. If “measurement” is not given for a value dimension the corresponding value is None.

    -
    -
    Return type:
    -

    defaultdict[str, str | None]

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    defaultdict[str, str | None] – Quantities as result[column]; unavailable quantities are None.

    -

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"},
    -...     "metrics": [
    -...         {
    -...             "external_id": "abc.temp",
    -...             "value_dimensions": [
    -...                 {"column": "value", "measurement": "temperature"},
    -...             ],
    -...         }
    -...     ],
    -... }
    ->>> get_singlets_measurements(dataframe)["value"]
    -'temperature'
    -
    -
    +

    See Units, measurements and return shapes for shared examples and return shapes.

    -hdhelpers.metadata.get_singlets_metric_info(singletsframe, metric_info, *, include_inherited=True)
    -

    Gets metadata associated to the single metric of a SingleTSFrame

    -

    Counterpart of get_metric_info() for SingleTSFrames: instead of a dictionary keyed -by metric this directly returns the requested information for the one metric. Like -get_metric_info(), it falls back to the value dimension “value”, to -“value_dimensions_shared” and finally to the properties the metric inherits.

    +hdhelpers.metadata.get_singlets_metric_info(singletsframe: DataFrame, metric_info: str, *, include_inherited: bool = True) → Any[source] +

    Read one metadata field for the frame’s single metric.

    +

    Search the metric, its “value” column, shared “value” metadata, then inherited fields +(if enabled).

    Parameters:
      -
    • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    • -
    • metric_info (str | Spec) – Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like “sensor.manufacturer” into sub dictionaries or any other glom Spec. See “How to query nested information?” in the first steps.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the metric, its value dimension “value” nor “value_dimensions_shared” provide the information. Defaults to True.

    • +
    • singletsframe – DataFrame with one metric’s metadata in attrs.

    • +
    • metric_info – Field name or dotted path, e.g. “sensor.model”.

    • +
    • include_inherited – Use inherited fields as a last fallback.

    Returns:
    -

    Retrieved information defined by metric_info, or None if it cannot be determined - which includes the case of absent metric metadata.

    -
    -
    Return type:
    -

    Any

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    Any – One field value, or None when unavailable.

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"},
    -...     "metrics": [{"external_id": "abc.temp", "name": "ABC temperature"}],
    -... }
    ->>> get_singlets_metric_info(dataframe, "name")
    -'ABC temperature'
    ->>> get_singlets_metric_info(dataframe, "not-given") is None
    -True
    -
    -
    -

    Without any metadata None is returned:

    -
    >>> dataframe.attrs = {}
    ->>> get_singlets_metric_info(dataframe, "name") is None
    -True
    +

    Using Shared data setup, compare a field of the metric with fields of its value +dimensions. See Metric fields and value-dimension fields for the related getters:

    +
    >>> md.get_singlets_metric_info(dataframe, "name")
    +'ABC temperature'
    +>>> md.get_singlets_info(dataframe, "name")["state"]
    +'measurement state'
     
    -hdhelpers.metadata.get_singlets_names(singletsframe, *, include_inherited=False)
    -

    Gets names of the value dimensions of a SingleTSFrame from metadata

    +hdhelpers.metadata.get_singlets_names(singletsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, str | None][source] +

    Read metadata names for each value column of a single-metric frame.

    +

    For “value”, try the metric’s name after the column’s own name.

    Parameters:
      -
    • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • singletsframe – DataFrame with one metric’s metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of value dimensions containing the names. If the name is not present for a value dimension the corresponding value is None.

    -
    -
    Return type:
    -

    defaultdict[str, str | None]

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    defaultdict[str, str | None] – Names as result[column]; unavailable names are None.

    -

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"},
    -...     "metrics": [
    -...         {
    -...             "external_id": "abc.temp",
    -...             "value_dimensions": [
    -...                 {"column": "value", "name": "temperature"},
    -...                 {"column": "state"},
    -...             ],
    -...         }
    -...     ],
    -... }
    ->>> result = get_singlets_names(dataframe)
    ->>> result["value"]
    -'temperature'
    ->>> result["state"] is None
    -True
    -
    -
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    -hdhelpers.metadata.get_singlets_recording(singletsframe)
    -

    Gets how the values of the single metric of a SingleTSFrame are recorded

    -

    See get_recordings() for the recording and how its frequency falls back to the one of -the dataset metadata.

    +hdhelpers.metadata.get_singlets_recording(singletsframe: DataFrame) → Recording[source] +

    Read recording settings for a single-metric frame.

    +

    Use the metric’s frequency, offset and time zone together. If its frequency is missing, +use the dataset’s three fields instead.

    Parameters:
    -

    singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    +

    singletsframe – DataFrame with one metric’s metadata in attrs.

    Returns:
    -

    The recording of the metric, with the frequency of the dataset metadata if the metric does not provide one.

    -
    -
    Return type:
    -

    Recording

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    Recording – Recording object; absent metadata yields its default field values.

    -

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {
    -...         "single_metric": "abc.temp",
    -...         "ref_data_frequency": "5min",
    -...         "ref_data_frequency_offset": "1min",
    -...     },
    -...     "metrics": [{"id": "abc.temp", "recording_mode": "periodic"}],
    -... }
    ->>> recording = get_singlets_recording(dataframe)
    ->>> recording.recording_mode, recording.data_frequency, recording.data_frequency_offset
    -('periodic', '5min', '1min')
    -
    -
    +

    See Recording information for shared examples and return shapes.

    -hdhelpers.metadata.get_singlets_short_display_names(singletsframe, *, include_inherited=False)
    -

    Gets short display names of the value dimensions of a SingleTSFrame from metadata

    -

    Falls back to the display name and then to the name of the value dimension if no short -display name is given. Each of them is looked up on the value dimension, on the metric (for -the value dimension “value”) and in “value_dimensions_shared” before falling back to the next -one. Fields provided as null count as missing.

    +hdhelpers.metadata.get_singlets_short_display_names(singletsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, str | None][source] +

    Read short display labels for each value column of a single-metric frame.

    +

    Use short_display_name, then display_name, then name.

    Parameters:
      -
    • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • singletsframe – DataFrame with one metric’s metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of value dimensions containing the short display names. If none of short display name, display name and name is present for a value dimension the corresponding value is None.

    +

    defaultdict[str, str | None] – Labels as result[column]; unavailable labels are None.

    -
    Return type:
    -

    defaultdict[str, str | None]

    +
    +

    See Names and display-name fallbacks for shared examples and return shapes.

    +
    + +
    +
    +hdhelpers.metadata.get_singlets_units(singletsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, str | None][source]
    +

    Read units for each value column of a single-metric frame.

    +
    +
    Parameters:
    +
      +
    • singletsframe – DataFrame with one metric’s metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    • +
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +
    Returns:
    +

    defaultdict[str, str | None] – Units as result[column]; “” means dimensionless and None means unavailable.

    Code example:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"},
    -...     "metrics": [
    -...         {
    -...             "external_id": "abc.temp",
    -...             "value_dimensions": [
    -...                 {"column": "value", "name": "temperature", "short_display_name": "T"},
    -...                 {"column": "state", "display_name": "state"},
    -...             ],
    -...         }
    -...     ],
    -... }
    ->>> result = get_singlets_short_display_names(dataframe)
    ->>> result["value"]
    -'T'
    ->>> result["state"]
    -'state'
    +
    >>> from hdhelpers.metadata.conventions import SINGLETSFRAME_METADATA_EXAMPLE
    +>>> dataframe.attrs = SINGLETSFRAME_METADATA_EXAMPLE
    +>>> md.get_singlets_units(dataframe)["value"]
    +'°C'
     
    +

    See Units, measurements and return shapes for shared examples and return shapes.

    -
    -hdhelpers.metadata.get_singlets_units(singletsframe, *, include_inherited=False)
    -

    Gets units of the value dimensions of a SingleTSFrame from metadata

    +
    +hdhelpers.metadata.get_units(multitsframe: DataFrame, *, include_inherited: bool = False) → defaultdict[str, defaultdict[str, str | None]][source]
    +

    Read units for each metric and value column.

    Parameters:
      -
    • singletsframe (pd.DataFrame) – STSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • multitsframe – DataFrame with per-metric metadata in attrs.

    • +
    • include_inherited – Use inherited fields last, only for the “value” column.

    Returns:
    -

    Dictionary of value dimensions containing the units. If the unit is not present for a value dimension the corresponding value is None.

    -
    -
    Return type:
    -

    defaultdict[str, str | None]

    -
    -
    Raises:
    -

    TypeError – If singletsframe is not a DataFrame.

    +

    defaultdict[str, defaultdict[str, str | None]] – Units as result[metric_id][column]; unavailable units are None.

    -

    Note that a value dimension falls back to “value_dimensions_shared” if it carries no unit -of its own:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"},
    -...     "metrics": [
    -...         {
    -...             "external_id": "abc.temp",
    -...             "value_dimensions": [
    -...                 {"column": "value", "unit": "°C"},
    -...                 {"column": "state"},
    -...             ],
    -...         }
    -...     ],
    -...     "value_dimensions_shared": [{"column": "state", "unit": "UNKNOWN"}],
    -... }
    ->>> result = get_singlets_units(dataframe)
    ->>> result["value"]
    -'°C'
    ->>> result["state"]
    -'UNKNOWN'
    ->>> result["not-given"] is None
    +

    Code example:

    +
    >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE
    +>>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE
    +>>> units = md.get_units(multitsframe)
    +>>> units["plant_a.temperature"]["value"]
    +'°C'
    +>>> units["unknown metric"]["value"] is None
     True
     
    -

    The “metric_key” is optional and defaults to “id”:

    -
    >>> dataframe.attrs = {
    -...     "dataset_metadata": {"single_metric": "abc.temp"},
    -...     "metrics": [
    -...         {"id": "abc.temp", "value_dimensions": [{"column": "value", "unit": "m"}]}
    -...     ],
    -... }
    ->>> get_singlets_units(dataframe)["value"]
    -'m'
    -
    -
    +

    See Units, measurements and return shapes for shared examples and return shapes.

    -
    -hdhelpers.metadata.get_units(multitsframe, *, include_inherited=False)
    -

    Gets unit of value dimensions in MTSF metrics from Metadata

    +
    +hdhelpers.metadata.read_metadata(obj: Series | DataFrame, data_type: Literal['SERIES'], *, required: Literal[True]) → SeriesMetadata[source]
    +
    +hdhelpers.metadata.read_metadata(obj: Series | DataFrame, data_type: Literal['SERIES'], *, required: bool = False) → SeriesMetadata | None
    +
    +hdhelpers.metadata.read_metadata(obj: Series | DataFrame, data_type: Literal['SINGLETSFRAME'], *, required: Literal[True]) → SingleTSFrameMetadata
    +
    +hdhelpers.metadata.read_metadata(obj: Series | DataFrame, data_type: Literal['SINGLETSFRAME'], *, required: bool = False) → SingleTSFrameMetadata | None
    +
    +hdhelpers.metadata.read_metadata(obj: Series | DataFrame, data_type: Literal['MULTITSFRAME'], *, required: Literal[True]) → MultiTSFrameMetadata
    +
    +hdhelpers.metadata.read_metadata(obj: Series | DataFrame, data_type: Literal['MULTITSFRAME'], *, required: bool = False) → MultiTSFrameMetadata | None
    +
    +hdhelpers.metadata.read_metadata(obj: Series | DataFrame, data_type: None = None, *, required: Literal[True]) → SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata
    +
    +hdhelpers.metadata.read_metadata(obj: Series | DataFrame, data_type: None = None, *, required: bool = False) → SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata | None
    +

    Read metadata as a model without changing the input.

    +

    Reuse an attached model; otherwise parse convention fields in attrs.

    Parameters:
      -
    • multitsframe (pd.DataFrame) – MTSF with metadata following the convention.

    • -
    • include_inherited (bool) – Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under “inherited” in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor “value_dimensions_shared” provide the information. Defaults to False, since this information should be provided for the metric itself.

    • +
    • obj – Series or DataFrame to read.

    • +
    • data_type – Expected convention. None keeps an attached model’s type or infers it for raw +attrs; see Automatic metadata type selection.

    • +
    • required – If True, raise on missing, invalid or wrong-type metadata instead of returning +None.

    Returns:
    -

    Dictionary of metrics containing the names of the value dimensions. -If the short display name of the value_dimension is not present it returns the result of get_value_dimension_info().

    -
    -
    Return type:
    -

    dict[str, dict[str | None] | None]

    +

    SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata | None – Attached or newly parsed model; None for missing, invalid or wrong-type +metadata.

    -
    Raises:
    -

    TypeError – If multitsframe is not a DataFrame.

    +
    Raises:
    +

    MetadataConventionError – Metadata cannot be returned and required=True.

    Code example:

    -
    >>> attr = {
    -...    "by_metric": {
    -...        "metric1": {
    -...           "value_dimensions": {
    -...                "value_dim_1": {
    -...                    "unit": "m"
    -...                }
    -...            }
    -...        },
    -...        "metric3": {
    -...            "value_dimensions": {
    -...                 "value_dim_1": {
    -...                     "unit": None,
    -...                 }
    -...            }
    -...        }
    -...    }
    -... }
    ->>> dataframe.attrs = attr
    ->>> result = get_units(dataframe)
    ->>> result["metric1"]['value_dim_1']
    -'m'
    ->>> result["metric3"]['value_dim_1'] is None
    -True
    ->>> result["metric2"]['value_dim_1'] is None
    +

    Using Shared data setup, read a model without changing the original attrs:

    +
    >>> meta = md.read_metadata(series, required=True)
    +>>> meta.unit()
    +'m'
    +>>> "hd_metadata" in series.attrs
    +False
    +>>> missing = pd.Series([1.0])
    +>>> md.read_metadata(missing) is None
     True
    +>>> try:
    +...     md.read_metadata(missing, required=True)
    +... except md.MetadataConventionError as error:
    +...     print(error.issues[0].message)
    +No metadata in attrs.
     
    -
    -hdhelpers.metadata.without_metadata(data)
    -

    Gets a copy of a Series or DataFrame without its metadata, to compute on

    -

    pandas deep-copies the metadata (attrs) of a Series or DataFrame to every object an -operation creates from it, e.g. when filtering rows, selecting a column or grouping. For -metadata of many metrics, e.g. with comments, loops over the metrics then take much longer -than the computation itself. Compute on the copy without metadata instead and attach the -metadata of the result once at the end.

    -

    The copy is cheap: It shares the data with data until one of them is changed (copy on -write), so changing the copy does not change data.

    +
    +hdhelpers.metadata.set_metadata(obj: Series | DataFrame, meta: SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata) → None[source]
    +

    Attach the supplied metadata model to a result object.

    +

    Store it under attrs[“hd_metadata”], replacing convention fields and preserving +unrelated attrs.

    Parameters:
    -

    data (pd.Series | pd.DataFrame) – Series or DataFrame, e.g. a MTSF with metadata.

    +
      +
    • obj – Result Series or DataFrame created by your component; updated in place.

    • +
    • meta – Validated metadata model; shared without copying or revalidation.

    • +
    Returns:
    -

    Copy of data with empty attrs.

    -
    -
    Return type:
    -

    pd.Series | pd.DataFrame

    +

    None – Updates obj in place.

    Code example:

    -
    >>> multitsframe.attrs = {"dataset_metadata": {"metric_key": "id"}, "metrics": []}
    ->>> data = without_metadata(multitsframe)
    ->>> data.attrs
    -{}
    ->>> multitsframe.attrs
    -{'dataset_metadata': {'metric_key': 'id'}, 'metrics': []}
    +

    Convert the metre values from Shared data setup to centimetres and set matching +metadata on the result. The original metadata stays unchanged:

    +
    >>> meta = md.read_metadata(series, required=True)
    +>>> result = series * 100
    +>>> changed = meta.with_value_dimension(None, "value", unit="cm")
    +>>> md.set_metadata(result, changed)
    +>>> md.get_series_unit(result), md.get_series_unit(series)
    +('cm', 'm')
    +>>> md.read_metadata(result) is changed
    +True
     
    @@ -1457,24 +1065,19 @@

    Functions
    -hdhelpers.helpers.get_locale(explicit_locale=None)
    -

    Extract (Plotly) locale

    -

    Infers Plotly locale from plot target settings or returns explicit provided -locale.

    +hdhelpers.helpers.get_locale(explicit_locale: str | None = None) → str | None[source] +

    Read the explicit locale or plot target locale, normalizing underscores to hyphens.

    Parameters:
    -

    explicit_locale (str | None) – Defaults to None. Locale string like “de”, “en-US” or similar. Will be returned instead of what is inferred from plot_target_settings if provided

    +

    explicit_locale – Override locale, e.g. “de_DE”. None or “” uses the plot target setting.

    Returns:
    -

    Extracted locale (like “de_DE”)

    -
    -
    Return type:
    -

    str | None

    +

    str | None – Locale such as “de-DE”, or None if no locale is configured.

    Code example:

    -
    >>> get_locale("de_DE")         # returns "de-DE"
    -'de-DE'
    +
    >>> get_locale("de_DE")         # returns "de-DE"
    +'de-DE'
     
     >>> print(get_locale())         # returns locale from plot target settings of execution or None
     None
    @@ -1484,34 +1087,35 @@ 

    Functions
    -hdhelpers.helpers.modify_timezone(object_to_convert, to_timezone=None, column_names=None, convert_index=True)
    -

    Converts time information of pandas objects to a certain timezone

    -

    This function is applicable to index and/or columns of pd.Series or pd.DataFrame as well as for single pd.Timestamp objects. -If time zone information is not defined in object to convert it is assumed that it is in UTC. -Please note that column_names and convert_index are not exclusive to enable modifying both at the same time. -To enable modifying the value of a series (not the index), please name the series and define the series’ name in column_names.

    +hdhelpers.helpers.modify_timezone(object_to_convert: T, to_timezone: str | None = None, column_names: list[str] | None = None, convert_index: bool = True) → T[source] +

    Convert aware timestamps or localize naive timestamps to the selected time zone.

    Parameters:
      -
    • object_to_convert (pd.Timestamp | pd.Series | pd.DataFrame) – Timestamp, Series or DataFrame where timezone is modified

    • -
    • to_timezone (str | None) – Timezone to convert to, e.g. for German time use “Europe/Berlin”. See possible timezone strings in pandas’ tz_convert method or pytz all_timezones list. If to_timezone is not defined, the global timezone from plot_target_settings is used. .

    • -
    • column_names (str | None) – List of column_names to modify. For pd.Series the default behaviour is modifying the index and for pd.DataFrame the default behaviour is modifying the column “timestamp”. This option is not applicable in case object_to_convert is a pd.Timestamp.

    • -
    • convert_index (bool | None) – Boolean that controls whether the index of pd.Dataframe or pd.Series should be transformed. Note that for a pd.Series setting this option to true results in the same output as using column_names=None. This option is not applicable in case object_to_convert is a pd.Timestamp.

    • +
    • object_to_convert (pd.Timestamp | datetime.datetime | pd.Series | pd.DataFrame) – Timestamp, datetime, Series or DataFrame to convert.

    • +
    • to_timezone – Target zone, e.g. “Europe/Berlin”. None uses the plot target setting; if unset, +naive values use UTC and aware values keep their zone.

    • +
    • column_names – Columns to convert. None/[] selects the Series index or DataFrame “timestamp” +column. For Series values, pass [series.name]. Ignored for scalars.

    • +
    • convert_index – Also convert the pandas index. With column_names=None/[], a Series index is +always converted. Ignored for scalars.

    Returns:
    -

    Returns the modified timezone object.

    -
    -
    Return type:
    -

    pd.Timestamp | pd.Series | pd.DataFrame

    +

    pd.Timestamp | pd.Series | pd.DataFrame – Timestamp for scalar inputs; converted copy for nonempty pandas inputs. Empty +pandas inputs are returned unchanged.

    -
    Raises:
    -

    TypeError – If object_to_convert is not a pd.Series, pd.Timestamp, pd.DataFrame.

    +
    Raises:
    +
      +
    • TypeError – Unsupported input or incompatible timestamp/index values.

    • +
    • ValueError – Unknown time zone or invalid datetime values.

    • +
    • KeyError – A requested column is absent.

    • +

    Code example:

    >>> from hdhelpers.helpers import modify_timezone
    ->>> modified_timezone = modify_timezone(pd.to_datetime("2025-01-01T01:00:00+05:00"), to_timezone="Europe/Berlin")
    +>>> modified_timezone = modify_timezone(pd.to_datetime("2025-01-01T01:00:00+05:00"), to_timezone="Europe/Berlin")
     >>> int(modified_timezone.utcoffset().total_seconds())
     3600
     
    @@ -1524,44 +1128,35 @@

    Functions
    -hdhelpers.plotting.resolve_color(color_str)
    -

    Resolves Fuseki Colors and returns resulting color string

    -

    Non-fuseki (color) strings are returned just as they are.

    +hdhelpers.plotting.resolve_color(color_str: str) → str[source] +

    Resolve a Fuseki palette name to a color string.

    Parameters:
    -

    color_str (str) – Color string like “#ea1255”, “orange” or “ki.shade” (Fuseki color)

    +

    color_str – Palette name, e.g. “ki.energy”, or any other string.

    Returns:
    -

    Returns the resolved color string.

    -
    -
    Return type:
    -

    str

    +

    str – Resolved color; other strings are returned unchanged.

    Code example:

    -
    >>> resolve_color("red")
    -'red'
    ->>> resolve_color("ki.energy")
    -'#eb6962'
    +
    >>> resolve_color("red")
    +'red'
    +>>> resolve_color("ki.energy")
    +'#eb6962'
     

    -hdhelpers.plotting.set_agnostic_theme(fig)
    -

    Set Plotly template of figure to a theme agnostic template

    -

    This Plotly template should yield good results for both light and dark themed -backgrounds.

    +hdhelpers.plotting.set_agnostic_theme(fig: Figure) → None[source] +

    Apply a Plotly theme suitable for light and dark backgrounds.

    Parameters:
    -

    fig (plotly Figure object) – Plotly figure object whose template will be set.

    +

    fig – Figure to style.

    Returns:
    -

    Returns None, since the figure object is modified in place

    -
    -
    Return type:
    -

    None

    +

    None – The supplied figure is updated in place.

    Code example:

    @@ -1574,77 +1169,65 @@

    Functions
    -hdhelpers.plotting.set_dt_ticks(fig)
    -

    Set x datetime tick labels to language agnostic format

    -

    Sets the x axes tick format of the Plotly Figure to some isoformat inspired -timestamp format, adequately for multiple zoom levels.

    -

    Infers Plotly locale from plot target settings or returns explicit provided -locale.

    +hdhelpers.plotting.set_dt_ticks(fig: Figure) → None[source] +

    Set zoom-dependent, locale-independent date/time labels on all x axes.

    +

    Update tickformatstops; timestamps and time zones are unchanged.

    Parameters:
    -

    fig (plotly Figure object) – Plotly figure object whose x axes will be modified.

    +

    fig – Figure to format.

    Returns:
    -

    Returns None, since the figure object is modified in place

    -
    -
    Return type:
    -

    None

    +

    None – The supplied figure is updated in place.

    Code example:

    >>> import plotly.graph_objects as go
    ->>> fig = go.Figure(data=go.Scatter(x=["2024-01-01", "2024-01-02"], y=[1, 4]))
    +>>> fig = go.Figure(data=go.Scatter(x=["2024-01-01", "2024-01-02"], y=[1, 4]))
     >>> set_dt_ticks(fig)
     

    -
    -

    exceptions

    -
    +
    +

    exceptions

    +

    For metadata validation errors, see hdhelpers.metadata.MetadataConventionError +in the metadata API. Its issues attribute contains the individual validation issues.

    +
    -exception hdhelpers.exceptions.HelperException(*args, error_code='', extra_information=None, **kwargs)
    +exception hdhelpers.exceptions.HelperException(*args: Any, error_code: int | str = '', extra_information: dict | None = None, **kwargs: Any)[source]

    Bases: Exception

    -

    Exception to re-raise exceptions with error code raised in the code of the hdhelpers -package.

    +

    Exception for reporting errors to hetida designer.

    +
    +
    +error_code
    +

    Error identifier; an empty string if unspecified.

    -
    Parameters:
    -
      -
    • args (Any)

    • -
    • error_code (int | str)

    • -
    • extra_information (dict | None)

    • -
    • kwargs (Any)

    • -
    +
    Type:
    +

    int | str

    -
    Return type:
    -

    None

    +
    +
    + +
    +
    +extra_information
    +

    Optional details for the error handler.

    +
    +
    Type:
    +

    dict | None

    +
    +
    -exception hdhelpers.exceptions.InsufficientPlottingData(*args, error_code='', extra_information=None, **kwargs)
    +exception hdhelpers.exceptions.InsufficientPlottingData(*args: Any, error_code: int | str = '', extra_information: dict | None = None, **kwargs: Any)[source]

    Bases: HelperException

    -

    A plot component has insufficient data to generate a meaningful plot

    -

    This exception class should be used when custom plots generated by hetida -designer are integrated in other frontends. This allows the frontend to -handle the case of e.g. no data to show in a sensible way, surpressing an -empty plot and showing an adequate message instead.

    -
    -
    Parameters:
    -
      -
    • args (Any)

    • -
    • error_code (int | str)

    • -
    • extra_information (dict | None)

    • -
    • kwargs (Any)

    • -
    -
    -
    Return type:
    -

    None

    -
    -
    +

    Raised when a plot component has insufficient data to produce a meaningful plot.

    +

    Frontends can handle this by displaying a message instead of an empty plot.

    @@ -1661,65 +1244,78 @@

    FunctionsTable of Contents

    Next topic

    -

    First steps

    +

    Installation

    This Page

    @@ -1765,15 +1361,15 @@

    Navigation

    modules |
  • - next |
  • - +
    \ No newline at end of file diff --git a/docs/installation.html b/docs/installation.html new file mode 100644 index 0000000..966e6c1 --- /dev/null +++ b/docs/installation.html @@ -0,0 +1,156 @@ + + + + + + + + Installation — hdhelpers - documentation + + + + + + + + + + + + + + + + +
    +
    +
    +
    + +
    +

    Installation

    +

    hdhelpers requires Python 3.12 or newer and is included in the hetida designer runtime. +To use it in your own Python project, run the following commands in your project’s +Python environment.

    +
    +

    Core package

    +

    Install the metadata and time zone helpers:

    +
    python -m pip install hdhelpers
    +
    +
    +

    For a project managed with uv, use uv add hdhelpers instead. +The core package works without Plotly.

    +
    +
    +

    Plotting helpers

    +

    Install the optional plotting extra to use hdhelpers.plotting and the +plotting functions exported by hdhelpers:

    +
    python -m pip install "hdhelpers[plotting]"
    +
    +
    +

    With uv, use uv add "hdhelpers[plotting]". This also enables +from hdhelpers import *, which includes plotting exports.

    +
    +
    +

    Verify the installation

    +
    python -c "import hdhelpers; print(hdhelpers.__version__)"
    +
    +
    +

    Continue with First steps for component examples or Metadata examples +for sample pandas data to use in a Python session.

    +
    +
    + + +
    +
    +
    +
    + +
    +
    + + + + \ No newline at end of file diff --git a/docs/metadata_examples.html b/docs/metadata_examples.html new file mode 100644 index 0000000..4bf3384 --- /dev/null +++ b/docs/metadata_examples.html @@ -0,0 +1,338 @@ + + + + + + + + Metadata examples — hdhelpers - documentation + + + + + + + + + + + + + + + + +
    +
    +
    +
    + +
    +

    Metadata examples

    +
    +

    Shared data setup

    +

    The following examples use pandas objects with metadata. In hetida designer, these +are supplied as component inputs. To try the examples in a Python session, create +them using the shared data setup below.

    +

    The setup creates three independent pandas objects with complete metadata following +the current conventions. Use these objects for the API reference examples as well:

    +
      +
    • series: a SERIES with one value per timestamp, measured in metres.

    • +
    • dataframe: a SINGLETSFRAME describing one metric with the value columns +value (temperature) and state (measurement state).

    • +
    • multitsframe: a MULTITSFRAME with pressure and temperature metrics, whose +identifiers are stored in the metric column.

    • +
    +

    md is the imported hdhelpers.metadata module. deepcopy keeps changes to +the example metadata independent of the supplied templates. The same setup is run +for each documentation test group. To start an example again with unchanged data, +rerun the setup.

    +
    from copy import deepcopy
    +
    +import pandas as pd
    +
    +from hdhelpers import metadata as md
    +from hdhelpers.metadata.conventions import (
    +    MULTITSFRAME_METADATA_EXAMPLE,
    +    SERIES_METADATA_EXAMPLE,
    +    SINGLETSFRAME_METADATA_EXAMPLE,
    +)
    +
    +timestamps = pd.to_datetime(["2026-01-01T00:00:00Z", "2026-01-01T00:15:00Z"])
    +
    +# SERIES: one metric, with timestamps in the index; values are in metres.
    +series = pd.Series([1.0, 1.2], index=timestamps, name="level")
    +series.attrs = deepcopy(SERIES_METADATA_EXAMPLE)
    +
    +# SINGLETSFRAME: one metric with several value columns; "value" is in degrees Celsius.
    +dataframe = pd.DataFrame(
    +    {"timestamp": timestamps, "value": [20.0, 21.0], "state": ["valid", "valid"]}
    +)
    +dataframe.attrs = deepcopy(SINGLETSFRAME_METADATA_EXAMPLE)
    +
    +# MULTITSFRAME: several metrics, identified by the "metric" column.
    +multitsframe = pd.DataFrame(
    +    {
    +        "timestamp": timestamps,
    +        "metric": ["plant_a.pressure", "plant_a.temperature"],
    +        "value": [1.0, 20.0],
    +    }
    +)
    +multitsframe.attrs = deepcopy(MULTITSFRAME_METADATA_EXAMPLE)
    +
    +
    +

    See First steps for examples within components.

    +
    +
    +

    Metadata lookup basics

    +

    A metric is one measured signal. A value column (called a value dimension in the +schema) holds values or related information, such as measurement state. Metadata +is stored in attrs as convention fields or as a model under hd_metadata. +Shared metadata in value_dimensions_shared supplies defaults for matching +columns across metrics.

    +

    The metadata models are immutable and allow additional fields at every level. +Metadata timestamps are UTC ISO strings with an explicit Z or +00:00 offset. +Adapters exchange convention fields; hdhelpers.metadata.attrs_to_json() converts +an attached model back to those fields for export.

    +

    Series and SINGLETSFRAME getters select dataset_metadata.single_metric, or the +only metric described if no identifier is set. They do not select by Series.name. +Without a unique selection, metric-specific information is unavailable. +Absent fields and fields set to None count as missing. Inherited properties are supplied +by the source system for the metric or its parent assets. They are used as fallbacks when +include_inherited=True. Explicit paths such as "inherited.site" access them directly, +even when this fallback is disabled. +Missing or invalid metadata gives empty mappings for mapping getters; missing-key +lookups still use their documented defaults.

    +
    +
    +

    Automatic metadata type selection

    +

    When parsing raw attrs, read_metadata, attach_metadata and +check_metadata_conventions infer the type as follows:

    +
      +
    • A Series selects SERIES.

    • +
    • A DataFrame with a metric column selects MULTITSFRAME.

    • +
    • Other DataFrames select SINGLETSFRAME.

    • +
    +

    An explicit data_type overrides this selection. read_metadata also checks +the type of an attached model when requested; attach_metadata keeps an already +attached model unchanged.

    +
    +
    +

    Units, measurements and return shapes

    +

    SERIES getters return a single value. SINGLETSFRAME getters return information by +value column; MULTITSFRAME getters return it by metric and value column. The +measurement describes the physical quantity, whereas the unit describes its scale.

    +
    >>> md.get_series_unit(series), md.get_series_measurement(series)
    +('m', 'level')
    +>>> dict(md.get_singlets_units(dataframe))
    +{'value': '°C', 'state': None}
    +>>> md.get_singlets_measurements(dataframe)["value"]
    +'temperature'
    +>>> md.get_units(multitsframe)["plant_a.pressure"]["value"]
    +'bar'
    +>>> md.get_measurements(multitsframe)["plant_a.pressure"]["value"]
    +'pressure'
    +>>> md.get_units(multitsframe)["unknown metric"]["value"] is None
    +True
    +>>> start, end = md.get_queried_interval(series)
    +>>> start.isoformat(), end.isoformat()
    +('2026-01-01T00:00:00+00:00', '2026-01-02T00:00:00+00:00')
    +
    +
    +
    +
    +

    Names and display-name fallbacks

    +

    These getters use the same return shapes. A short display name falls back to the +display name, which falls back to the name. The templates omit display names, so +the examples show that fallback. See First steps for the lookup order and +the optional include_inherited=True fallback to inherited properties.

    +
    >>> md.get_series_name(series)
    +'Water level north'
    +>>> md.get_series_display_name(series), md.get_series_short_display_name(series)
    +('Water level north', 'Water level north')
    +>>> md.get_singlets_names(dataframe)["state"]
    +'measurement state'
    +>>> md.get_singlets_display_names(dataframe)["state"]
    +'measurement state'
    +>>> md.get_singlets_short_display_names(dataframe)["state"]
    +'measurement state'
    +>>> md.get_names(multitsframe)["plant_a.pressure"]["value"]
    +'Plant A pressure'
    +>>> md.get_display_names(multitsframe)["plant_a.pressure"]["value"]
    +'Plant A pressure'
    +>>> md.get_short_display_names(multitsframe)["plant_a.pressure"]["value"]
    +'Plant A pressure'
    +
    +
    +
    +
    +

    Metric fields and value-dimension fields

    +

    get_singlets_info returns information for each value column, whereas +get_singlets_metric_info returns one value for the metric. The Series variant +looks up its single value dimension; get_metric_info returns values by metric. +The field argument may also be a dotted path into nested metadata.

    +
    >>> md.get_series_info(series, "decimals")
    +2
    +>>> md.get_singlets_info(dataframe, "name")["state"]
    +'measurement state'
    +>>> md.get_singlets_metric_info(dataframe, "name")
    +'ABC temperature'
    +>>> md.get_metric_info(multitsframe, "name")["plant_a.pressure"]
    +'Plant A pressure'
    +
    +
    +
    +
    +

    Recording information

    +

    Single-metric getters return a Recording object; get_recordings returns +these objects by metric. Access its fields to distinguish samples, periodic +recording and recording on change. Frequency, offset and time zone fall back to +the dataset metadata together, as described by hdhelpers.metadata.get_recordings().

    +
    >>> recording = md.get_series_recording(series)
    +>>> recording.recording_mode, recording.data_frequency
    +('periodic', '15min')
    +>>> md.get_singlets_recording(dataframe).data_frequency is None
    +True
    +>>> md.get_recordings(multitsframe)["plant_a.temperature"].recording_mode
    +'on_change'
    +
    +
    +
    +
    +

    Default time zones

    +

    A metric’s time zone falls back to the dataset’s default, otherwise to None. +Use an explicit fallback such as or "UTC" when choosing a time zone for a +computation. See First steps for a daily aggregation example and the +distinction between computation and display time zones.

    +
    >>> md.get_series_default_timezone(series)
    +'Europe/Berlin'
    +>>> md.get_singlets_default_timezone(dataframe) is None
    +True
    +>>> md.get_singlets_default_timezone(dataframe) or "UTC"
    +'UTC'
    +>>> md.get_default_timezones(multitsframe)["plant_a.pressure"]
    +'Europe/Berlin'
    +
    +
    +
    +
    +

    Default aggregation methods

    +

    The getters return a method name, or method names by metric. An explicit method +wins; AVG is normalized to MEAN. Otherwise, value_data_type in metadata +selects LAST for boolean/string and MEAN for other types.

    +
    >>> md.get_series_default_aggregation_method(series)
    +'MEAN'
    +>>> md.get_singlets_default_aggregation_method(dataframe)
    +'MEAN'
    +>>> md.get_default_aggregation_methods(multitsframe)["plant_a.temperature"]
    +'MAX'
    +
    +
    +

    For the metadata lifecycle, start with hdhelpers.metadata.check_metadata_conventions(), +then hdhelpers.metadata.read_metadata() or hdhelpers.metadata.attach_metadata(). +Use hdhelpers.metadata.set_metadata() to attach changed metadata to a result +and hdhelpers.metadata.attrs_to_json() before serialization. Each function +has an example using the shared data setup.

    +

    See Metadata model reference for the model classes and their reading, update and conversion methods.

    +
    +
    + + +
    +
    +
    +
    + +
    +
    + + + + \ No newline at end of file diff --git a/docs/metadata_models.html b/docs/metadata_models.html new file mode 100644 index 0000000..3e87146 --- /dev/null +++ b/docs/metadata_models.html @@ -0,0 +1,699 @@ + + + + + + + + Metadata model reference — hdhelpers - documentation + + + + + + + + + + + + + + + +
    +
    +
    +
    + +
    +

    Metadata model reference

    +

    Read models with hdhelpers.metadata.read_metadata(). The returned type depends +on the metadata’s data type; see Automatic metadata type selection. +All three root models are immutable, allow additional fields and provide the same +reading and update methods.

    + + +++++ + + + + + + + + + + + + + + + + + + + + +
    Root models

    Model

    Data

    Value dimensions

    SeriesMetadata

    One metric in a pandas Series

    One, named value

    SingleTSFrameMetadata

    One metric in a pandas DataFrame

    One or more value columns

    MultiTSFrameMetadata

    Metrics identified by a DataFrame’s metric column

    Value columns for each metric

    +

    The root fields are dataset_metadata (settings for the dataset), metrics +(metric descriptions) and optional value_dimensions_shared (column defaults). +See Metadata lookup basics for storage, missing values and shared field rules.

    +
    +

    Reading and updating

    +

    Using Shared data setup, read a model and attach changed metadata to a +converted result. Updates return a new model; the original remains unchanged.

    +
    >>> meta = md.read_metadata(series, required=True)
    +>>> meta.unit()
    +'m'
    +>>> result = series * 100
    +>>> changed = meta.with_value_dimension(None, "value", unit="cm")
    +>>> md.set_metadata(result, changed)
    +>>> md.get_series_unit(result), meta.unit()
    +('cm', 'm')
    +>>> multi = md.read_metadata(multitsframe, required=True)
    +>>> multi.unit("plant_a.pressure")
    +'bar'
    +>>> multi.narrowed_to("plant_a.pressure").unit()
    +'bar'
    +
    +
    +

    metric_id is the value of the field selected by dataset_metadata.metric_key. +It can be omitted for SeriesMetadata and SingleTSFrameMetadata; MultiTSFrameMetadata +requires it for methods reading one metric, even if only one metric is present. +column selects a value dimension and defaults to "value".

    +

    Methods in the plural return dictionaries by metric ID; value-dimension methods +return nested dictionaries as result[metric_id][column]. Missing fields return +None; metric() raises KeyError for an unknown metric. See +Default time zones, Recording information and +Default aggregation methods for their specific defaults.

    +

    with_metric, with_value_dimension and with_dataset_metadata accept field +changes as keyword arguments and validate the changed metadata. Use these methods +for updates; model_copy(update=...) does not validate replacement values. +Use hdhelpers.metadata.attrs_to_json() when exporting pandas attrs, or +model_dump(mode="json") to obtain the model’s convention fields directly.

    +
    +
    +

    Root classes and methods

    +

    The shared methods are documented once under SeriesMetadata below. They are also +available on SingleTSFrameMetadata and MultiTSFrameMetadata. Conversion methods +change only metadata; convert the pandas data separately to match it.

    +
    +
    +class hdhelpers.metadata.conventions.SeriesMetadata(**data)[source]
    +

    Immutable metadata for a SERIES: one metric with a single “value” dimension.

    +

    Read from a pandas Series with read_metadata. See the module documentation for storage, +adapter exchange and common field rules.

    +
    +
    +as_singletsframe(column: str = 'value') → SingleTSFrameMetadata[source]
    +

    The metadata of the SINGLETSFRAME with the values of the SERIES in column

    +

    Renames the value dimension “value” of the metric and the shared one to column.

    +
    + +
    +
    +default_aggregation_method(metric_id: str | None = None) → str
    +

    The default aggregation method of the metric, see the module docstring

    +
    + +
    +
    +default_aggregation_methods() → dict[str, str]
    +
    + +
    +
    +default_timezone(metric_id: str | None = None) → str | None
    +

    The default time zone of the metric, otherwise the one of the dataset metadata

    +
    + +
    +
    +default_timezones() → dict[str, str | None]
    +
    + +
    +
    +display_name(metric_id: str | None = None, column: str = 'value', *, include_inherited: bool = False) → str | None
    +

    The display name, falling back to the name

    +
    + +
    +
    +display_names(*, include_inherited: bool = False) → dict[str, dict[str, str | None]]
    +
    + +
    +
    +measurement(metric_id: str | None = None, column: str = 'value', *, include_inherited: bool = False) → str | None
    +
    + +
    +
    +measurements(*, include_inherited: bool = False) → dict[str, dict[str, str | None]]
    +
    + +
    +
    +metric(metric_id: str | None = None) → MetricMetadata
    +

    The metadata of a metric, KeyError if there is none

    +
    + +
    +
    +metric_ids() → list[str]
    +

    The ids of the metrics, i.e. their values of the field named by the metric key

    +
    + +
    +
    +metric_info(field: str, metric_id: str | None = None, *, include_inherited: bool = True) → Any
    +

    Information of a metric, see the module docstring for the fallbacks

    +
    + +
    +
    +metric_infos(field: str, *, include_inherited: bool = True) → dict[str, Any]
    +
    + +
    +
    +name(metric_id: str | None = None, column: str = 'value', *, include_inherited: bool = False) → str | None
    +
    + +
    +
    +names(*, include_inherited: bool = False) → dict[str, dict[str, str | None]]
    +
    + +
    +
    +queried_interval() → tuple[datetime | None, datetime | None]
    +

    Start and end of the queried interval (the ref interval), None if missing

    +
    + +
    +
    +recording(metric_id: str | None = None) → Recording
    +

    How the values of the metric are recorded

    +

    The frequency with its offset and time zone falls back to the ones of the dataset +metadata as a whole.

    +
    + +
    +
    +recordings() → dict[str, Recording]
    +
    + +
    +
    +short_display_name(metric_id: str | None = None, column: str = 'value', *, include_inherited: bool = False) → str | None
    +

    The short display name, falling back to the display name and the name

    +
    + +
    +
    +short_display_names(*, include_inherited: bool = False) → dict[str, dict[str, str | None]]
    +
    + +
    +
    +unit(metric_id: str | None = None, column: str = 'value', *, include_inherited: bool = False) → str | None
    +
    + +
    +
    +units(*, include_inherited: bool = False) → dict[str, dict[str, str | None]]
    +
    + +
    +
    +value_dimension_info(fields: str | Sequence[str], metric_id: str | None = None, column: str = 'value', *, include_inherited: bool = False) → Any
    +

    Information of a value dimension, trying the fields in turn

    +
    + +
    +
    +value_dimension_infos(fields: str | Sequence[str], *, include_inherited: bool = False) → dict[str, dict[str, Any]]
    +

    Information of the value dimensions by column by metric

    +

    The value dimensions of a metric are its own ones, “value” and the shared ones.

    +
    + +
    +
    +with_dataset_metadata(**changes: Any) → Self
    +

    A version with fields of the dataset metadata changed, the metrics are shared

    +

    Validated including all rules of the conventions, since the dataset metadata concerns +all metrics.

    +
    + +
    +
    +with_metric(metric_id: str | None = None, /, **changes: Any) → Self
    +

    A version with fields of a metric changed, the other metrics are shared

    +

    The changed metric is validated, including the rules of the conventions which concern it.

    +
    + +
    +
    +with_value_dimension(metric_id: str | None = None, column: str = 'value', /, **changes: Any) → Self
    +

    A version with fields of a value dimension of a metric changed, see with_metric

    +
    + +
    + +
    +
    +class hdhelpers.metadata.conventions.SingleTSFrameMetadata(**data)[source]
    +

    Immutable metadata for a SINGLETSFRAME: one metric with one or more value columns.

    +

    Read from a pandas DataFrame with read_metadata. See the module documentation for storage, +adapter exchange and common field rules.

    +
    +
    +as_series(column: str = 'value') → SeriesMetadata[source]
    +

    The metadata of the SERIES with the values of column

    +

    Keeps only the value dimensions of column, renamed to “value”. KeyError if the metric +has no value dimension of column.

    +
    + +
    +
    +as_multitsframe(metric_id: str | None = None) → MultiTSFrameMetadata[source]
    +

    The metadata of the MULTITSFRAME of the single metric, named metric_id if given

    +

    If metric_id differs from the id of the metric, it becomes its id: in the field named by +the metric key if the metric does not have it, otherwise in the field “metric”, which +then becomes the metric key.

    +
    + +
    + +
    +
    +class hdhelpers.metadata.conventions.MultiTSFrameMetadata(**data)[source]
    +

    Immutable metadata for a MULTITSFRAME: multiple metrics identified by a metric column.

    +

    Each metric can have several value dimensions; a MULTITSFRAME may also hold one metric. +Read from a pandas DataFrame with read_metadata. See the module documentation for storage, +adapter exchange and common field rules.

    +
    +
    +narrowed_to(metric_id: str) → SingleTSFrameMetadata[source]
    +

    The metadata of the SINGLETSFRAME of one metric, KeyError if it has no metadata

    +

    The metric is shared, not copied. It becomes the single and only queried metric.

    +
    + +
    + +
    +
    +

    Reading method guide

    + + ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Shared methods

    Methods

    Purpose

    SeriesMetadata.metric_ids(), metric()

    List metric identifiers or retrieve a metric’s metadata.

    SeriesMetadata.value_dimension_info(), value_dimension_infos()

    Read a field or dotted path for one value dimension, or for all metrics and columns. +An ordered sequence of fields specifies fallbacks, e.g. ("display_name", "name").

    SeriesMetadata.unit(), units(), +measurement(), measurements()

    Read units or measured quantities, e.g. "°C" or "temperature".

    SeriesMetadata.name(), names(), +display_name(), display_names(), +short_display_name(), short_display_names()

    Read names or labels with the documented fallbacks.

    SeriesMetadata.metric_info(), metric_infos()

    Read a field or dotted path, searching the metric before its value dimension. +Inherited properties are a final fallback when enabled.

    SeriesMetadata.default_timezone(), default_timezones()

    Read the time zone for calculations, falling back to the dataset default.

    SeriesMetadata.recording(), recordings()

    Read Recording objects describing how values are recorded.

    SeriesMetadata.default_aggregation_method(), default_aggregation_methods()

    Read explicit or inferred aggregation methods, normalizing AVG to MEAN.

    SeriesMetadata.queried_interval()

    Read the requested interval as UTC datetimes.

    +
    +
    +

    Nested metadata

    +

    These classes describe the root models’ fields. Construct them from convention +fields with ModelName.model_validate(mapping), or access them through a root +model, e.g. meta.metric().value_dimensions. Required fields may accept None +where the conventions permit unknown values.

    +
    +
    +class hdhelpers.metadata.conventions.MetricMetadata(**data)[source]
    +

    Metadata of a metric, i.e. of a single, possibly multidimensional timeseries

    +

    A metric is identified by its field named by dataset_metadata.metric_key, whose values occur +in the metric column of a MULTITSFRAME. Every metric must have this field as a string, +unique among the metrics.

    +

    Required fields include id, name and value_dimensions. Other fields +describe labels, recording, default time zone, aggregation, comments and inherited properties.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.ValueDimensionMetadata(**data)[source]
    +

    Metadata for one metric’s value column, such as its unit and measured quantity.

    +

    Each metric describes its value columns; a SERIES uses “value”. Non-null fields take +precedence over shared metadata for the same column. Unit and measurement are required +fields, but may be None if unknown.

    +

    Required fields are column, unit and measurement. Unit and measurement +may be None. Optional fields include names, data type, deadband and display precision.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.SharedValueDimensionMetadata(**data)[source]
    +

    Default metadata for a value column shared across metrics.

    +

    Matched by column name (“value” for a SERIES) and used when the corresponding field +is missing from metric-specific metadata.

    +

    column identifies the value dimension. Its other fields supply shared defaults.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.SingleMetricDatasetMetadata(**data)[source]
    +

    Metadata associated with a SERIES or SINGLETSFRAME as a whole

    +

    E.g. the queried time interval or how existing data should be handled when the dataset is +sent to an adapter. The fields concerning invalidation and deletion are only relevant for +adapters actually supporting these operations, like the built-in SQL adapter. Other adapters +may ignore them.

    +

    Start and end of an interval must be provided together.

    +

    single_metric identifies the sole metric using metric_key.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.MultiTSFrameDatasetMetadata(**data)[source]
    +

    Metadata associated with a MULTITSFRAME as a whole

    +

    E.g. the queried time interval or how existing data should be handled when the dataset is +sent to an adapter. The fields concerning invalidation and deletion are only relevant for +adapters actually supporting these operations, like the built-in SQL adapter. Other adapters +may ignore them.

    +

    Start and end of an interval must be provided together.

    +

    Both dataset models include metric_key, queried_metrics, the requested interval, +default settings and adapter invalidation settings. Here single_metric is optional +and applies only to a dataset containing one metric.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.InheritedMetricMetadata(**data)[source]
    +

    Properties supplied by a source system for a metric and its parent assets.

    +

    Stores already-resolved values, e.g. a plant’s latitude; the source system resolves the +hierarchy. Metadata lookups use these properties as fallbacks only with +include_inherited=True. Convention-defined fields such as name, id and unit belong in +the metric or value-dimension metadata.

    +

    Use JSON types matching each value’s meaning: numbers, booleans and UTC ISO timestamp +strings for points in time. For example, hetida platform supplies properties by label, +using a signal’s own value or that of the nearest parent asset providing one.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.MetricComment(**data)[source]
    +

    A comment attached to a metric, describing a time range of its timeseries data

    +

    E.g. the hetida platform provides the comments on a signal whose time range overlaps the +queried interval and which existed at query time, newest first.

    +

    Start and end of the time range must be provided together. They are equal for a comment on a +single point in time.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.MetricCommentReply(**data)[source]
    +

    A reply to a comment attached to a metric

    +
    + +
    +
    +class hdhelpers.metadata.conventions.SignalGroupMetadata(**data)[source]
    +

    A group of signals a dataset was read from

    +

    E.g. the hetida platform provides the signal group a MULTITSFRAME was read from.

    +
    + +
    +
    +

    Allowed values and validation issues

    +
    +
    +class hdhelpers.metadata.conventions.IntervalType(*values)[source]
    +

    Which boundaries belong to a time interval

    +

    closed: both, open: none, left_closed / right_open: only the start, +right_closed / left_open: only the end.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.AggregationMethod(*values)[source]
    +

    Method for aggregating timeseries values into coarser intervals.

    +

    Metadata lookups normalize AVG to MEAN; they are distinct enum values.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.ValueDataType(*values)[source]
    +

    Data type of the values of a value dimension

    +
    + +
    +
    +class hdhelpers.metadata.conventions.RecordingMode(*values)[source]
    +

    When the values of a timeseries are recorded

    +

    periodic: one value per interval of data_frequency, timestamped with the start of the +interval. A missing value is a gap. +on_change: a value is recorded when it changes (by more than the deadband of its value +dimension) and holds until the next one. Missing timestamps are no gaps, as long as +data_frequency (the heartbeat) is not exceeded. +irregular: values at arbitrary points in time.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.MetadataIssueSeverity(*values)[source]
    +

    Severity of an issue found when checking metadata against the metadata conventions

    +

    error: Violates the conventions, so helpers and components may misinterpret the metadata. +warning: Likely a mistake, e.g. metadata not matching the data.

    +
    + +
    +
    +class hdhelpers.metadata.conventions.MetadataConventionIssue(**data)[source]
    +

    A metadata validation problem, reported as an error or warning.

    +
    +
    +severity
    +

    Error for a violation; warning for a likely mistake.

    +
    +
    Type:
    +

    MetadataIssueSeverity

    +
    +
    +
    + +
    +
    +location
    +

    Path to the affected field, e.g. “metrics[0].name”; empty for the +metadata as a whole.

    +
    +
    Type:
    +

    str

    +
    +
    +
    + +
    +
    +message
    +

    Explanation of the problem.

    +
    +
    Type:
    +

    str

    +
    +
    +
    + +
    + +

    Obtain validation issues with hdhelpers.metadata.check_metadata_conventions(). +When required metadata cannot be read, hdhelpers.metadata.read_metadata() +raises MetadataConventionError, whose issues attribute +contains these issue objects.

    +
    +
    + + +
    +
    +
    +
    + +
    +
    + + + + \ No newline at end of file diff --git a/docs/objects.inv b/docs/objects.inv index 4b30d33..0d20204 100644 Binary files a/docs/objects.inv and b/docs/objects.inv differ diff --git a/docs/py-modindex.html b/docs/py-modindex.html index 6d07dff..3471a8f 100644 --- a/docs/py-modindex.html +++ b/docs/py-modindex.html @@ -6,16 +6,17 @@ Python Module Index — hdhelpers - documentation - - - + + + - + + - + @@ -29,15 +30,15 @@

    Navigation

    modules | - + -
    +
    - +

    Python Module Index

    @@ -108,12 +109,12 @@

    Navigation

    modules | - +
    \ No newline at end of file diff --git a/docs/search.html b/docs/search.html index 46580a9..199fde1 100644 --- a/docs/search.html +++ b/docs/search.html @@ -6,21 +6,22 @@ Search — hdhelpers - documentation - - - - + + + + - + + - + +
    - +

    Search

    - + - - + +

    Searching for multiple words only shows matches that contain all words.

    - - + +
    - - + +
    - +
    @@ -90,12 +91,12 @@

    Navigation

    modules | - +
    \ No newline at end of file diff --git a/docs/searchindex.js b/docs/searchindex.js index 75b26e4..798657e 100644 --- a/docs/searchindex.js +++ b/docs/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles":{"First steps":[[0,null]],"Functions":[[1,"functions"]],"Further Information":[[1,"further-information"]],"How to get metadata with hdhelpers?":[[0,"how-to-get-metadata-with-hdhelpers"]],"How to query nested information?":[[0,"how-to-query-nested-information"]],"How to use hdhelpers for plotting? (tbd)":[[0,"how-to-use-hdhelpers-for-plotting-tbd"]],"Introduction":[[1,"introduction"]],"Where does the information come from?":[[0,"where-does-the-information-come-from"]],"Which time zone to compute in?":[[0,"which-time-zone-to-compute-in"]],"exceptions":[[1,"module-hdhelpers.exceptions"]],"hdhelpers":[[1,null]],"helpers":[[1,"module-hdhelpers.helpers"]],"metadata":[[1,"module-hdhelpers.metadata"]],"plotting":[[1,"module-hdhelpers.plotting"]]},"docnames":["first_steps","index"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["first_steps.rst","index.rst"],"indexentries":{"data_frequency (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.data_frequency",false]],"data_frequency_offset (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.data_frequency_offset",false]],"data_frequency_timezone (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.data_frequency_timezone",false]],"get_default_aggregation_methods() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_default_aggregation_methods",false]],"get_default_timezones() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_default_timezones",false]],"get_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_display_names",false]],"get_locale() (in module hdhelpers.helpers)":[[1,"hdhelpers.helpers.get_locale",false]],"get_measurements() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_measurements",false]],"get_metric_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_metric_info",false]],"get_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_names",false]],"get_queried_interval() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_queried_interval",false]],"get_recordings() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_recordings",false]],"get_series_default_aggregation_method() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_default_aggregation_method",false]],"get_series_default_timezone() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_default_timezone",false]],"get_series_display_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_display_name",false]],"get_series_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_info",false]],"get_series_measurement() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_measurement",false]],"get_series_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_name",false]],"get_series_recording() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_recording",false]],"get_series_short_display_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_short_display_name",false]],"get_series_unit() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_unit",false]],"get_short_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_short_display_names",false]],"get_singlets_default_aggregation_method() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_default_aggregation_method",false]],"get_singlets_default_timezone() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_default_timezone",false]],"get_singlets_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_display_names",false]],"get_singlets_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_info",false]],"get_singlets_measurements() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_measurements",false]],"get_singlets_metric_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_metric_info",false]],"get_singlets_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_names",false]],"get_singlets_recording() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_recording",false]],"get_singlets_short_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_short_display_names",false]],"get_singlets_units() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_units",false]],"get_units() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_units",false]],"hdhelpers.exceptions":[[1,"module-hdhelpers.exceptions",false]],"hdhelpers.helpers":[[1,"module-hdhelpers.helpers",false]],"hdhelpers.metadata":[[1,"module-hdhelpers.metadata",false]],"hdhelpers.plotting":[[1,"module-hdhelpers.plotting",false]],"helperexception":[[1,"hdhelpers.exceptions.HelperException",false]],"insufficientplottingdata":[[1,"hdhelpers.exceptions.InsufficientPlottingData",false]],"modify_timezone() (in module hdhelpers.helpers)":[[1,"hdhelpers.helpers.modify_timezone",false]],"module":[[1,"module-hdhelpers.exceptions",false],[1,"module-hdhelpers.helpers",false],[1,"module-hdhelpers.metadata",false],[1,"module-hdhelpers.plotting",false]],"recording (class in hdhelpers.metadata)":[[1,"hdhelpers.metadata.Recording",false]],"recording_aggregation_method (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.recording_aggregation_method",false]],"recording_mode (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.recording_mode",false]],"resolve_color() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.resolve_color",false]],"set_agnostic_theme() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.set_agnostic_theme",false]],"set_dt_ticks() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.set_dt_ticks",false]],"without_metadata() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.without_metadata",false]]},"objects":{"hdhelpers":[[1,0,0,"-","exceptions"],[1,0,0,"-","helpers"],[1,0,0,"-","metadata"],[1,0,0,"-","plotting"]],"hdhelpers.exceptions":[[1,1,1,"","HelperException"],[1,1,1,"","InsufficientPlottingData"]],"hdhelpers.helpers":[[1,2,1,"","get_locale"],[1,2,1,"","modify_timezone"]],"hdhelpers.metadata":[[1,3,1,"","Recording"],[1,2,1,"","get_default_aggregation_methods"],[1,2,1,"","get_default_timezones"],[1,2,1,"","get_display_names"],[1,2,1,"","get_measurements"],[1,2,1,"","get_metric_info"],[1,2,1,"","get_names"],[1,2,1,"","get_queried_interval"],[1,2,1,"","get_recordings"],[1,2,1,"","get_series_default_aggregation_method"],[1,2,1,"","get_series_default_timezone"],[1,2,1,"","get_series_display_name"],[1,2,1,"","get_series_info"],[1,2,1,"","get_series_measurement"],[1,2,1,"","get_series_name"],[1,2,1,"","get_series_recording"],[1,2,1,"","get_series_short_display_name"],[1,2,1,"","get_series_unit"],[1,2,1,"","get_short_display_names"],[1,2,1,"","get_singlets_default_aggregation_method"],[1,2,1,"","get_singlets_default_timezone"],[1,2,1,"","get_singlets_display_names"],[1,2,1,"","get_singlets_info"],[1,2,1,"","get_singlets_measurements"],[1,2,1,"","get_singlets_metric_info"],[1,2,1,"","get_singlets_names"],[1,2,1,"","get_singlets_recording"],[1,2,1,"","get_singlets_short_display_names"],[1,2,1,"","get_singlets_units"],[1,2,1,"","get_units"],[1,2,1,"","without_metadata"]],"hdhelpers.metadata.Recording":[[1,4,1,"","data_frequency"],[1,4,1,"","data_frequency_offset"],[1,4,1,"","data_frequency_timezone"],[1,4,1,"","recording_aggregation_method"],[1,4,1,"","recording_mode"]],"hdhelpers.plotting":[[1,2,1,"","resolve_color"],[1,2,1,"","set_agnostic_theme"],[1,2,1,"","set_dt_ticks"]]},"objnames":{"0":["py","module","Python module"],"1":["py","exception","Python exception"],"2":["py","function","Python function"],"3":["py","class","Python class"],"4":["py","attribute","Python attribute"]},"objtypes":{"0":"py:module","1":"py:exception","2":"py:function","3":"py:class","4":"py:attribute"},"terms":{"000z":[],"00z":1,"01t01":1,"01t08":0,"05t13":1,"06t13":1,"15min":1,"1d":[0,1],"1h":1,"1min":1,"1ms":1,"2h":1,"5min":1,"A":[0,1],"Both":0,"DO":0,"Each":1,"For":[0,1],"How":1,"If":[0,1],"In":[0,1],"It":[0,1],"NOT":0,"Other":0,"Our":0,"So":0,"That":0,"The":[0,1],"They":1,"This":1,"To":1,"We":0,"Where":1,"Which":1,"__data__":0,"__hd_wrapped_data_object__":0,"__metadata__":0,"abc":1,"abov":0,"absent":1,"abstract":1,"accept":[0,1],"access":1,"accord":0,"acm":[0,1],"addit":0,"adequ":1,"adher":0,"adit":1,"adjust":1,"aggreg":[0,1],"agnost":1,"alia":1,"align":1,"all_timezon":1,"allow":[0,1],"also":[0,1],"alway":0,"ambigu":1,"america":[0,1],"ani":[0,1],"anoth":1,"anyway":0,"appli":[0,1],"applic":1,"arbitrari":1,"arbitrarili":1,"arg":1,"argument":0,"asset":[0,1],"associ":1,"assum":1,"attach":1,"attr":1,"avail":1,"average_level":1,"avg":1,"axe":1,"axi":0,"back":[0,1],"background":1,"base":1,"befor":[0,1],"behaviour":1,"belong":[0,1],"berlin":[0,1],"berlin_sensor":1,"bool":1,"boolean":1,"bucket":[0,1],"by_metr":1,"c":1,"calendar":0,"calibr":0,"calibration_d":0,"can":[0,1],"carri":1,"case":1,"caught":0,"certain":1,"chang":1,"cheap":1,"class":1,"close":0,"cm":0,"coarser":1,"code":[0,1],"collect":1,"color":[0,1],"color_str":1,"column":[0,1],"column_nam":1,"come":1,"comment":[0,1],"compli":1,"compon":[0,1],"comput":1,"concret":1,"consist":1,"contain":[0,1],"content":0,"context":1,"contrast":[0,1],"control":1,"convent":[0,1],"convert":1,"convert_index":1,"copi":1,"correspond":[0,1],"count":[0,1],"counterpart":1,"creat":1,"current":1,"custom":1,"daili":0,"daily_sum":0,"dark":1,"dashboard":0,"data":[0,1],"data_frequ":1,"data_frequency_offset":1,"data_frequency_timezon":1,"datafram":1,"dataset":[0,1],"dataset_metadata":[0,1],"date":[0,1],"datetim":1,"day":[0,1],"daylight":0,"de":1,"de_d":1,"deadband":1,"dedic":[0,1],"deep":1,"def":0,"default":[0,1],"default_aggregation_method":1,"default_timezon":[0,1],"default_titl":[],"defaultdict":1,"defin":[0,1],"depend":0,"describ":0,"design":[0,1],"determin":1,"dict":1,"dictionari":[0,1],"differ":0,"dimens":[0,1],"direct":[0,1],"display":[0,1],"display_nam":1,"display_name_of_metric1":1,"display_name_of_seri":1,"doe":1,"dot":[0,1],"e":[0,1],"ea1255":1,"eas":1,"eb6962":1,"edit":0,"either":0,"empti":[0,1],"en":1,"enabl":1,"end":[0,1],"energi":1,"entri":[0,1],"entrypoint":0,"equival":1,"error":[0,1],"error_cod":1,"especi":1,"europ":[0,1],"evalu":[0,1],"everi":[0,1],"exact":1,"exampl":[0,1],"exclus":1,"execut":1,"expect":1,"explicit":[0,1],"explicit_local":1,"external_first":1,"external_id":[0,1],"external_second":1,"extra_inform":1,"extract":1,"fall":[0,1],"fallback":0,"fals":[0,1],"field":[0,1],"fig":[0,1],"figur":[0,1],"filter":1,"final":1,"find":0,"firmwar":0,"first":1,"fix":[0,1],"follow":[0,1],"format":1,"frequenc":1,"frontend":1,"full_titl":0,"function":0,"fuseki":1,"g":[0,1],"gap":1,"gaug":0,"generat":1,"german":1,"germani":0,"get":1,"get_default_aggregation_method":1,"get_default_timezon":[0,1],"get_display_nam":1,"get_local":[0,1],"get_measur":1,"get_metric_info":[0,1],"get_nam":1,"get_queried_interv":[0,1],"get_record":1,"get_series_default_aggregation_method":1,"get_series_default_timezon":[0,1],"get_series_display_nam":1,"get_series_info":[0,1],"get_series_measur":1,"get_series_nam":[0,1],"get_series_record":1,"get_series_short_display_nam":1,"get_series_unit":[0,1],"get_short_display_nam":1,"get_singlets_default_aggregation_method":1,"get_singlets_default_timezon":[0,1],"get_singlets_display_nam":1,"get_singlets_info":[0,1],"get_singlets_measur":1,"get_singlets_metric_info":[0,1],"get_singlets_nam":1,"get_singlets_record":1,"get_singlets_short_display_nam":1,"get_singlets_unit":[0,1],"get_unit":[0,1],"get_value_dimension_info":1,"given":[0,1],"global":1,"glom":[0,1],"go":[0,1],"good":1,"graph_object":[0,1],"group":[0,1],"groupbi":0,"handl":[0,1],"happen":1,"heartbeat":1,"helper":0,"helperexcept":1,"hetida":[0,1],"hierarchi":[0,1],"higher":[0,1],"hold":1,"hour":0,"iana":[0,1],"id":[0,1],"identifi":1,"import":[0,1],"includ":1,"include_inherit":[0,1],"index":[0,1],"infer":1,"info":1,"inherit":[0,1],"inspir":1,"instal":1,"instead":[0,1],"insuffici":1,"insufficientplottingdata":1,"int":1,"integr":1,"interpret":[0,1],"interv":[0,1],"irregular":1,"iso":1,"isoformat":1,"json":[0,1],"just":1,"key":[0,1],"keyword":0,"ki":[0,1],"kwarg":1,"label":[0,1],"languag":1,"last":[0,1],"latest":1,"latitud":[0,1],"latter":1,"let":[0,1],"level":[0,1],"light":1,"like":[0,1],"limit":0,"line":0,"list":[0,1],"local":1,"longer":1,"longitud":0,"look":[0,1],"loop":1,"m":1,"main":0,"mainten":0,"mani":1,"manufactur":[0,1],"marker":0,"max":1,"may":0,"mean":1,"meaning":1,"meant":[0,1],"measur":[0,1],"messag":1,"method":1,"metric":[0,1],"metric1":1,"metric2":1,"metric3":1,"metric_info":1,"metric_key":[0,1],"metric_without_metadata":1,"midnight":[0,1],"might":0,"miss":[0,1],"modifi":1,"modified_timezon":1,"modify_timezon":[0,1],"mtsf":1,"much":1,"multipl":1,"multitsfram":[0,1],"name":[0,1],"name_of_metric1":1,"name_of_metric2":1,"name_of_series_1":1,"name_of_series_2":1,"neither":[0,1],"nest":1,"never":0,"nevertheless":0,"new":0,"new_york":[0,1],"new_york_sensor":1,"next":[0,1],"non":1,"none":[0,1],"normal":1,"north":[0,1],"note":[0,1],"null":[0,1],"object":[0,1],"object_to_convert":1,"offset":[0,1],"on_chang":1,"onc":1,"one":[0,1],"onli":[0,1],"oper":[0,1],"option":1,"orang":1,"order":0,"otherwis":1,"output":1,"packag":1,"panda":[0,1],"paramet":1,"pass":0,"path":[0,1],"pd":1,"per":[0,1],"period":1,"place":1,"plain":0,"plant":[0,1],"platform":[0,1],"pleas":1,"plot_target_set":1,"point":1,"possibl":1,"present":[0,1],"print":1,"properti":[0,1],"provid":[0,1],"provis":0,"pump_run":1,"pump_stat":1,"pytz":1,"queri":1,"rainfal":1,"rais":[0,1],"rang":0,"rather":0,"re":1,"reach":[0,1],"receiv":0,"record":1,"recording_aggregation_method":1,"recording_mod":1,"red":1,"ref_data_frequ":1,"ref_data_frequency_offset":1,"ref_data_frequency_timezon":1,"ref_interval_end_timestamp":[0,1],"ref_interval_start_timestamp":[0,1],"ref_interval_typ":0,"replac":0,"report":[0,1],"repres":0,"request":[0,1],"requir":0,"resampl":0,"resolv":1,"resolve_color":[0,1],"resort":[0,1],"respect":[0,1],"result":[0,1],"retriev":[0,1],"return":[0,1],"row":1,"ruhr":1,"ruhrverband":0,"s":[0,1],"sampl":1,"save":0,"say":0,"scatter":[0,1],"scheme":[],"second":1,"see":1,"select":1,"sensibl":1,"sensor":[0,1],"separ":0,"seri":[0,1],"serial":1,"set":[0,1],"set_agnostic_them":[0,1],"set_dt_tick":[0,1],"set_index":0,"shade":1,"share":1,"shift":1,"short":[0,1],"short_display_nam":[0,1],"short_display_name_of_metric1":1,"short_display_name_of_seri":1,"show":[0,1],"similar":1,"sinc":[0,1],"singl":1,"single_metr":[0,1],"single_metric_dataset_metadata":[],"single_metric_metadata":1,"singletsfram":1,"skip":0,"spec":[0,1],"specifi":1,"split":0,"standard":[0,1],"start":[0,1],"state":1,"step":1,"str":[0,1],"streamlin":1,"string":[0,1],"structured_metadata":1,"stsf":1,"style":1,"sub":[0,1],"sum":[0,1],"surpress":1,"switch":[0,1],"t":1,"take":1,"taken":1,"target":[0,1],"tbd":1,"temp":1,"temperatur":1,"templat":1,"test":1,"theme":1,"therefor":1,"tick":1,"time":1,"timedelta":1,"timeseri":[0,1],"timestamp":[0,1],"timezon":[0,1],"to_datetim":1,"to_timezon":[0,1],"togeth":1,"toggleabl":1,"total_second":1,"transform":[0,1],"tri":1,"true":[0,1],"tupl":1,"type":1,"typeerror":1,"tz_convert":[0,1],"underlying":1,"unit":[0,1],"unknown":1,"update_layout":0,"update_xax":0,"upper":0,"us":1,"use":1,"user":0,"usual":0,"utc":[0,1],"utcoffset":1,"valu":[0,1],"value_data_typ":1,"value_dim_1":1,"value_dim_info":1,"value_dimens":[0,1],"value_dimensions_shar":[0,1],"value_name_of_seri":1,"valueerror":1,"valv":1,"vari":1,"variabl":1,"via":[0,1],"view":0,"viewer":0,"vision":0,"want":0,"water":[0,1],"way":1,"well":1,"whether":1,"whole":[0,1],"whose":[0,1],"will":1,"win":0,"without":[0,1],"without_metadata":1,"write":1,"x":[0,1],"y":[0,1],"yaxis_titl":0,"yield":[0,1],"york":0,"zone":1,"zoom":1},"titles":["First steps","hdhelpers"],"titleterms":{"Further":1,"How":0,"Where":0,"Which":0,"come":0,"comput":0,"doe":0,"except":1,"first":0,"function":1,"get":0,"hdhelper":[0,1],"helper":1,"inform":[0,1],"introduct":1,"metadata":[0,1],"nest":0,"plot":[0,1],"queri":0,"step":0,"tbd":0,"time":0,"use":0,"zone":0}}) \ No newline at end of file +Search.setIndex({"alltitles":{"Allowed values and validation issues":[[4,"allowed-values-and-validation-issues"]],"Automatic metadata type selection":[[3,"automatic-metadata-type-selection"]],"Core package":[[2,"core-package"]],"Default aggregation methods":[[3,"default-aggregation-methods"]],"Default time zones":[[3,"default-time-zones"]],"First steps":[[0,null]],"Functions":[[1,"functions"]],"Further Information":[[1,"further-information"]],"How to get metadata with hdhelpers?":[[0,"how-to-get-metadata-with-hdhelpers"]],"How to query nested information?":[[0,"how-to-query-nested-information"]],"How to use hdhelpers for plotting? (tbd)":[[0,"how-to-use-hdhelpers-for-plotting-tbd"]],"Installation":[[2,null]],"Introduction":[[1,"introduction"]],"Metadata as model":[[0,"metadata-as-model"]],"Metadata examples":[[3,null]],"Metadata lookup basics":[[3,"metadata-lookup-basics"]],"Metadata model reference":[[4,null]],"Metric fields and value-dimension fields":[[3,"metric-fields-and-value-dimension-fields"]],"Names and display-name fallbacks":[[3,"names-and-display-name-fallbacks"]],"Nested metadata":[[4,"nested-metadata"]],"Plotting helpers":[[2,"plotting-helpers"]],"Reading and updating":[[4,"reading-and-updating"]],"Reading method guide":[[4,"reading-method-guide"]],"Recording information":[[3,"recording-information"]],"Root classes and methods":[[4,"root-classes-and-methods"]],"Root models":[[4,"id1"]],"Shared data setup":[[3,"shared-data-setup"]],"Shared methods":[[4,"id2"]],"Units, measurements and return shapes":[[3,"units-measurements-and-return-shapes"]],"Verify the installation":[[2,"verify-the-installation"]],"Where does the information come from?":[[0,"where-does-the-information-come-from"]],"Which time zone to compute in?":[[0,"which-time-zone-to-compute-in"]],"exceptions":[[1,"exceptions"]],"hdhelpers":[[1,null]],"helpers":[[1,"module-hdhelpers.helpers"]],"metadata":[[1,"metadata"]],"plotting":[[1,"module-hdhelpers.plotting"]]},"docnames":["first_steps","index","installation","metadata_examples","metadata_models"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2,"sphinx.ext.viewcode":1},"filenames":["first_steps.rst","index.rst","installation.rst","metadata_examples.rst","metadata_models.rst"],"indexentries":{"aggregationmethod (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.AggregationMethod",false]],"as_multitsframe() (hdhelpers.metadata.conventions.singletsframemetadata method)":[[4,"hdhelpers.metadata.conventions.SingleTSFrameMetadata.as_multitsframe",false]],"as_series() (hdhelpers.metadata.conventions.singletsframemetadata method)":[[4,"hdhelpers.metadata.conventions.SingleTSFrameMetadata.as_series",false]],"as_singletsframe() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.as_singletsframe",false]],"attach_metadata() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.attach_metadata",false]],"attrs_to_json() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.attrs_to_json",false]],"check_metadata_conventions() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.check_metadata_conventions",false]],"copy_without_metadata() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.copy_without_metadata",false]],"data_frequency (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.data_frequency",false]],"data_frequency_offset (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.data_frequency_offset",false]],"data_frequency_timezone (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.data_frequency_timezone",false]],"default_aggregation_method() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.default_aggregation_method",false]],"default_aggregation_methods() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.default_aggregation_methods",false]],"default_timezone() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.default_timezone",false]],"default_timezones() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.default_timezones",false]],"display_name() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.display_name",false]],"display_names() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.display_names",false]],"error_code (hdhelpers.exceptions.helperexception attribute)":[[1,"hdhelpers.exceptions.HelperException.error_code",false]],"extra_information (hdhelpers.exceptions.helperexception attribute)":[[1,"hdhelpers.exceptions.HelperException.extra_information",false]],"get_default_aggregation_methods() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_default_aggregation_methods",false]],"get_default_timezones() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_default_timezones",false]],"get_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_display_names",false]],"get_locale() (in module hdhelpers.helpers)":[[1,"hdhelpers.helpers.get_locale",false]],"get_measurements() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_measurements",false]],"get_metric_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_metric_info",false]],"get_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_names",false]],"get_queried_interval() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_queried_interval",false]],"get_recordings() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_recordings",false]],"get_series_default_aggregation_method() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_default_aggregation_method",false]],"get_series_default_timezone() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_default_timezone",false]],"get_series_display_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_display_name",false]],"get_series_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_info",false]],"get_series_measurement() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_measurement",false]],"get_series_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_name",false]],"get_series_recording() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_recording",false]],"get_series_short_display_name() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_short_display_name",false]],"get_series_unit() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_series_unit",false]],"get_short_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_short_display_names",false]],"get_singlets_default_aggregation_method() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_default_aggregation_method",false]],"get_singlets_default_timezone() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_default_timezone",false]],"get_singlets_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_display_names",false]],"get_singlets_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_info",false]],"get_singlets_measurements() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_measurements",false]],"get_singlets_metric_info() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_metric_info",false]],"get_singlets_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_names",false]],"get_singlets_recording() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_recording",false]],"get_singlets_short_display_names() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_short_display_names",false]],"get_singlets_units() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_singlets_units",false]],"get_units() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.get_units",false]],"hdhelpers.exceptions":[[1,"module-hdhelpers.exceptions",false]],"hdhelpers.helpers":[[1,"module-hdhelpers.helpers",false]],"hdhelpers.metadata":[[1,"module-hdhelpers.metadata",false]],"hdhelpers.plotting":[[1,"module-hdhelpers.plotting",false]],"helperexception":[[1,"hdhelpers.exceptions.HelperException",false]],"inheritedmetricmetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.InheritedMetricMetadata",false]],"insufficientplottingdata":[[1,"hdhelpers.exceptions.InsufficientPlottingData",false]],"intervaltype (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.IntervalType",false]],"issues (hdhelpers.metadata.metadataconventionerror attribute)":[[1,"hdhelpers.metadata.MetadataConventionError.issues",false]],"location (hdhelpers.metadata.conventions.metadataconventionissue attribute)":[[4,"hdhelpers.metadata.conventions.MetadataConventionIssue.location",false]],"measurement() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.measurement",false]],"measurements() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.measurements",false]],"message (hdhelpers.metadata.conventions.metadataconventionissue attribute)":[[4,"hdhelpers.metadata.conventions.MetadataConventionIssue.message",false]],"metadataconventionerror":[[1,"hdhelpers.metadata.MetadataConventionError",false]],"metadataconventionissue (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.MetadataConventionIssue",false]],"metadataissueseverity (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.MetadataIssueSeverity",false]],"metric() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.metric",false]],"metric_ids() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.metric_ids",false]],"metric_info() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.metric_info",false]],"metric_infos() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.metric_infos",false]],"metriccomment (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.MetricComment",false]],"metriccommentreply (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.MetricCommentReply",false]],"metricmetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.MetricMetadata",false]],"modify_timezone() (in module hdhelpers.helpers)":[[1,"hdhelpers.helpers.modify_timezone",false]],"module":[[1,"module-hdhelpers.exceptions",false],[1,"module-hdhelpers.helpers",false],[1,"module-hdhelpers.metadata",false],[1,"module-hdhelpers.plotting",false]],"multitsframedatasetmetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.MultiTSFrameDatasetMetadata",false]],"multitsframemetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.MultiTSFrameMetadata",false]],"name() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.name",false]],"names() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.names",false]],"narrowed_to() (hdhelpers.metadata.conventions.multitsframemetadata method)":[[4,"hdhelpers.metadata.conventions.MultiTSFrameMetadata.narrowed_to",false]],"queried_interval() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.queried_interval",false]],"read_metadata() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.read_metadata",false]],"recording (class in hdhelpers.metadata)":[[1,"hdhelpers.metadata.Recording",false]],"recording() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.recording",false]],"recording_aggregation_method (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.recording_aggregation_method",false]],"recording_mode (hdhelpers.metadata.recording attribute)":[[1,"hdhelpers.metadata.Recording.recording_mode",false]],"recordingmode (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.RecordingMode",false]],"recordings() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.recordings",false]],"resolve_color() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.resolve_color",false]],"seriesmetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata",false]],"set_agnostic_theme() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.set_agnostic_theme",false]],"set_dt_ticks() (in module hdhelpers.plotting)":[[1,"hdhelpers.plotting.set_dt_ticks",false]],"set_metadata() (in module hdhelpers.metadata)":[[1,"hdhelpers.metadata.set_metadata",false]],"severity (hdhelpers.metadata.conventions.metadataconventionissue attribute)":[[4,"hdhelpers.metadata.conventions.MetadataConventionIssue.severity",false]],"sharedvaluedimensionmetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.SharedValueDimensionMetadata",false]],"short_display_name() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.short_display_name",false]],"short_display_names() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.short_display_names",false]],"signalgroupmetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.SignalGroupMetadata",false]],"singlemetricdatasetmetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.SingleMetricDatasetMetadata",false]],"singletsframemetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.SingleTSFrameMetadata",false]],"unit() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.unit",false]],"units() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.units",false]],"value_dimension_info() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.value_dimension_info",false]],"value_dimension_infos() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.value_dimension_infos",false]],"valuedatatype (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.ValueDataType",false]],"valuedimensionmetadata (class in hdhelpers.metadata.conventions)":[[4,"hdhelpers.metadata.conventions.ValueDimensionMetadata",false]],"with_dataset_metadata() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.with_dataset_metadata",false]],"with_metric() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.with_metric",false]],"with_value_dimension() (hdhelpers.metadata.conventions.seriesmetadata method)":[[4,"hdhelpers.metadata.conventions.SeriesMetadata.with_value_dimension",false]]},"objects":{"hdhelpers":[[1,0,0,"-","exceptions"],[1,0,0,"-","helpers"],[1,0,0,"-","metadata"],[1,0,0,"-","plotting"]],"hdhelpers.exceptions":[[1,1,1,"","HelperException"],[1,1,1,"","InsufficientPlottingData"]],"hdhelpers.exceptions.HelperException":[[1,2,1,"","error_code"],[1,2,1,"","extra_information"]],"hdhelpers.helpers":[[1,3,1,"","get_locale"],[1,3,1,"","modify_timezone"]],"hdhelpers.metadata":[[1,1,1,"","MetadataConventionError"],[1,4,1,"","Recording"],[1,3,1,"","attach_metadata"],[1,3,1,"","attrs_to_json"],[1,3,1,"","check_metadata_conventions"],[1,3,1,"","copy_without_metadata"],[1,3,1,"","get_default_aggregation_methods"],[1,3,1,"","get_default_timezones"],[1,3,1,"","get_display_names"],[1,3,1,"","get_measurements"],[1,3,1,"","get_metric_info"],[1,3,1,"","get_names"],[1,3,1,"","get_queried_interval"],[1,3,1,"","get_recordings"],[1,3,1,"","get_series_default_aggregation_method"],[1,3,1,"","get_series_default_timezone"],[1,3,1,"","get_series_display_name"],[1,3,1,"","get_series_info"],[1,3,1,"","get_series_measurement"],[1,3,1,"","get_series_name"],[1,3,1,"","get_series_recording"],[1,3,1,"","get_series_short_display_name"],[1,3,1,"","get_series_unit"],[1,3,1,"","get_short_display_names"],[1,3,1,"","get_singlets_default_aggregation_method"],[1,3,1,"","get_singlets_default_timezone"],[1,3,1,"","get_singlets_display_names"],[1,3,1,"","get_singlets_info"],[1,3,1,"","get_singlets_measurements"],[1,3,1,"","get_singlets_metric_info"],[1,3,1,"","get_singlets_names"],[1,3,1,"","get_singlets_recording"],[1,3,1,"","get_singlets_short_display_names"],[1,3,1,"","get_singlets_units"],[1,3,1,"","get_units"],[1,3,1,"","read_metadata"],[1,3,1,"","set_metadata"]],"hdhelpers.metadata.MetadataConventionError":[[1,2,1,"","issues"]],"hdhelpers.metadata.Recording":[[1,2,1,"","data_frequency"],[1,2,1,"","data_frequency_offset"],[1,2,1,"","data_frequency_timezone"],[1,2,1,"","recording_aggregation_method"],[1,2,1,"","recording_mode"]],"hdhelpers.metadata.conventions":[[4,4,1,"","AggregationMethod"],[4,4,1,"","InheritedMetricMetadata"],[4,4,1,"","IntervalType"],[4,4,1,"","MetadataConventionIssue"],[4,4,1,"","MetadataIssueSeverity"],[4,4,1,"","MetricComment"],[4,4,1,"","MetricCommentReply"],[4,4,1,"","MetricMetadata"],[4,4,1,"","MultiTSFrameDatasetMetadata"],[4,4,1,"","MultiTSFrameMetadata"],[4,4,1,"","RecordingMode"],[4,4,1,"","SeriesMetadata"],[4,4,1,"","SharedValueDimensionMetadata"],[4,4,1,"","SignalGroupMetadata"],[4,4,1,"","SingleMetricDatasetMetadata"],[4,4,1,"","SingleTSFrameMetadata"],[4,4,1,"","ValueDataType"],[4,4,1,"","ValueDimensionMetadata"]],"hdhelpers.metadata.conventions.MetadataConventionIssue":[[4,2,1,"","location"],[4,2,1,"","message"],[4,2,1,"","severity"]],"hdhelpers.metadata.conventions.MultiTSFrameMetadata":[[4,5,1,"","narrowed_to"]],"hdhelpers.metadata.conventions.SeriesMetadata":[[4,5,1,"","as_singletsframe"],[4,5,1,"","default_aggregation_method"],[4,5,1,"","default_aggregation_methods"],[4,5,1,"","default_timezone"],[4,5,1,"","default_timezones"],[4,5,1,"","display_name"],[4,5,1,"","display_names"],[4,5,1,"","measurement"],[4,5,1,"","measurements"],[4,5,1,"","metric"],[4,5,1,"","metric_ids"],[4,5,1,"","metric_info"],[4,5,1,"","metric_infos"],[4,5,1,"","name"],[4,5,1,"","names"],[4,5,1,"","queried_interval"],[4,5,1,"","recording"],[4,5,1,"","recordings"],[4,5,1,"","short_display_name"],[4,5,1,"","short_display_names"],[4,5,1,"","unit"],[4,5,1,"","units"],[4,5,1,"","value_dimension_info"],[4,5,1,"","value_dimension_infos"],[4,5,1,"","with_dataset_metadata"],[4,5,1,"","with_metric"],[4,5,1,"","with_value_dimension"]],"hdhelpers.metadata.conventions.SingleTSFrameMetadata":[[4,5,1,"","as_multitsframe"],[4,5,1,"","as_series"]],"hdhelpers.plotting":[[1,3,1,"","resolve_color"],[1,3,1,"","set_agnostic_theme"],[1,3,1,"","set_dt_ticks"]]},"objnames":{"0":["py","module","Python module"],"1":["py","exception","Python exception"],"2":["py","attribute","Python attribute"],"3":["py","function","Python function"],"4":["py","class","Python class"],"5":["py","method","Python method"]},"objtypes":{"0":"py:module","1":"py:exception","2":"py:attribute","3":"py:function","4":"py:class","5":"py:method"},"terms":{"00z":3,"01t00":[1,3],"01t01":1,"01t08":0,"02t00":3,"15min":[1,3],"1c2d3e4f5a6b":0,"1d":0,"1ms":1,"4c1f0e2a":0,"4e5f":0,"5min":1,"7b3d":0,"9a8b":0,"A":[0,1,3,4],"All":4,"An":[0,1,3,4],"Both":[0,4],"DO":0,"Each":[0,3,4],"For":[0,1,2,3,4],"Here":4,"How":[1,4],"If":[0,1,4],"In":[0,3],"It":[0,1,4],"Its":[1,4],"NOT":0,"No":1,"Other":[0,1,3,4],"Our":0,"So":0,"That":0,"The":[0,1,2,3,4],"These":[3,4],"They":[0,1,3,4],"This":[1,2],"To":[0,2,3],"We":0,"When":[3,4],"Where":1,"Which":[1,4],"With":[0,1,2],"_":1,"__data__":0,"__hd_wrapped_data_object__":0,"__metadata__":0,"__version__":2,"abc":[1,3],"abov":[0,1],"absent":[1,3],"accept":[0,4],"access":[0,1,3,4],"accord":0,"acm":0,"across":[3,4],"actual":4,"adapt":[3,4],"add":2,"addit":[0,3,4],"address":0,"adit":1,"adjust":1,"affect":4,"aggreg":[0,1,4],"aggregationmethod":4,"alia":1,"align":1,"allow":[0,1,3],"alon":1,"alreadi":[1,3,4],"also":[1,2,3,4],"alway":1,"america":0,"among":4,"ani":[1,4],"anyway":0,"api":[1,3],"appli":[0,1,4],"arbitrari":[1,4],"arg":1,"argument":[0,3,4],"as_multitsfram":4,"as_seri":4,"as_singletsfram":4,"asset":[0,3,4],"associ":4,"attach":[0,1,3,4],"attach_metadata":[0,1,3],"attr":[0,1,3,4],"attribut":[1,4],"attrs_to_json":[0,1,3,4],"automat":[1,4],"avail":4,"avg":[1,3,4],"awar":1,"axe":1,"axi":0,"back":[0,3,4],"background":1,"bar":[3,4],"base":1,"basic":[1,4],"becom":[1,4],"befor":[0,3,4],"behavior":1,"belong":[0,1,4],"berlin":[0,1,3],"bool":[1,4],"boolean":[1,3,4],"bound":1,"boundari":4,"bucket":0,"built":4,"c":[1,2,3,4],"calcul":[1,4],"calendar":0,"calibr":0,"calibration_d":0,"call":3,"can":[0,1,4],"caus":1,"celsius":3,"centimetr":1,"chang":[0,1,3,4],"check":[1,3,4],"check_metadata_convent":[0,1,3,4],"choos":3,"class":[1,3],"close":[0,4],"cm":[0,1,4],"coarser":4,"code":[0,1],"collect":1,"color":[0,1],"color_str":1,"column":[0,1,3,4],"column_nam":1,"come":1,"command":2,"comment":[0,4],"common":4,"compar":1,"compat":1,"complet":3,"compli":1,"compon":[0,1,2,3,4],"comput":[1,3],"concern":4,"configur":1,"construct":4,"contain":[0,1,4],"context":1,"continu":2,"contract":0,"contrast":0,"convent":[0,1,3,4],"convers":[3,4],"convert":[1,3,4],"convert_index":1,"copi":[0,1,3,4],"copy_without_metadata":1,"core":1,"correspond":[0,4],"count":[0,3],"creat":[0,1,3],"current":3,"daili":[0,3],"daily_sum":0,"dark":1,"dashboard":0,"data":[0,1,2,4],"data_frequ":[1,3,4],"data_frequency_offset":1,"data_frequency_timezon":1,"data_typ":[1,3],"datafram":[0,1,3,4],"dataset":[0,1,3,4],"dataset_metadata":[0,1,3,4],"date":[0,1],"datetim":[1,4],"day":0,"daylight":0,"de":1,"de_d":1,"deadband":[1,4],"decim":[1,3],"dedic":0,"deepcopi":3,"def":0,"default":[0,1,4],"default_aggregation_method":[1,4],"default_timezon":[0,1,4],"defaultdict":1,"defin":[0,4],"degre":3,"delet":4,"depend":[0,1,4],"deriv":0,"describ":[0,1,3,4],"descript":4,"design":[0,1,2,3],"detail":1,"dict":[1,3,4],"dictionari":[1,4],"differ":[0,4],"dimens":[0,1,4],"dimensionless":1,"direct":[0,3,4],"disabl":3,"display":[0,1,4],"display_nam":[0,1,4],"distinct":[3,4],"distinguish":3,"docstr":4,"document":[3,4],"doe":[1,4],"dot":[0,1,3,4],"dump":[0,1],"e":[0,1,4],"eas":1,"eb6962":1,"edit":0,"either":1,"empti":[0,1,3,4],"enabl":[0,1,2,4],"encod":1,"end":[0,1,3,4],"energi":1,"engin":1,"entrypoint":0,"enum":4,"environ":2,"equal":4,"equival":0,"error":[1,4],"error_cod":1,"especi":1,"europ":[0,1,3],"evalu":0,"even":[0,1,3,4],"everi":[0,3,4],"exampl":[0,1,2,4],"exceed":[1,4],"exchang":[3,4],"execut":1,"exist":4,"expect":1,"explan":4,"explicit":[0,1,3,4],"explicit_local":1,"export":[1,2,3,4],"external_id":0,"extra":2,"extra_inform":1,"fail":0,"failur":1,"fall":[0,3,4],"fallback":[0,1,4],"fals":[0,1,4],"field":[0,1,4],"fig":[0,1],"figur":[0,1],"final":[0,4],"firmwar":0,"first":[1,2,3,4],"fix":0,"follow":[0,1,2,3],"form":0,"format":1,"found":4,"frame":1,"frequenc":[1,3,4],"frontend":1,"full_titl":0,"function":[0,2,3],"fuseki":1,"g":[0,1,4],"gap":[1,4],"germani":0,"get":1,"get_":1,"get_default_aggregation_method":[1,3],"get_default_timezon":[0,1,3],"get_display_nam":[1,3],"get_local":[0,1],"get_measur":[1,3],"get_metric_info":[0,1,3],"get_nam":[1,3],"get_queried_interv":[0,1,3],"get_record":[1,3],"get_series_default_aggregation_method":[1,3],"get_series_default_timezon":[0,1,3],"get_series_display_nam":[1,3],"get_series_info":[0,1,3],"get_series_measur":[1,3],"get_series_nam":[0,1,3],"get_series_record":[1,3],"get_series_short_display_nam":[1,3],"get_series_unit":[0,1,3,4],"get_short_display_nam":[1,3],"get_singlets_default_aggregation_method":[1,3],"get_singlets_default_timezon":[0,1,3],"get_singlets_display_nam":[1,3],"get_singlets_info":[0,1,3],"get_singlets_measur":[1,3],"get_singlets_metric_info":[0,1,3],"get_singlets_nam":[1,3],"get_singlets_record":[1,3],"get_singlets_short_display_nam":[1,3],"get_singlets_unit":[0,1,3],"get_unit":[0,1,3],"getter":[0,1,3],"give":[0,1,3],"given":[0,4],"go":[0,1],"graph_object":[0,1],"group":[0,3,4],"groupbi":0,"guid":1,"handl":[1,4],"handler":1,"hd_metadata":[1,3],"hdhelper":[2,3,4],"heartbeat":[1,4],"helper":[0,4],"helperexcept":1,"hetida":[0,1,2,3,4],"hierarchi":[0,4],"higher":0,"hold":[0,1,3,4],"hour":0,"hyphen":1,"iana":[0,1],"id":[0,1,4],"identifi":[1,3,4],"ignor":[1,4],"immut":[0,1,3,4],"import":[0,1,2,3],"includ":[1,2,4],"include_inherit":[0,1,3,4],"incompat":1,"independ":[1,3],"index":[0,1,3],"individu":1,"infer":[1,3,4],"inform":4,"inherit":[0,1,3,4],"inheritedmetricmetadata":4,"input":[0,1,3],"instal":1,"instead":[0,1,2],"insuffici":1,"insufficientplottingdata":1,"int":1,"interval":[0,1,4],"intervaltyp":4,"invalid":[1,3,4],"irregular":[1,4],"isinst":1,"iso":[3,4],"isoformat":[1,3],"issu":[0,1],"item":0,"json":[0,1,4],"keep":[1,3,4],"key":[0,3,4],"keyerror":[1,4],"keyword":[0,4],"ki":[0,1],"kwarg":1,"label":[0,1,4],"last":[0,1,3],"latitud":[0,4],"leav":1,"left":1,"left_clos":4,"left_open":4,"let":0,"level":[0,1,3],"lifecycl":3,"light":1,"like":[0,1,4],"limit":0,"line":0,"list":[0,1,4],"liter":1,"load":1,"local":1,"locat":[0,1,4],"long":4,"longitud":0,"look":[0,3],"lookup":[0,1,4],"lot":0,"m":[0,1,2,3,4],"main":0,"make":1,"manag":2,"mani":0,"manufactur":0,"map":[1,3,4],"mar":1,"marker":0,"match":[1,3,4],"max":[1,3],"maximum":1,"may":[0,3,4],"md":[1,3,4],"mean":[1,3,4],"meaning":1,"meant":0,"measur":[0,1,4],"messag":[1,4],"meta":[0,1,4],"metadata":2,"metadataconventionerror":[0,1,4],"metadataconventionissu":[1,4],"metadataissuesever":4,"method":[0,1],"metr":[1,3],"metric":[0,1,4],"metric_id":[1,4],"metric_info":[0,1,4],"metric_key":[0,1,4],"metriccom":4,"metriccommentrepli":4,"metricmetadata":4,"midnight":0,"might":0,"misinterpret":4,"miss":[0,1,3,4],"mistak":4,"mode":[1,4],"model":[1,3],"model_copi":4,"model_dump":4,"model_valid":4,"modelnam":4,"modifi":1,"modified_timezon":1,"modify_timezon":[0,1],"modul":[3,4],"multi":4,"multidimension":4,"multipl":4,"multitsfram":[0,1,3,4],"multitsframe_metadata_exampl":[1,3],"multitsframedatasetmetadata":4,"multitsframemetadata":[0,1,4],"must":[1,4],"naiv":1,"name":[0,1,4],"narrowed_to":4,"nearest":4,"neither":[0,1],"nest":[1,3],"never":0,"nevertheless":0,"new":[0,1,4],"new_york":0,"newer":2,"newest":4,"newli":1,"next":[0,1,4],"non":4,"none":[0,1,3,4],"nonempti":1,"normal":[1,3,4],"north":[0,1,3],"note":0,"now":1,"null":[0,4],"number":4,"obj":[0,1],"object":[0,1,3,4],"object_to_convert":1,"obtain":4,"occur":4,"offset":[0,1,3,4],"omit":[0,1,3,4],"on_chang":[1,3,4],"onc":[0,4],"one":[0,1,3,4],"onli":[0,1,3,4],"open":4,"oper":[1,4],"option":[1,2,3,4],"order":[0,1,3,4],"origin":[1,4],"otherwis":[1,3,4],"overlap":4,"overrid":[1,3],"packag":1,"palett":1,"panda":[0,1,2,3,4],"paramet":1,"parent":[3,4],"parquet":1,"pars":[0,1,3],"part":0,"pass":[0,1],"path":[0,1,3,4],"pd":[1,3],"per":[0,1,3,4],"period":[1,3,4],"permit":4,"physic":[1,3],"pip":2,"place":1,"plain":1,"plant":[0,1,3,4],"plant_a":[1,3,4],"platform":[0,1,4],"plot_target_set":1,"plural":[0,4],"point":[1,4],"possibl":4,"preced":4,"precis":4,"present":4,"preserv":1,"pressur":[1,3,4],"print":[1,2],"problem":[1,4],"produc":1,"project":2,"properti":[0,3,4],"provid":[0,1,4],"provis":0,"purpos":4,"python":[2,3],"quantiti":[1,3,4],"queri":[1,4],"queried_interv":[0,4],"queried_metr":[0,4],"rais":[0,1,4],"rang":[0,4],"raw":[1,3],"reach":0,"read":[1,3],"read_metadata":[0,1,3,4],"recommend":1,"record":[0,1,4],"recording_aggregation_method":1,"recording_mod":[1,3],"recordingmod":4,"red":1,"ref":4,"ref_interval_end_timestamp":0,"ref_interval_start_timestamp":0,"ref_interval_typ":0,"refer":[0,1,3],"relat":[0,1,3],"relev":4,"remain":4,"remov":1,"renam":4,"replac":[1,4],"repli":4,"report":[0,1,4],"repres":0,"request":[0,1,3,4],"requir":[0,1,2,4],"rerun":3,"resampl":0,"resolv":[0,1,4],"resolve_color":[0,1],"resort":0,"respect":0,"result":[0,1,3,4],"retriev":[0,4],"return":[0,1,4],"reus":1,"revalid":1,"right_clos":4,"right_open":4,"root":1,"rule":4,"run":[2,3],"runtim":[0,2],"s":[0,1,2,3,4],"sampl":[0,1,2,3],"save":0,"say":0,"scalar":1,"scale":3,"scatter":[0,1],"schema":3,"search":[0,1,4],"see":[0,1,3,4],"select":[1,4],"self":4,"sensor":[0,1],"sent":4,"separ":[0,4],"sequenc":4,"seri":[0,1,3,4],"serial":[1,3],"series_metadata_exampl":[1,3],"seriesmetadata":[0,1,4],"session":[2,3],"set":[0,1,3,4],"set_agnostic_them":[0,1],"set_dt_tick":[0,1],"set_index":0,"set_metadata":[0,1,3,4],"setup":[0,1,4],"sever":[1,3,4],"shape":1,"share":[0,1],"sharedvaluedimensionmetadata":4,"shift":1,"short":[0,1,3,4],"short_display_nam":[0,1,4],"show":[0,3],"signal":[3,4],"signalgroupmetadata":4,"sinc":[0,4],"singl":[1,3,4],"single_metr":[0,3,4],"singlemetricdatasetmetadata":4,"singletsfram":[0,1,3,4],"singletsframe_metadata_exampl":[1,3],"singletsframemetadata":[0,1,4],"site":3,"sole":4,"sort":1,"sourc":[1,3,4],"specif":[3,4],"specifi":4,"sql":4,"standard":[0,1],"start":[0,1,3,4],"state":[1,3],"stay":1,"step":[1,2,3],"still":3,"storag":4,"store":[1,3,4],"str":[1,4],"streamlin":1,"string":[1,3,4],"style":1,"sub":0,"suitabl":1,"sum":0,"suppli":[1,3,4],"support":4,"switch":0,"system":[3,4],"t":1,"take":[0,4],"taken":1,"target":[0,1],"tbd":1,"temperatur":[1,3,4],"templat":3,"test":3,"theme":1,"thin":1,"three":[1,3,4],"tickformatstop":1,"time":[1,2,4],"timedelta":1,"timeseri":[0,4],"timestamp":[0,1,3,4],"timezon":[0,1],"to_datetim":[1,3],"to_fram":1,"to_parquet":[0,1],"to_timezon":[0,1],"togeth":[1,3,4],"toggleabl":1,"total_second":1,"tri":[1,3,4],"true":[0,1,3,4],"tupl":[1,4],"turn":4,"type":[0,1,4],"typeerror":1,"tz_convert":0,"unavail":[1,3],"unchang":[0,1,3,4],"underscor":1,"uniqu":[3,4],"unit":[0,1,4],"unknown":[1,3,4],"unrel":1,"unresolv":1,"unset":1,"unspecifi":1,"unsupport":1,"updat":[1,3],"update_layout":0,"update_xax":0,"use":[1,2,3,4],"user":0,"usual":0,"utc":[0,1,3,4],"utcoffset":1,"uv":2,"valid":[0,1,3],"valu":[0,1],"value_data_typ":[1,3],"value_dim_info":1,"value_dimens":[0,1,4],"value_dimension_info":4,"value_dimensions_shar":[0,3,4],"valuedatatyp":4,"valuedimensionmetadata":4,"valueerror":1,"variabl":1,"variant":3,"verifi":1,"version":[0,4],"via":0,"view":0,"viewer":0,"violat":4,"vision":0,"want":0,"warn":[1,4],"water":[0,1,3],"well":3,"wherea":3,"whole":[0,4],"whose":[1,3,4],"win":[0,3],"with_dataset_metadata":4,"with_metr":4,"with_value_dimens":[0,1,4],"within":3,"without":[0,1,2,3],"work":[0,2],"workflow":0,"wrapper":1,"write":1,"written":1,"wrong":1,"x":[0,1],"y":[0,1],"yaxis_titl":0,"yield":[0,1],"york":0,"z":3,"zero":1,"zone":[1,2,4],"zoom":1},"titles":["First steps","hdhelpers","Installation","Metadata examples","Metadata model reference"],"titleterms":{"Further":1,"How":0,"Where":0,"Which":0,"aggreg":3,"allow":4,"automat":3,"basic":3,"class":4,"come":0,"comput":0,"core":2,"data":3,"default":3,"dimens":3,"display":3,"doe":0,"exampl":3,"except":1,"fallback":3,"field":3,"first":0,"function":1,"get":0,"guid":4,"hdhelper":[0,1],"helper":[1,2],"inform":[0,1,3],"instal":2,"introduct":1,"issu":4,"lookup":3,"measur":3,"metadata":[0,1,3,4],"method":[3,4],"metric":3,"model":[0,4],"name":3,"nest":[0,4],"packag":2,"plot":[0,1,2],"queri":0,"read":4,"record":3,"refer":4,"return":3,"root":4,"select":3,"setup":3,"shape":3,"share":[3,4],"step":0,"tbd":0,"time":[0,3],"type":3,"unit":3,"updat":4,"use":0,"valid":4,"valu":[3,4],"verifi":2,"zone":[0,3]}}) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c9cef9e..3d898ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,14 +29,15 @@ maintainers = [ requires-python = ">=3.12" dependencies = [ + "pydantic", "pandas>=3; python_version < '3.15'", "pandas>=3.0.6; python_version >= '3.15'", - "plotly", "pydantic", - "glom>25,<26", "numpy; python_version < '3.15'", "numpy>=2.5.2; python_version >= '3.15'", "pytz", + # the time zone names the metadata conventions accept, independent of the system + "tzdata", ] license = "MIT" license-files = ["LICENSE"] @@ -58,6 +59,12 @@ classifiers = [ "License :: OSI Approved :: MIT License", ] +[project.optional-dependencies] +# the plotting helpers (hdhelpers.plotting and its names in hdhelpers) +plotting = [ + "plotly", +] + [project.urls] homepage = "https://fuseki.com/data-science/hetida-designer/" documentation = "https://hetida.github.io/hdhelpers/" @@ -76,19 +83,17 @@ explicit = true # allow longer lines than the default (88, same as black) line-length = 100 +lint.select = ["E", "F", "B", "W", "PL", "ERA", "G", "I", "UP", "YTT", "S", "BLE", "A", "COM", "C4", "DTZ", "T10", "ISC", "ICN", "INP", "PIE", "T20", "PT", "Q", "RET", "SIM", "ARG", "PD"] + +lint.ignore = ["E713", "E714", "B008", "PLR2004", "COM812", "RET504", "PLR0913", "PLR0917", "UP017", "ISC001"] +# the aliases ruff expects by default for the libraries used here, without "datetime" as "dt" +lint.flake8-import-conventions.aliases = { numpy = "np", "numpy.typing" = "npt", pandas = "pd", "plotly.express" = "px" } -[pycodestyle] +[tool.ruff.lint.pycodestyle] # Ensures E501 (line-too-long) uses the same limit max-line-length = 100 -# Assume python314 -target-version = "py314" - -lint.select = ["E", "F", "B", "W", "PL", "ERA", "G", "I", "UP", "YTT", "S", "BLE", "A", "COM", "C4", "DTZ", "T10", "ISC", "ICN", "INP", "PIE", "T20", "PT", "Q", "RET", "SIM", "ARG", "PD"] - -lint.ignore = ["E713", "E714", "B008", "PLR2004", "COM812", "RET504", "PLR0913", "UP017", "ISC001"] - [dependency-groups] dev = [ "certifi>=2026.10.19", @@ -106,10 +111,6 @@ dev = [ "/**/tests/**/*.py" = ["S101", "PT023", "T201", "INP001", "PT001", "ARG001", "S113", "S603", "PLR0912"] "tests/conftest.py" = ["F401"] -[[tool.mypy.overrides]] -module = ["glom"] -ignore_missing_imports = true - [[tool.mypy.overrides]] module = ["plotly", "plotly.*"] ignore_missing_imports = true diff --git a/run b/run index 5622cdd..e7f267f 100755 --- a/run +++ b/run @@ -35,12 +35,18 @@ lint(){ fi } -build_docs(){ - uv pip install . - uv run sphinx-build -M html sphinx/source sphinx/build - uv run sphinx-build -M doctest sphinx/source sphinx/build - uv run pytest --doctest-modules src/hdhelpers/metadata/_specs.py - cp -r sphinx/build/html/* docs/ + +build_docs() { + uv run --locked --all-extras sphinx-build \ + -M clean sphinx/source sphinx/build || return + + uv run --locked --all-extras sphinx-build \ + -M doctest sphinx/source sphinx/build -E -W || return + + uv run --locked --all-extras sphinx-build \ + -M html sphinx/source sphinx/build -E -a -W || return + + uv run --locked --all-extras python sphinx/publish_docs.py } diff --git a/sphinx/publish_docs.py b/sphinx/publish_docs.py new file mode 100644 index 0000000..790901f --- /dev/null +++ b/sphinx/publish_docs.py @@ -0,0 +1,48 @@ +"""Synchronize generated documentation while preserving repository and local files.""" + +from pathlib import Path + + +def publish_docs(source: Path, destination: Path) -> None: + """Copy a validated HTML build and remove obsolete Sphinx output files.""" + source = source.resolve(strict=True) + destination = destination.resolve() + if not (source / "index.html").is_file(): + raise ValueError("The documentation build has no index.html.") + if source.is_relative_to(destination) or destination.is_relative_to(source): + raise ValueError("The build and publication directories must not overlap.") + + files = { + path.relative_to(source): path + for path in source.rglob("*") + if path.is_file() + and not any(part.startswith(".") for part in path.relative_to(source).parts) + } + existing = [path for path in destination.glob("*.html") if path.is_file()] + existing += [destination / "objects.inv", destination / "searchindex.js"] + for name in ("_static", "_sources", "_modules", "_images", "_downloads"): + existing += [path for path in (destination / name).rglob("*") if path.is_file()] + + # Validate all write/delete targets before modifying the published documentation. + targets = [*existing, *(destination / relative for relative in files)] + if any(not path.resolve().is_relative_to(destination) for path in targets): + raise ValueError("A documentation target points outside the publication directory.") + + for path in existing: + if path.is_file() and path.relative_to(destination) not in files: + path.unlink() + for relative, path in files.items(): + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + data = path.read_bytes() + if path.suffix in {".html", ".txt", ".css", ".js"}: + data = data.replace(b"\r\n", b"\n") + if path.suffix == ".html": + data = b"\n".join(line.rstrip(b" \t") for line in data.split(b"\n")) + if not target.is_file() or target.read_bytes() != data: + target.write_bytes(data) + + +if __name__ == "__main__": + repository = Path(__file__).resolve().parent.parent + publish_docs(repository / "sphinx" / "build" / "html", repository / "docs") diff --git a/sphinx/source/_examples/metadata_setup.py b/sphinx/source/_examples/metadata_setup.py new file mode 100644 index 0000000..7a2ba86 --- /dev/null +++ b/sphinx/source/_examples/metadata_setup.py @@ -0,0 +1,32 @@ +from copy import deepcopy + +import pandas as pd + +from hdhelpers import metadata as md +from hdhelpers.metadata.conventions import ( + MULTITSFRAME_METADATA_EXAMPLE, + SERIES_METADATA_EXAMPLE, + SINGLETSFRAME_METADATA_EXAMPLE, +) + +timestamps = pd.to_datetime(["2026-01-01T00:00:00Z", "2026-01-01T00:15:00Z"]) + +# SERIES: one metric, with timestamps in the index; values are in metres. +series = pd.Series([1.0, 1.2], index=timestamps, name="level") +series.attrs = deepcopy(SERIES_METADATA_EXAMPLE) + +# SINGLETSFRAME: one metric with several value columns; "value" is in degrees Celsius. +dataframe = pd.DataFrame( + {"timestamp": timestamps, "value": [20.0, 21.0], "state": ["valid", "valid"]} +) +dataframe.attrs = deepcopy(SINGLETSFRAME_METADATA_EXAMPLE) + +# MULTITSFRAME: several metrics, identified by the "metric" column. +multitsframe = pd.DataFrame( + { + "timestamp": timestamps, + "metric": ["plant_a.pressure", "plant_a.temperature"], + "value": [1.0, 20.0], + } +) +multitsframe.attrs = deepcopy(MULTITSFRAME_METADATA_EXAMPLE) diff --git a/sphinx/source/_static/favicon.ico b/sphinx/source/_static/favicon.ico new file mode 100644 index 0000000..75a4b30 Binary files /dev/null and b/sphinx/source/_static/favicon.ico differ diff --git a/sphinx/source/_static/stylesheet.css b/sphinx/source/_static/stylesheet.css index 8de6a21..80131a6 100644 --- a/sphinx/source/_static/stylesheet.css +++ b/sphinx/source/_static/stylesheet.css @@ -1,15 +1,55 @@ - -/* if content is to big in x or y direction specifiy scroll for overflow-y or overflow-x */ +/* Sidebar scrollable, no horizontal scrollbar */ .sphinxsidebarwrapper { overflow-y: auto; - overflow-x: auto; + overflow-x: hidden; +} + +/* Truncate long function names */ +.sphinxsidebarwrapper li > a { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -div.sphinxsidebar { - width: 300px; +/* Keep long signatures and code inside the content column. */ +div.documentwrapper { + min-width: 0; } -div.bodywrapper { - margin: 0 0 0 300px; +div.body { + overflow-wrap: anywhere; +} + +div.body .sig .pre { + white-space: normal; +} + +/* On narrow screens, give the content the full width and move navigation below it. */ +@media (max-width: 900px) { + div.document { + flex-direction: column; + } + + div.bodywrapper { + margin: 0; + } + + div.sphinxsidebar { + float: none; + width: auto; + margin: 0; + } + + div.body table.docutils { + display: block; + max-width: 100%; + overflow-x: auto; + } + + div.body p, div.body dd, div.body li { + text-align: left; + } } diff --git a/sphinx/source/conf.py b/sphinx/source/conf.py index c576c49..34373b7 100644 --- a/sphinx/source/conf.py +++ b/sphinx/source/conf.py @@ -23,7 +23,14 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "classic" #'sphinxdoc' +html_theme_options = { + "sidebarwidth": 350, + "body_min_width": 0, + "body_max_width": 1600, +} + html_static_path = ["_static"] +html_favicon = "_static/favicon.ico" templates_path = ["_templates"] html_css_files = [ "stylesheet.css", @@ -31,33 +38,75 @@ extensions = [ - "sphinx.ext.autodoc", # docstrings to documentation + "sphinx.ext.autodoc", # docstrings to documentation "sphinx.ext.napoleon", # enables Sphinx to parse both NumPy and Google style docstrings - "sphinx.ext.doctest", # enabled embedding and testing Python code examples in documentation + "sphinx.ext.doctest", # enabled embedding and testing Python code examples in documentation + "sphinx.ext.viewcode", # add source code links ] autosummary_generate = False -autodoc_typehints = "description" +autodoc_typehints = "signature" +viewcode_line_numbers = True # line numbers in displayed source code toc_object_entries_show_parents = "hide" # hide class name in Table of Contents +napoleon_use_rtype = False # avoid return types as additonal line in docs # -- setting hdhelpers on path to be importable -- +import re import sys +from html import escape from pathlib import Path +from docutils import nodes +from sphinx.environment.adapters.toctree import TocTree + sys.path.insert(0, str(Path("..", "src", "hdhelpers").resolve())) doctest_global_setup = """ -try: - import pandas as pd - from hdhelpers import * - from hdhelpers.metadata import * - from hdhelpers.helpers import * - from hdhelpers.plotting import * - - series = pd.Series() - dataframe = pd.DataFrame() - multitsframe = pd.DataFrame() -except ImportError: - pd = None -""" +from hdhelpers import * +from hdhelpers.metadata import * +from hdhelpers.helpers import * +from hdhelpers.plotting import * +""" + (Path(__file__).resolve().parent / "_examples" / "metadata_setup.py").read_text( + encoding="utf-8" +) + + +def add_sidebar_titles(app, pagename, _templatename, context, doctree): + """Add native tooltips with the full text of each local sidebar link.""" + if doctree is None: + return + + toc = TocTree(app.env).get_toc_for(pagename, app.builder) + for reference in toc.findall(nodes.reference): + reference["reftitle"] = reference.astext() + context["toc"] = app.builder.render_partial(toc)["fragment"] + + +def fix_source_backlinks(app, pagename, _templatename, context, _doctree): + """Resolve source backlinks when a module is documented through several import paths.""" + if not pagename.startswith("_modules/"): + return + + module = pagename.removeprefix("_modules/").replace("/", ".") + objects = app.env.get_domain("py").objects + + def replace_link(match): + entry = objects.get(f"{module}.{match[2]}") + if entry is None: + return match[0] + target = app.builder.get_relative_uri(pagename, entry.docname) + "#" + entry.node_id + return match[1] + escape(target, quote=True) + match[3] + + # Sphinx viewcode otherwise uses the first documented import path for every backlink. + context["body"] = re.sub( + r'(
    \s*' + r')', + replace_link, + context.get("body", ""), + ) + + +def setup(app): + app.connect("html-page-context", add_sidebar_titles) + app.connect("html-page-context", fix_source_backlinks) diff --git a/sphinx/source/first_steps.rst b/sphinx/source/first_steps.rst index 24dab44..d69764a 100644 --- a/sphinx/source/first_steps.rst +++ b/sphinx/source/first_steps.rst @@ -2,6 +2,9 @@ First steps ####################### +See :ref:`metadata-example-data` for sample pandas inputs and examples of the +related getters. + How to get metadata with hdhelpers? =================================== @@ -17,16 +20,18 @@ In hetida designer this series can be represented as json for *direct provisioni "dataset_metadata": { "metric_key": "external_id", "single_metric": "water.north.level", + "queried_metrics": ["water.north.level"], "ref_interval_start_timestamp": "2020-01-01T08:10:00+00:00", "ref_interval_end_timestamp": "2020-01-01T08:20:00+00:00", "ref_interval_type": "closed" }, "metrics": [ { + "id": "4c1f0e2a-7b3d-4e5f-9a8b-1c2d3e4f5a6b", "external_id": "water.north.level", "name": "Water level north", "short_display_name": "Water Level", - "value_dimensions": [{"column": "value", "unit": "cm"}], + "value_dimensions": [{"column": "value", "unit": "cm", "measurement": "level"}], "inherited": {"latitude": 51.43, "longitude": 7.58} } ] @@ -55,8 +60,65 @@ We can retrieve the name, unit and latitude of the series with the following cod ... +or, equivalently, via the metadata as model: + +.. code-block:: python + + from hdhelpers.metadata import read_metadata + + def main(*, series): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + meta = read_metadata(series) # None if the series has no valid metadata + if meta is not None: + name = meta.name() # "Water level north" + unit = meta.unit() # "cm" + latitude = meta.metric_info("latitude") # 51.43 + + ... + .. _hetida designer metadata conventions: https://hetida.github.io/hetida-designer/user_guide/attached_metadata/ +Metadata as model +================= + +``hdhelpers.metadata.conventions`` holds the models of the metadata conventions, one per data +type: ``SeriesMetadata``, ``SingleTSFrameMetadata`` and ``MultiTSFrameMetadata``. They are the +contract of the conventions: Parsing fails for metadata which does not follow them. +``check_metadata_conventions`` reports the issues of such metadata at their locations. + +``read_metadata(obj)`` gives the metadata of a Series or DataFrame as model, or None if there is +no valid metadata. With ``required=True`` it raises a ``MetadataConventionError`` listing the +issues instead, for components which cannot work without metadata. The methods of the models +resolve the fallbacks described below, e.g. ``unit()``, ``display_name()``, ``metric_info()``, +``default_timezone()``, ``recording()`` and ``queried_interval()``. For a SERIES or +SINGLETSFRAME, the metric can be omitted. For a MULTITSFRAME, the methods take the metric, and +methods in plural like ``units()`` return the information of all metrics. + +The hetida designer runtime parses the metadata of the workflow inputs once and attaches the +model to them (``attach_metadata``). The models are immutable, so that all objects pandas +derives from an input share the model instead of copying the metadata, which saves a lot of +time for metadata of many metrics. To change metadata, create a changed version, which shares +all unchanged parts, and set it on your result: + +.. code-block:: python + + from hdhelpers.metadata import read_metadata, set_metadata + + def main(*, series): + # entrypoint function for this component + # ***** DO NOT EDIT LINES ABOVE ***** + + result = series / 100 + meta = read_metadata(series, required=True) + set_metadata(result, meta.with_value_dimension(None, "value", unit="m")) + + return {"result": result} + +Since the model is no JSON value, ``json.dumps(obj.attrs)`` or ``obj.to_parquet()`` fail for +objects with attached metadata. Use ``attrs_to_json(obj.attrs)`` to get attrs in their JSON form. + Where does the information come from? ===================================== @@ -91,43 +153,36 @@ In contrast, the functions for dedicated information do not use them by default, inherited property with the same label usually does not describe the metric itself, e.g. a property "unit" of a plant. Pass ``include_inherited=True`` to use them as last resort anyway. -Entries which do not adhere to the conventions are skipped: metrics which are no objects or -whose field named by the metric key is no string, and value dimensions which are no objects or -whose column is no string. ``"value_dimensions"`` and ``"value_dimensions_shared"`` which are no -lists count as empty, a metric key which is no string as missing, i.e. ``"id"`` is used. +Metadata which does not follow the conventions provides no information at all: The functions +return None or empty results for it. How to query nested information? ================================ The information requested from ``get_metric_info``, ``get_singlets_metric_info``, -``get_series_info`` and ``get_singlets_info`` is interpreted as a `glom`_ spec. A string is a -path in which dots separate the keys, so it also reaches into sub dictionaries. Let's say the -metric of the series above additionally provides comments and information on its sensor (the -conventions allow additional fields at every level), and the label of one of its inherited -properties contains a dot: +``get_series_info`` and ``get_singlets_info`` (and from the corresponding methods of the models) +is a field or a path of fields separated by dots, which reaches into sub objects. Let's say the +metric of the series above additionally provides information on its sensor (the conventions +allow additional fields at every level): .. code-block:: json { + "id": "4c1f0e2a-7b3d-4e5f-9a8b-1c2d3e4f5a6b", "external_id": "water.north.level", "name": "Water level north", "sensor": { "manufacturer": "Acme", "calibration": {"date": "2026-03-01", "offset": 0.02} }, - "comments": [ - {"content": "Gauge maintenance"}, - {"content": "Sensor replaced"} - ], - "value_dimensions": [{"column": "value", "unit": "cm"}], - "inherited": {"latitude": 51.43, "operator.name": "Ruhrverband"} + "value_dimensions": [{"column": "value", "unit": "cm", "measurement": "level"}], + "inherited": {"latitude": 51.43} } We can retrieve the nested information with the following code .. code-block:: python - from glom import Path from hdhelpers.metadata import get_series_info def main(*, series): @@ -139,30 +194,21 @@ We can retrieve the nested information with the following code latitude = get_series_info(series, "inherited.latitude") # 51.43, never the metric's own firmware = get_series_info(series, "sensor.firmware") # None, not provided - # any other glom spec, e.g. the content of each comment - comments = get_series_info(series, ("comments", ["content"])) - # ["Gauge maintenance", "Sensor replaced"] - - # a key which itself contains dots requires a glom Path - operator = get_series_info(series, Path("operator.name")) # "Ruhrverband" - ... For a MULTITSFRAME, ``get_metric_info(multitsframe, "sensor.manufacturer")`` provides the manufacturer keyed by metric accordingly. -The fallbacks described above apply to the whole path, e.g. ``"sensor.manufacturer"`` is looked -up in the metric first, then in its value dimension ``"value"`` and so on. A path which is not -present yields None instead of an error. Note that a plain string like ``"operator.name"`` is -always split at its dots, so it does not find the inherited property ``"operator.name"``. - -A spec may also transform the information, e.g. ``("name", str.upper)``. Since information -provided as null counts as missing, such a spec may receive null: on the metric itself and, if -the metric does not provide the information, on the levels it falls back to. Errors raised by -the spec are not caught. So either let it handle None, or rather transform the information -after retrieving it. +The fallbacks described above apply to the whole path. ``get_series_info`` and +``get_singlets_info`` search the value dimension first, then the metric (only for +``"value"``), then shared metadata. ``get_metric_info`` and ``get_singlets_metric_info`` +search the metric first, then its ``"value"`` dimension, then shared ``"value"`` metadata. +Each lookup can use inherited properties as a final fallback when enabled. -.. _glom: https://glom.readthedocs.io/ +A missing path yields None. An explicit path such as ``"inherited.latitude"`` accesses +that property directly, even with ``include_inherited=False``. Paths cannot address list +items or keys containing dots. Access those through the models, e.g. +``read_metadata(series, required=True).metric().comments``; see :doc:`metadata_models`. Which time zone to compute in? ============================== diff --git a/sphinx/source/index.rst b/sphinx/source/index.rst index cf00ef9..a5424d1 100644 --- a/sphinx/source/index.rst +++ b/sphinx/source/index.rst @@ -29,7 +29,10 @@ Further Information .. toctree:: :maxdepth: 2 + installation first_steps + metadata_examples + metadata_models Functions ========= @@ -37,6 +40,10 @@ Functions metadata ----------------- +The examples below use :ref:`metadata-example-data`. See :doc:`metadata_examples` +for the return shapes and shared examples of the related getters. +See :ref:`metadata-lookup-basics` for metric selection and missing-field behavior. + .. automodule:: hdhelpers.metadata :members: :show-inheritance: @@ -63,6 +70,9 @@ plotting exceptions ------------------- +For metadata validation errors, see :class:`hdhelpers.metadata.MetadataConventionError` +in the metadata API. Its ``issues`` attribute contains the individual validation issues. + .. automodule:: hdhelpers.exceptions :members: :show-inheritance: diff --git a/sphinx/source/installation.rst b/sphinx/source/installation.rst new file mode 100644 index 0000000..7f7c615 --- /dev/null +++ b/sphinx/source/installation.rst @@ -0,0 +1,41 @@ +Installation +============ + +hdhelpers requires Python 3.12 or newer and is included in the hetida designer runtime. +To use it in your own Python project, run the following commands in your project's +Python environment. + +Core package +------------ + +Install the metadata and time zone helpers: + +.. code-block:: console + + python -m pip install hdhelpers + +For a project managed with uv, use ``uv add hdhelpers`` instead. +The core package works without Plotly. + +Plotting helpers +---------------- + +Install the optional ``plotting`` extra to use ``hdhelpers.plotting`` and the +plotting functions exported by ``hdhelpers``: + +.. code-block:: console + + python -m pip install "hdhelpers[plotting]" + +With uv, use ``uv add "hdhelpers[plotting]"``. This also enables +``from hdhelpers import *``, which includes plotting exports. + +Verify the installation +----------------------- + +.. code-block:: console + + python -c "import hdhelpers; print(hdhelpers.__version__)" + +Continue with :doc:`first_steps` for component examples or :doc:`metadata_examples` +for sample pandas data to use in a Python session. diff --git a/sphinx/source/metadata_examples.rst b/sphinx/source/metadata_examples.rst new file mode 100644 index 0000000..1bf04d8 --- /dev/null +++ b/sphinx/source/metadata_examples.rst @@ -0,0 +1,216 @@ +Metadata examples +================= + +.. _metadata-example-data: + +Shared data setup +----------------- + +The following examples use pandas objects with metadata. In hetida designer, these +are supplied as component inputs. To try the examples in a Python session, create +them using the shared data setup below. + +The setup creates three independent pandas objects with complete metadata following +the current conventions. Use these objects for the API reference examples as well: + +* ``series``: a SERIES with one value per timestamp, measured in metres. +* ``dataframe``: a SINGLETSFRAME describing one metric with the value columns + ``value`` (temperature) and ``state`` (measurement state). +* ``multitsframe``: a MULTITSFRAME with pressure and temperature metrics, whose + identifiers are stored in the ``metric`` column. + +``md`` is the imported ``hdhelpers.metadata`` module. ``deepcopy`` keeps changes to +the example metadata independent of the supplied templates. The same setup is run +for each documentation test group. To start an example again with unchanged data, +rerun the setup. + +.. literalinclude:: _examples/metadata_setup.py + :language: python + +See :doc:`first_steps` for examples within components. + +.. _metadata-lookup-basics: + +Metadata lookup basics +---------------------- + +A metric is one measured signal. A value column (called a *value dimension* in the +schema) holds values or related information, such as measurement state. Metadata +is stored in ``attrs`` as convention fields or as a model under ``hd_metadata``. +Shared metadata in ``value_dimensions_shared`` supplies defaults for matching +columns across metrics. + +The metadata models are immutable and allow additional fields at every level. +Metadata timestamps are UTC ISO strings with an explicit ``Z`` or ``+00:00`` offset. +Adapters exchange convention fields; :func:`hdhelpers.metadata.attrs_to_json` converts +an attached model back to those fields for export. + +Series and SINGLETSFRAME getters select ``dataset_metadata.single_metric``, or the +only metric described if no identifier is set. They do not select by ``Series.name``. +Without a unique selection, metric-specific information is unavailable. +Absent fields and fields set to None count as missing. Inherited properties are supplied +by the source system for the metric or its parent assets. They are used as fallbacks when +``include_inherited=True``. Explicit paths such as ``"inherited.site"`` access them directly, +even when this fallback is disabled. +Missing or invalid metadata gives empty mappings for mapping getters; missing-key +lookups still use their documented defaults. + +.. _metadata-type-selection: + +Automatic metadata type selection +--------------------------------- + +When parsing raw attrs, ``read_metadata``, ``attach_metadata`` and +``check_metadata_conventions`` infer the type as follows: + +* A Series selects ``SERIES``. +* A DataFrame with a ``metric`` column selects ``MULTITSFRAME``. +* Other DataFrames select ``SINGLETSFRAME``. + +An explicit ``data_type`` overrides this selection. ``read_metadata`` also checks +the type of an attached model when requested; ``attach_metadata`` keeps an already +attached model unchanged. + +.. _metadata-getter-values: + +Units, measurements and return shapes +------------------------------------- + +SERIES getters return a single value. SINGLETSFRAME getters return information by +value column; MULTITSFRAME getters return it by metric and value column. The +measurement describes the physical quantity, whereas the unit describes its scale. + +.. doctest:: metadata.examples.values + + >>> md.get_series_unit(series), md.get_series_measurement(series) + ('m', 'level') + >>> dict(md.get_singlets_units(dataframe)) + {'value': '°C', 'state': None} + >>> md.get_singlets_measurements(dataframe)["value"] + 'temperature' + >>> md.get_units(multitsframe)["plant_a.pressure"]["value"] + 'bar' + >>> md.get_measurements(multitsframe)["plant_a.pressure"]["value"] + 'pressure' + >>> md.get_units(multitsframe)["unknown metric"]["value"] is None + True + >>> start, end = md.get_queried_interval(series) + >>> start.isoformat(), end.isoformat() + ('2026-01-01T00:00:00+00:00', '2026-01-02T00:00:00+00:00') + +.. _metadata-getter-names: + +Names and display-name fallbacks +-------------------------------- + +These getters use the same return shapes. A short display name falls back to the +display name, which falls back to the name. The templates omit display names, so +the examples show that fallback. See :doc:`first_steps` for the lookup order and +the optional ``include_inherited=True`` fallback to inherited properties. + +.. doctest:: metadata.examples.names + + >>> md.get_series_name(series) + 'Water level north' + >>> md.get_series_display_name(series), md.get_series_short_display_name(series) + ('Water level north', 'Water level north') + >>> md.get_singlets_names(dataframe)["state"] + 'measurement state' + >>> md.get_singlets_display_names(dataframe)["state"] + 'measurement state' + >>> md.get_singlets_short_display_names(dataframe)["state"] + 'measurement state' + >>> md.get_names(multitsframe)["plant_a.pressure"]["value"] + 'Plant A pressure' + >>> md.get_display_names(multitsframe)["plant_a.pressure"]["value"] + 'Plant A pressure' + >>> md.get_short_display_names(multitsframe)["plant_a.pressure"]["value"] + 'Plant A pressure' + +.. _metadata-getter-fields: + +Metric fields and value-dimension fields +---------------------------------------- + +``get_singlets_info`` returns information for each value column, whereas +``get_singlets_metric_info`` returns one value for the metric. The Series variant +looks up its single value dimension; ``get_metric_info`` returns values by metric. +The field argument may also be a dotted path into nested metadata. + +.. doctest:: metadata.examples.fields + + >>> md.get_series_info(series, "decimals") + 2 + >>> md.get_singlets_info(dataframe, "name")["state"] + 'measurement state' + >>> md.get_singlets_metric_info(dataframe, "name") + 'ABC temperature' + >>> md.get_metric_info(multitsframe, "name")["plant_a.pressure"] + 'Plant A pressure' + +.. _metadata-getter-recordings: + +Recording information +--------------------- + +Single-metric getters return a ``Recording`` object; ``get_recordings`` returns +these objects by metric. Access its fields to distinguish samples, periodic +recording and recording on change. Frequency, offset and time zone fall back to +the dataset metadata together, as described by :func:`hdhelpers.metadata.get_recordings`. + +.. doctest:: metadata.examples.recordings + + >>> recording = md.get_series_recording(series) + >>> recording.recording_mode, recording.data_frequency + ('periodic', '15min') + >>> md.get_singlets_recording(dataframe).data_frequency is None + True + >>> md.get_recordings(multitsframe)["plant_a.temperature"].recording_mode + 'on_change' + +.. _metadata-getter-timezones: + +Default time zones +------------------ + +A metric's time zone falls back to the dataset's default, otherwise to None. +Use an explicit fallback such as ``or "UTC"`` when choosing a time zone for a +computation. See :doc:`first_steps` for a daily aggregation example and the +distinction between computation and display time zones. + +.. doctest:: metadata.examples.timezones + + >>> md.get_series_default_timezone(series) + 'Europe/Berlin' + >>> md.get_singlets_default_timezone(dataframe) is None + True + >>> md.get_singlets_default_timezone(dataframe) or "UTC" + 'UTC' + >>> md.get_default_timezones(multitsframe)["plant_a.pressure"] + 'Europe/Berlin' + +.. _metadata-getter-aggregation: + +Default aggregation methods +--------------------------- + +The getters return a method name, or method names by metric. An explicit method +wins; ``AVG`` is normalized to ``MEAN``. Otherwise, ``value_data_type`` in metadata +selects ``LAST`` for boolean/string and ``MEAN`` for other types. + +.. doctest:: metadata.examples.aggregation + + >>> md.get_series_default_aggregation_method(series) + 'MEAN' + >>> md.get_singlets_default_aggregation_method(dataframe) + 'MEAN' + >>> md.get_default_aggregation_methods(multitsframe)["plant_a.temperature"] + 'MAX' + +For the metadata lifecycle, start with :func:`hdhelpers.metadata.check_metadata_conventions`, +then :func:`hdhelpers.metadata.read_metadata` or :func:`hdhelpers.metadata.attach_metadata`. +Use :func:`hdhelpers.metadata.set_metadata` to attach changed metadata to a result +and :func:`hdhelpers.metadata.attrs_to_json` before serialization. Each function +has an example using the shared data setup. + +See :doc:`metadata_models` for the model classes and their reading, update and conversion methods. diff --git a/sphinx/source/metadata_models.rst b/sphinx/source/metadata_models.rst new file mode 100644 index 0000000..8aece16 --- /dev/null +++ b/sphinx/source/metadata_models.rst @@ -0,0 +1,186 @@ +Metadata model reference +======================== + +.. currentmodule:: hdhelpers.metadata.conventions + +Read models with :func:`hdhelpers.metadata.read_metadata`. The returned type depends +on the metadata's data type; see :ref:`metadata-type-selection`. +All three root models are immutable, allow additional fields and provide the same +reading and update methods. + +.. list-table:: Root models + :header-rows: 1 + :widths: 30 35 35 + + * - Model + - Data + - Value dimensions + * - :class:`SeriesMetadata` + - One metric in a pandas Series + - One, named ``value`` + * - :class:`SingleTSFrameMetadata` + - One metric in a pandas DataFrame + - One or more value columns + * - :class:`MultiTSFrameMetadata` + - Metrics identified by a DataFrame's ``metric`` column + - Value columns for each metric + +The root fields are ``dataset_metadata`` (settings for the dataset), ``metrics`` +(metric descriptions) and optional ``value_dimensions_shared`` (column defaults). +See :ref:`metadata-lookup-basics` for storage, missing values and shared field rules. + +Reading and updating +-------------------- + +Using :ref:`metadata-example-data`, read a model and attach changed metadata to a +converted result. Updates return a new model; the original remains unchanged. + +.. doctest:: metadata.models + + >>> meta = md.read_metadata(series, required=True) + >>> meta.unit() + 'm' + >>> result = series * 100 + >>> changed = meta.with_value_dimension(None, "value", unit="cm") + >>> md.set_metadata(result, changed) + >>> md.get_series_unit(result), meta.unit() + ('cm', 'm') + >>> multi = md.read_metadata(multitsframe, required=True) + >>> multi.unit("plant_a.pressure") + 'bar' + >>> multi.narrowed_to("plant_a.pressure").unit() + 'bar' + +``metric_id`` is the value of the field selected by ``dataset_metadata.metric_key``. +It can be omitted for SeriesMetadata and SingleTSFrameMetadata; MultiTSFrameMetadata +requires it for methods reading one metric, even if only one metric is present. +``column`` selects a value dimension and defaults to ``"value"``. + +Methods in the plural return dictionaries by metric ID; value-dimension methods +return nested dictionaries as ``result[metric_id][column]``. Missing fields return +None; ``metric()`` raises KeyError for an unknown metric. See +:ref:`metadata-getter-timezones`, :ref:`metadata-getter-recordings` and +:ref:`metadata-getter-aggregation` for their specific defaults. + +``with_metric``, ``with_value_dimension`` and ``with_dataset_metadata`` accept field +changes as keyword arguments and validate the changed metadata. Use these methods +for updates; ``model_copy(update=...)`` does not validate replacement values. +Use :func:`hdhelpers.metadata.attrs_to_json` when exporting pandas attrs, or +``model_dump(mode="json")`` to obtain the model's convention fields directly. + +Root classes and methods +------------------------ + +The shared methods are documented once under SeriesMetadata below. They are also +available on SingleTSFrameMetadata and MultiTSFrameMetadata. Conversion methods +change only metadata; convert the pandas data separately to match it. + +.. autoclass:: SeriesMetadata(**data) + :members: metric_ids, metric, value_dimension_info, value_dimension_infos, unit, units, measurement, measurements, name, names, display_name, display_names, short_display_name, short_display_names, metric_info, metric_infos, default_timezone, default_timezones, recording, recordings, default_aggregation_method, default_aggregation_methods, queried_interval, with_metric, with_value_dimension, with_dataset_metadata, as_singletsframe + :undoc-members: + :member-order: bysource + +.. autoclass:: SingleTSFrameMetadata(**data) + :members: as_series, as_multitsframe + :member-order: bysource + +.. autoclass:: MultiTSFrameMetadata(**data) + :members: narrowed_to + +Reading method guide +-------------------- + +.. list-table:: Shared methods + :header-rows: 1 + :widths: 40 60 + + * - Methods + - Purpose + * - :meth:`SeriesMetadata.metric_ids`, :meth:`~SeriesMetadata.metric` + - List metric identifiers or retrieve a metric's metadata. + * - :meth:`SeriesMetadata.value_dimension_info`, :meth:`~SeriesMetadata.value_dimension_infos` + - Read a field or dotted path for one value dimension, or for all metrics and columns. + An ordered sequence of fields specifies fallbacks, e.g. ``("display_name", "name")``. + * - :meth:`SeriesMetadata.unit`, :meth:`~SeriesMetadata.units`, + :meth:`~SeriesMetadata.measurement`, :meth:`~SeriesMetadata.measurements` + - Read units or measured quantities, e.g. ``"°C"`` or ``"temperature"``. + * - :meth:`SeriesMetadata.name`, :meth:`~SeriesMetadata.names`, + :meth:`~SeriesMetadata.display_name`, :meth:`~SeriesMetadata.display_names`, + :meth:`~SeriesMetadata.short_display_name`, :meth:`~SeriesMetadata.short_display_names` + - Read names or labels with the :ref:`documented fallbacks `. + * - :meth:`SeriesMetadata.metric_info`, :meth:`~SeriesMetadata.metric_infos` + - Read a field or dotted path, searching the metric before its ``value`` dimension. + Inherited properties are a final fallback when enabled. + * - :meth:`SeriesMetadata.default_timezone`, :meth:`~SeriesMetadata.default_timezones` + - Read the time zone for calculations, falling back to the dataset default. + * - :meth:`SeriesMetadata.recording`, :meth:`~SeriesMetadata.recordings` + - Read :class:`~hdhelpers.metadata.Recording` objects describing how values are recorded. + * - :meth:`SeriesMetadata.default_aggregation_method`, :meth:`~SeriesMetadata.default_aggregation_methods` + - Read explicit or inferred aggregation methods, normalizing ``AVG`` to ``MEAN``. + * - :meth:`SeriesMetadata.queried_interval` + - Read the requested interval as UTC datetimes. + +Nested metadata +--------------- + +These classes describe the root models' fields. Construct them from convention +fields with ``ModelName.model_validate(mapping)``, or access them through a root +model, e.g. ``meta.metric().value_dimensions``. Required fields may accept None +where the conventions permit unknown values. + +.. autoclass:: MetricMetadata(**data) + + Required fields include ``id``, ``name`` and ``value_dimensions``. Other fields + describe labels, recording, default time zone, aggregation, comments and inherited properties. + +.. autoclass:: ValueDimensionMetadata(**data) + + Required fields are ``column``, ``unit`` and ``measurement``. Unit and measurement + may be None. Optional fields include names, data type, deadband and display precision. + +.. autoclass:: SharedValueDimensionMetadata(**data) + + ``column`` identifies the value dimension. Its other fields supply shared defaults. + +.. autoclass:: SingleMetricDatasetMetadata(**data) + + ``single_metric`` identifies the sole metric using ``metric_key``. + +.. autoclass:: MultiTSFrameDatasetMetadata(**data) + + Both dataset models include ``metric_key``, ``queried_metrics``, the requested interval, + default settings and adapter invalidation settings. Here ``single_metric`` is optional + and applies only to a dataset containing one metric. + +.. autoclass:: InheritedMetricMetadata(**data) + +.. autoclass:: MetricComment(**data) + +.. autoclass:: MetricCommentReply(**data) + +.. autoclass:: SignalGroupMetadata(**data) + +Allowed values and validation issues +---------------------------------------- + +.. autoclass:: IntervalType + :members: + +.. autoclass:: AggregationMethod + :members: + +.. autoclass:: ValueDataType + :members: + +.. autoclass:: RecordingMode + :members: + +.. autoclass:: MetadataIssueSeverity + :members: + +.. autoclass:: MetadataConventionIssue(**data) + +Obtain validation issues with :func:`hdhelpers.metadata.check_metadata_conventions`. +When required metadata cannot be read, :func:`hdhelpers.metadata.read_metadata` +raises :class:`~hdhelpers.metadata.MetadataConventionError`, whose ``issues`` attribute +contains these issue objects. diff --git a/src/hdhelpers/__init__.py b/src/hdhelpers/__init__.py index a6fa5e3..736baca 100644 --- a/src/hdhelpers/__init__.py +++ b/src/hdhelpers/__init__.py @@ -1,17 +1,48 @@ """helper functions for writing components in hetida designer""" -from hdhelpers import exceptions, helpers, metadata, plotting +import importlib +from typing import TYPE_CHECKING, Any + +from hdhelpers import exceptions, helpers, metadata from hdhelpers.exceptions import HelperException, InsufficientPlottingData from hdhelpers.helpers.locale import get_locale from hdhelpers.helpers.timezone_handling import modify_timezone from hdhelpers.plot_target_settings import StatusColors -from hdhelpers.plotting.color import FUSEKI_COLOR_CYCLE, resolve_color -from hdhelpers.plotting.plotly_theme import set_agnostic_theme -from hdhelpers.plotting.timestamp_ticks import TICKFORMATSTOPS, set_dt_ticks + +if TYPE_CHECKING: + from hdhelpers import plotting + from hdhelpers.plotting.color import FUSEKI_COLOR_CYCLE, resolve_color + from hdhelpers.plotting.plotly_theme import set_agnostic_theme + from hdhelpers.plotting.timestamp_ticks import TICKFORMATSTOPS, set_dt_ticks # do not edit line of __version__ as it is automatically modified by running ./run build_package __version__ = "0.0.10" +# The plotting helpers need plotly, which comes with the optional extra hdhelpers[plotting]. They +# are imported on first access, so that everything else works without plotly. +_PLOTTING_NAMES = frozenset( + {"FUSEKI_COLOR_CYCLE", "resolve_color", "set_agnostic_theme", "TICKFORMATSTOPS", "set_dt_ticks"} +) + + +def __getattr__(name: str) -> Any: + if name != "plotting" and name not in _PLOTTING_NAMES: + raise AttributeError(f"module 'hdhelpers' has no attribute {name!r}") + # always via the package, which needs plotly: A failed import of the package keeps the + # submodules imported until then, e.g. hdhelpers.plotting.color, which does not need plotly. + try: + plotting = importlib.import_module("hdhelpers.plotting") + except ImportError as e: + if (e.name or "").partition(".")[0] != "plotly": + raise + raise ImportError( + f"hdhelpers.{name} needs plotly, which is installed with the extra hdhelpers[plotting]" + ) from e + value = plotting if name == "plotting" else getattr(plotting, name) + globals()[name] = value + return value + + # import can be done by using `from hdhelpers import *`` __all__ = [ "HelperException", diff --git a/src/hdhelpers/exceptions.py b/src/hdhelpers/exceptions.py index 756e540..e1898dc 100644 --- a/src/hdhelpers/exceptions.py +++ b/src/hdhelpers/exceptions.py @@ -2,8 +2,12 @@ class HelperException(Exception): - """Exception to re-raise exceptions with error code raised in the code of the hdhelpers - package.""" + """Exception for reporting errors to hetida designer. + + Attributes: + error_code (int | str): Error identifier; an empty string if unspecified. + extra_information (dict | None): Optional details for the error handler. + """ __is_hetida_designer_exception__ = True @@ -22,10 +26,7 @@ def __init__( class InsufficientPlottingData(HelperException): - """A plot component has insufficient data to generate a meaningful plot + """Raised when a plot component has insufficient data to produce a meaningful plot. - This exception class should be used when custom plots generated by hetida - designer are integrated in other frontends. This allows the frontend to - handle the case of e.g. no data to show in a sensible way, surpressing an - empty plot and showing an adequate message instead. + Frontends can handle this by displaying a message instead of an empty plot. """ diff --git a/src/hdhelpers/helpers/locale.py b/src/hdhelpers/helpers/locale.py index 8422b3b..91a5138 100644 --- a/src/hdhelpers/helpers/locale.py +++ b/src/hdhelpers/helpers/locale.py @@ -2,17 +2,14 @@ def get_locale(explicit_locale: str | None = None) -> str | None: - """Extract (Plotly) locale - - Infers Plotly locale from plot target settings or returns explicit provided - locale. + """Read the explicit locale or plot target locale, normalizing underscores to hyphens. Args: - explicit_locale (str | None): Defaults to None. Locale string like "de", "en-US" or similar. Will be returned instead of what is inferred from plot_target_settings if provided + explicit_locale: + Override locale, e.g. "de_DE". None or "" uses the plot target setting. Returns: - str | None: - Extracted locale (like "de_DE") + str | None: Locale such as "de-DE", or None if no locale is configured. Code example: @@ -23,8 +20,7 @@ def get_locale(explicit_locale: str | None = None) -> str | None: >>> print(get_locale()) # returns locale from plot target settings of execution or None None - - """ + """ # noqa: E501 if explicit_locale: return explicit_locale.replace("_", "-") diff --git a/src/hdhelpers/helpers/timezone_handling.py b/src/hdhelpers/helpers/timezone_handling.py index 35c4097..9557afc 100644 --- a/src/hdhelpers/helpers/timezone_handling.py +++ b/src/hdhelpers/helpers/timezone_handling.py @@ -12,7 +12,7 @@ @singledispatch -def _convert_to_optional_timezone(object_to_convert, to_timezone: str | None): +def _convert_to_optional_timezone(object_to_convert, to_timezone: str | None): # noqa: ARG001 """Convert object_to_convert to to_timezone if not None, or to its own timezone if aware or to UTC otherwise""" @@ -50,24 +50,30 @@ def modify_timezone[T: (pd.Timestamp, datetime.datetime, pd.Series, pd.DataFrame column_names: list[str] | None = None, convert_index: bool = True, ) -> T: - """Converts time information of pandas objects to a certain timezone - - This function is applicable to index and/or columns of pd.Series or pd.DataFrame as well as for single pd.Timestamp objects. - If time zone information is not defined in object to convert it is assumed that it is in UTC. - Please note that column_names and convert_index are not exclusive to enable modifying both at the same time. - To enable modifying the value of a series (not the index), please name the series and define the series' name in column_names. + """Convert aware timestamps or localize naive timestamps to the selected time zone. Args: - object_to_convert (pd.Timestamp | pd.Series | pd.DataFrame): Timestamp, Series or DataFrame where timezone is modified - to_timezone (str | None): Timezone to convert to, e.g. for German time use "Europe/Berlin". See possible timezone strings in pandas' `tz_convert` method or pytz all_timezones list. If to_timezone is not defined, the global timezone from plot_target_settings is used. . - column_names (str | None): List of column_names to modify. For pd.Series the default behaviour is modifying the index and for pd.DataFrame the default behaviour is modifying the column "timestamp". This option is not applicable in case object_to_convert is a pd.Timestamp. - convert_index (bool | None): Boolean that controls whether the index of pd.Dataframe or pd.Series should be transformed. Note that for a pd.Series setting this option to true results in the same output as using `column_names=None`. This option is not applicable in case `object_to_convert` is a pd.Timestamp. + object_to_convert (pd.Timestamp | datetime.datetime | pd.Series | pd.DataFrame): + Timestamp, datetime, Series or DataFrame to convert. + to_timezone: + Target zone, e.g. "Europe/Berlin". None uses the plot target setting; if unset, + naive values use UTC and aware values keep their zone. + column_names: + Columns to convert. None/[] selects the Series index or DataFrame "timestamp" + column. For Series values, pass [series.name]. Ignored for scalars. + convert_index: + Also convert the pandas index. With column_names=None/[], a Series index is + always converted. Ignored for scalars. + Returns: pd.Timestamp | pd.Series | pd.DataFrame: - Returns the modified timezone object. + Timestamp for scalar inputs; converted copy for nonempty pandas inputs. Empty + pandas inputs are returned unchanged. Raises: - TypeError: If `object_to_convert` is not a pd.Series, pd.Timestamp, pd.DataFrame. + TypeError: Unsupported input or incompatible timestamp/index values. + ValueError: Unknown time zone or invalid datetime values. + KeyError: A requested column is absent. Code example: @@ -77,13 +83,14 @@ def modify_timezone[T: (pd.Timestamp, datetime.datetime, pd.Series, pd.DataFrame >>> modified_timezone = modify_timezone(pd.to_datetime("2025-01-01T01:00:00+05:00"), to_timezone="Europe/Berlin") >>> int(modified_timezone.utcoffset().total_seconds()) 3600 - """ + """ # noqa: E501 if not isinstance( object_to_convert, pd.Timestamp | pd.Series | pd.DataFrame | datetime.datetime ): raise TypeError( - f"object_to_convert is {type(object_to_convert)} not pd.Timestamp | pd.Series | pd.DataFrame | datetime.datetime" + f"object_to_convert is {type(object_to_convert)} not pd.Timestamp | pd.Series |" + " pd.DataFrame | datetime.datetime" ) if column_names is None: column_names = [] diff --git a/src/hdhelpers/metadata/__init__.py b/src/hdhelpers/metadata/__init__.py index e068e2e..2db1136 100644 --- a/src/hdhelpers/metadata/__init__.py +++ b/src/hdhelpers/metadata/__init__.py @@ -1,9 +1,22 @@ -"""Collection of functions to access metadata information from timeseries objects. -Metadata information can follow varying conventions as the package glom is used to -extract the requested information.""" +"""Access to the metadata of SERIES, SINGLETSFRAMEs and MULTITSFRAMEs +The metadata follows the hetida designer metadata conventions, see +hdhelpers.metadata.conventions for its models. read_metadata gives the metadata of an object as +model, whose methods resolve the fallbacks of the conventions. The get_* functions are thin +wrappers over these methods. +""" + +from hdhelpers.metadata.attached import ( + META_KEY, + attach_metadata, + attrs_to_json, + read_metadata, + set_metadata, +) +from hdhelpers.metadata.conventions import MetadataConventionError, check_metadata_conventions from hdhelpers.metadata.helpers import ( Recording, + copy_without_metadata, get_default_aggregation_methods, get_default_timezones, get_display_names, @@ -33,10 +46,16 @@ get_singlets_short_display_names, get_singlets_units, get_units, - without_metadata, ) __all__ = [ + "META_KEY", + "MetadataConventionError", + "attach_metadata", + "attrs_to_json", + "check_metadata_conventions", + "read_metadata", + "set_metadata", "get_queried_interval", "get_series_display_name", "get_series_measurement", @@ -67,5 +86,5 @@ "get_default_aggregation_methods", "get_series_default_aggregation_method", "get_singlets_default_aggregation_method", - "without_metadata", + "copy_without_metadata", ] diff --git a/src/hdhelpers/metadata/_current_convention.py b/src/hdhelpers/metadata/_current_convention.py deleted file mode 100644 index 3f589b5..0000000 --- a/src/hdhelpers/metadata/_current_convention.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Extraction of metadata following the current metadata convention - -The metadata is gathered in a single pass in plain Python: the object of each metric with its -value dimensions by column, and the value dimensions shared by all metrics -("value_dimensions_shared"). The fallback chains are resolved on these objects, applying the -spec of the requested information only to the objects of the levels actually needed. Field -names and dotted paths of them are looked up without glom, since evaluating glom specs costs a -lot of time per object, which adds up for frames with many metrics. - -Entries not adhering to the conventions are skipped: metrics which are no objects or whose -field named by the metric key is no string, and value dimensions which are no objects or whose -column is no string. "value_dimensions" and "value_dimensions_shared" which are no lists count -as empty, a metric key which is no string as missing, i.e. the default "id" is used. - -Older metadata structures are handled by the glom specs in _specs. -""" - -from collections import defaultdict -from collections.abc import Callable, Sequence -from dataclasses import dataclass, replace -from typing import Any - -from glom import Coalesce, Spec, glom - -from hdhelpers.metadata._specs import INHERITED - -Getter = Callable[[Any], Any] - -_MISSING = object() -_DEFAULT_COALESCE_SKIP = Coalesce("any").skip - - -@dataclass(frozen=True) -class GatheredMetric: - metric: dict - value_dimensions: dict[str, dict] # by column - - -@dataclass(frozen=True) -class GatheredMetadata: - metrics: dict[str, GatheredMetric] # by value of the field named by the metric key - value_dimensions_shared: dict[str, dict] # by column - - -def follows_current_convention(attrs: Any) -> bool: - """Whether metadata follows the current metadata convention, i.e. has a list of metrics""" - return isinstance(attrs, dict) and isinstance(attrs.get("metrics"), list) - - -def _metric_key(attrs: dict) -> str: - dataset_metadata = attrs.get("dataset_metadata") - metric_key = dataset_metadata.get("metric_key") if isinstance(dataset_metadata, dict) else None - return metric_key if isinstance(metric_key, str) else "id" - - -def _by_column(value_dimensions: Any) -> dict[str, dict]: - """Value dimension objects by column, the last one winning, skipping malformed ones""" - if not isinstance(value_dimensions, list): - return {} - return { - value_dimension["column"]: value_dimension - for value_dimension in value_dimensions - if isinstance(value_dimension, dict) and isinstance(value_dimension.get("column"), str) - } - - -def gather(attrs: Any) -> GatheredMetadata | None: - """Gather the objects of all levels, None if not following the current convention""" - if not follows_current_convention(attrs): - return None - - metric_key = _metric_key(attrs) - return GatheredMetadata( - metrics={ - metric[metric_key]: GatheredMetric(metric, _by_column(metric.get("value_dimensions"))) - for metric in attrs["metrics"] - if isinstance(metric, dict) and isinstance(metric.get(metric_key), str) - }, - value_dimensions_shared=_by_column(attrs.get("value_dimensions_shared")), - ) - - -def restricted_to(gathered: GatheredMetadata, metric: str) -> GatheredMetadata: - """The gathered metadata of one of the metrics only, so that only its information is resolved""" - return replace(gathered, metrics={metric: gathered.metrics[metric]}) - - -def _simple_path(spec: Any) -> tuple[str, ...] | None: - """The keys a spec looks up one after another, if it is just a path of dict keys - - glom splits string paths at dots, and only interprets stars besides. - """ - if isinstance(spec, str) and "*" not in spec and "\\" not in spec: - return tuple(spec.split(".")) - if ( - isinstance(spec, Coalesce) - and len(spec.subspecs) == 1 - and spec.default is None - and spec.skip is _DEFAULT_COALESCE_SKIP - ): - return _simple_path(spec.subspecs[0]) - return None - - -def getter(spec: str | Spec) -> Getter: - """Apply spec as Coalesce(spec, default=None), without glom for paths of dict keys""" - generic_spec = Coalesce(spec, default=None) - - def generic(obj: Any) -> Any: - return glom(obj, generic_spec) - - path = _simple_path(spec) - if path is None: - return generic - - def by_path(obj: Any) -> Any: - # glom accesses keys of dicts via item access only, so a missing key yields None. - # Other targets, e.g. lists accessed via indices, are left to glom. - target = obj - for key in path: - if type(target) is not dict: - return generic(obj) - if key not in target: - return None - target = target[key] - return target - - return by_path - - -def _inherited_info(metric: dict, get: Getter) -> Any: - # the spec is not applied if the metric does not provide inherited properties at all - return get(metric[INHERITED]) if INHERITED in metric else None - - -def value_dimension_info( - gathered: GatheredMetadata, specs: Sequence[str | Spec], include_inherited: bool -) -> defaultdict[str, defaultdict[str, Any]]: - """Information per value dimension per metric, falling back to further specs - - The value dimensions of a metric are its own ones, "value" and the shared ones. For each - of them, the first information which is not None is used: Each spec is applied on - - 1. the value dimension itself, - 2. only for the value dimension "value": the metric, - 3. "value_dimensions_shared" - - before the next spec is tried. If include_inherited, the properties the metric inherits - are used as last resort for the value dimension "value", again trying each spec in turn. - """ - getters = [getter(spec) for spec in specs] - shared_info_cache: list[dict[str, Any]] = [{} for _ in getters] - - def shared_info(index: int, column: str) -> Any: - cache = shared_info_cache[index] - if column not in cache: - shared = gathered.value_dimensions_shared.get(column) - cache[column] = None if shared is None else getters[index](shared) - return cache[column] - - result: defaultdict[str, defaultdict[str, Any]] = defaultdict(lambda: defaultdict(lambda: None)) - for metric, gathered_metric in gathered.metrics.items(): - info_by_column: defaultdict[str, Any] = defaultdict(lambda: None) - columns = dict.fromkeys( - [*gathered.value_dimensions_shared, "value", *gathered_metric.value_dimensions] - ) - for column in columns: - value_dimension = gathered_metric.value_dimensions.get(column) - info = None - for index, get in enumerate(getters): - info = get(value_dimension) if value_dimension is not None else None - if info is None and column == "value": - info = get(gathered_metric.metric) - if info is None: - info = shared_info(index, column) - if info is not None: - break - if info is None and include_inherited and column == "value": - for get in getters: - info = _inherited_info(gathered_metric.metric, get) - if info is not None: - break - info_by_column[column] = info - result[metric] = info_by_column - return result - - -def metric_info( - gathered: GatheredMetadata, spec: str | Spec, include_inherited: bool -) -> dict[str, Any]: - """Information per metric, the first one which is not None of - - 1. the metric itself, - 2. its value dimension "value", - 3. the value dimension "value" in "value_dimensions_shared", - 4. if include_inherited, the properties the metric inherits. - """ - get = getter(spec) - shared = gathered.value_dimensions_shared.get("value") - shared_info: Any = _MISSING - - result: dict[str, Any] = {} - for metric, gathered_metric in gathered.metrics.items(): - info = get(gathered_metric.metric) - if info is None: - value_dimension = gathered_metric.value_dimensions.get("value") - info = get(value_dimension) if value_dimension is not None else None - if info is None and shared is not None: - if shared_info is _MISSING: - shared_info = get(shared) - info = shared_info - if info is None and include_inherited: - info = _inherited_info(gathered_metric.metric, get) - result[metric] = info - return result - - -def own_metric_info(gathered: GatheredMetadata, key: str) -> dict[str, Any]: - """A field of each metric itself, without falling back to other levels""" - return { - metric: gathered_metric.metric.get(key) - for metric, gathered_metric in gathered.metrics.items() - } diff --git a/src/hdhelpers/metadata/_helpers.py b/src/hdhelpers/metadata/_helpers.py deleted file mode 100644 index 7af114d..0000000 --- a/src/hdhelpers/metadata/_helpers.py +++ /dev/null @@ -1,343 +0,0 @@ -from collections import defaultdict -from collections.abc import Callable, Collection -from typing import Any, cast - -import pandas as pd -from glom import Check, Coalesce, CoalesceError, Spec, glom - -import hdhelpers.metadata._current_convention as _current_convention -import hdhelpers.metadata._specs as _specs - - -def check_dataframe(data: pd.DataFrame): - if not isinstance(data, pd.DataFrame): - raise TypeError("Input is not a pd.DataFrame") - - -def check_series(series: pd.Series): - if not isinstance(series, pd.Series): - raise TypeError("Input is not a pd.Series.") - - -def check_series_or_dataframe(data: pd.Series | pd.DataFrame): - if not isinstance(data, pd.Series) and not isinstance(data, pd.DataFrame): - raise TypeError("Input is not a pd.Series or pd.DataFrame.") - - -def spec_not_none(spec: str | Spec) -> Spec: - """this entries must be given in the spec""" - # TODO: Given the suite of tools introduced with Match, the Check specifier type may be deprecated in a future release - return (spec, Check(validate=lambda x: x is not None)) - - -def extract_from_metadata(metadata: Any, key: str, default: str | None = None) -> Any: - return glom(metadata, Coalesce(f"dataset_metadata.{key}", default=default)) - - -def _legacy_value_dimension_info( - attrs: Any, value_dim_info: str | Spec -) -> defaultdict[str, defaultdict[str, Any]]: - """get_value_dimension_info for older metadata structures""" - return defaultdict( - lambda: defaultdict(lambda: None), - glom(attrs, _specs.by_metric_key_by_val_dimension(value_dim_info)), - ) - - -def get_value_dimension_info( - multitsframe: pd.DataFrame | pd.Series, - value_dim_info: str | Spec, - include_inherited: bool = False, -) -> defaultdict[str, defaultdict[str, Any]]: - """Obtain metadata info associated to the value dimensions of the metrics - - Returns a default dict whose values are the entries of the metrics metadata specified via - "metric_key" in "dataset_metadata". - - Its values are defaultdicts whose keys are the "column" entries of the value dimension - objects of that metric and whose values are extracted from the value_dimension object - using using value_dim_info as a glom Spec, typically just a subfield. - - For the default "value" value dimension, if no concrete / explicit information is available - for this value dimension, a corresponding entry in the metric object may be used. - - For all value dimensions, if no concrete explicit information is available for that value - dimension in the value_dimensions list under the metric, the global "value_dimensions_shared" - field of the attrs object is searched for corresponding information. - - If include_inherited, the properties a metric inherits are used as last resort for the - "value" value dimension, i.e. after "value_dimensions_shared". - - If no information is found, None is set as value and is the default value of the - inner default dict. - - For metadata following the current metadata convention, value_dim_info is only applied to - the levels needed and entries not adhering to the conventions are skipped, see - _current_convention. - - For examples we refer to the corresponding unit tests (/tests/helpers/test_metadata.py). - """ - gathered = _current_convention.gather(multitsframe.attrs) - if gathered is not None: - return _current_convention.value_dimension_info( - gathered, [value_dim_info], include_inherited - ) - return _legacy_value_dimension_info(multitsframe.attrs, value_dim_info) - - -def value_dimension_info_with_fallbacks( - data: pd.DataFrame | pd.Series, - fields: tuple[str | Spec, ...], - legacy_spec: Spec, - include_inherited: bool = False, -) -> defaultdict[str, defaultdict[str, Any]]: - """Obtain a field of the value dimensions, falling back to further fields - - For metadata following the current metadata convention, each field is looked up on all - levels described in get_value_dimension_info (the value dimension, the metric for the - value dimension "value" and "value_dimensions_shared") before the next field is tried. - So e.g. a display name of the metric wins over the name of its value dimension. Fields - provided as null are treated as missing. Inherited properties are only used if none of the - fields is provided by the metadata itself. - - Older metadata structures are handled via legacy_spec, keeping their established behaviour. - """ - gathered = _current_convention.gather(data.attrs) - if gathered is None: - return _legacy_value_dimension_info(data.attrs, legacy_spec) - return _current_convention.value_dimension_info(gathered, fields, include_inherited) - - -_SINGLE_METRIC_KEY = "single_metric" - - -def _single_metric_name(attrs: Any) -> str | None: - """The metric named by _SINGLE_METRIC_KEY in "dataset_metadata", if it is a string""" - metric = extract_from_metadata(attrs, key=_SINGLE_METRIC_KEY) - return metric if isinstance(metric, str) else None - - -def _single_metric(attrs: Any, metrics: Collection[str]) -> str | None: - """The single metric of a SingleTSFrame or Series among the metrics present - - The single metric is identified via _SINGLE_METRIC_KEY in "dataset_metadata", values which - are no strings count as missing. If it is missing but exactly one metric is present, that - one is used, since a SingleTSFrame or Series cannot be ambiguous in this respect. Otherwise - None is returned - also if the identified metric is not present, since the only metric - present is another one then. - """ - metric = _single_metric_name(attrs) - if metric is not None: - return metric if metric in metrics else None - - if len(metrics) == 1: - return next(iter(metrics)) - - return None - - -def _select_single_metric(info_by_metric: dict, attrs: Any, empty: Any) -> Any: - """Pick the entry of the single metric (see _single_metric) out of a by-metric mapping - - `empty` is returned if the single metric cannot be determined. - """ - metric = _single_metric(attrs, info_by_metric) - return empty if metric is None else info_by_metric[metric] - - -def _single_metric_info( - attrs: Any, - current: Callable[[_current_convention.GatheredMetadata], dict[str, Any]], - legacy: Callable[[], dict[str, Any]], - empty: Any, -) -> Any: - """Information of the single metric of a SingleTSFrame or Series (see _single_metric) - - For metadata following the current metadata convention, `current` gets the gathered metadata - of the single metric only, so that no information of the other metrics is resolved - of - which there may be many, e.g. for a Series taken from a MultiTSFrame. For older metadata - structures, the entry of the single metric is picked out of the result of `legacy`. - - `empty` is returned if the single metric cannot be determined. - """ - gathered = _current_convention.gather(attrs) - if gathered is None: - return _select_single_metric(legacy(), attrs, empty) - - metric = _single_metric(attrs, gathered.metrics) - if metric is None: - return empty - return current(_current_convention.restricted_to(gathered, metric))[metric] - - -def singlets_value_dimension_info( - singletsframe: pd.DataFrame, value_dim_info: str | Spec, include_inherited: bool = False -) -> defaultdict[str, Any]: - """Metadata info of the value dimensions of the single metric of a SingleTSFrame - - A SingleTSFrame holds exactly one metric but - like a MultiTSFrame - arbitrarily many - value dimensions. So in contrast to get_value_dimension_info (which is keyed by metric - first) this returns a defaultdict keyed by value dimension column name only. - - The single metric is identified via "dataset_metadata.single_metric". If that is missing - but the metadata contains exactly one metric, that metric is used, since a SingleTSFrame - cannot be ambiguous in this respect. - - What is extracted per value dimension is exactly what get_value_dimension_info extracts - - only the metric level is collapsed away. - """ - return singlets_value_dimension_info_with_fallbacks( - singletsframe, (value_dim_info,), value_dim_info, include_inherited - ) - - -def singlets_value_dimension_info_with_fallbacks( - singletsframe: pd.DataFrame, - fields: tuple[str | Spec, ...], - legacy_spec: Spec, - include_inherited: bool = False, -) -> defaultdict[str, Any]: - """Counterpart of value_dimension_info_with_fallbacks for SingleTSFrames - - Like singlets_value_dimension_info, the result is keyed by value dimension only. - """ - return _single_metric_info( - singletsframe.attrs, - lambda gathered: _current_convention.value_dimension_info( - gathered, fields, include_inherited - ), - lambda: _legacy_value_dimension_info(singletsframe.attrs, legacy_spec), - empty=defaultdict(lambda: None), - ) - - -# the metric of a Series in older metadata structures, used if "single_metric" is missing -_LEGACY_SERIES_METRIC = "series" - - -def series_value_info( - series: pd.Series, fields: tuple[str | Spec, ...], legacy_spec: Spec, include_inherited: bool -) -> Any: - """Info of the value dimension "value" of the single metric of a Series - - Each field is looked up as described in value_dimension_info_with_fallbacks. The single - metric is determined like for SingleTSFrames, see singlets_value_dimension_info. Only older - metadata structures name the metric of a Series "series", which is used if "single_metric" - is missing. - """ - - def legacy() -> dict[str, Any]: - info_by_metric = _legacy_value_dimension_info(series.attrs, legacy_spec) - if _single_metric_name(series.attrs) is None and _LEGACY_SERIES_METRIC in info_by_metric: - return {_LEGACY_SERIES_METRIC: info_by_metric[_LEGACY_SERIES_METRIC]} - return info_by_metric - - return _single_metric_info( - series.attrs, - lambda gathered: _current_convention.value_dimension_info( - gathered, fields, include_inherited - ), - legacy, - empty={}, - ).get("value") - - -def _legacy_metric_info(attrs: Any, metric_info: str | Spec) -> dict[str, Any]: - """metric_info_by_metric for older metadata structures""" - try: - return glom(attrs, _specs.spec_by_metric_key(metric_info)) # type: ignore[no-any-return] - except CoalesceError: # no metric metadata present at all - return {} - - -def metric_info_by_metric( - data: pd.DataFrame | pd.Series, metric_info: str | Spec, include_inherited: bool = True -) -> dict[str, Any]: - """Obtain metadata info associated to the metrics, keyed by metric - - For metadata following the current metadata convention, information the metric itself does - not provide is taken from its value dimension "value", then from "value_dimensions_shared" - and, if include_inherited, finally from the properties the metric inherits. Information - provided as null counts as missing. metric_info is only applied to the levels needed and - entries not adhering to the conventions are skipped, see _current_convention. - - Returns an empty dict if no metric metadata is present at all. Errors raised by applying - metric_info, e.g. a transformation failing on null, are not caught. - """ - gathered = _current_convention.gather(data.attrs) - if gathered is not None: - return _current_convention.metric_info(gathered, metric_info, include_inherited) - return _legacy_metric_info(data.attrs, metric_info) - - -def singlets_metric_info( - singletsframe: pd.DataFrame, metric_info: str | Spec, include_inherited: bool = True -) -> Any: - """Metadata of the single metric of a SingleTSFrame - - Counterpart of get_metric_info for SingleTSFrames: instead of a mapping keyed by metric - this directly returns the requested information for the one metric, or None if it cannot - be determined - which includes the case of absent metric metadata, since components should - not require metadata (see the metadata conventions documentation). - """ - return _single_metric_info( - singletsframe.attrs, - lambda gathered: _current_convention.metric_info(gathered, metric_info, include_inherited), - lambda: _legacy_metric_info(singletsframe.attrs, metric_info), - empty=None, - ) - - -def _string_or_none(value: Any) -> str | None: - return value if isinstance(value, str) and value != "" else None - - -def _dataset_default_timezone(attrs: Any) -> str | None: - return _string_or_none(extract_from_metadata(attrs, key=_specs.DEFAULT_TIMEZONE)) - - -def _default_timezones( - gathered: _current_convention.GatheredMetadata | None, dataset_timezone: str | None -) -> defaultdict[str, str | None]: - own_timezones = ( - {} - if gathered is None - else _current_convention.own_metric_info(gathered, _specs.DEFAULT_TIMEZONE) - ) - return defaultdict( - lambda: dataset_timezone, - { - metric: _string_or_none(timezone) or dataset_timezone - for metric, timezone in own_timezones.items() - }, - ) - - -def default_timezone_by_metric(data: pd.DataFrame | pd.Series) -> defaultdict[str, str | None]: - """The default time zone of each metric, falling back to the one of the dataset metadata - - The default time zone of a metric only falls back to the one of the dataset metadata, not to - its value dimensions or inherited properties. - The default of the returned defaultdict is the one of the dataset metadata, since it applies - to all metrics without their own. Values which are no (non-empty) strings count as missing. - """ - return _default_timezones( - _current_convention.gather(data.attrs), _dataset_default_timezone(data.attrs) - ) - - -def single_metric_default_timezone(data: pd.DataFrame | pd.Series) -> str | None: - """The default time zone of the single metric of a Series or SingleTSFrame - - Falls back to the one of the dataset metadata, also if the single metric is not found. - """ - dataset_timezone = _dataset_default_timezone(data.attrs) - return cast( - str | None, - _single_metric_info( - data.attrs, - lambda gathered: _default_timezones(gathered, dataset_timezone), - dict, # older metadata structures provide no default time zones of the metrics - empty=dataset_timezone, - ), - ) diff --git a/src/hdhelpers/metadata/_reading.py b/src/hdhelpers/metadata/_reading.py new file mode 100644 index 0000000..e30361b --- /dev/null +++ b/src/hdhelpers/metadata/_reading.py @@ -0,0 +1,457 @@ +"""Reading metadata of the conventions, with the fallbacks of the conventions + +The methods of MetadataReading are available on the root models of the conventions +(SeriesMetadata, SingleTSFrameMetadata, MultiTSFrameMetadata). They resolve the fallbacks: + +- Information of a value dimension, e.g. its unit: Each field of a chain, e.g. display_name and + then name, is looked up on the value dimension, for the value dimension "value" on the metric, + then on the shared value dimension of the column (value_dimensions_shared), before the next + field is tried. With include_inherited, the properties the metric inherits are the last resort + for the value dimension "value". +- Information of a metric: the metric, its value dimension "value", the shared value dimension + "value", with include_inherited finally the properties the metric inherits. +- Default time zone: the one of the metric, otherwise the one of the dataset metadata. +- Recording: the frequency of the metric together with its offset and time zone, otherwise the + ones of the dataset metadata (ref_data_frequency etc.), always as a whole. +- Default aggregation method: the one of the metric, with AVG as MEAN, otherwise LAST for + boolean and string values and MEAN for all others. + +Fields can be given as dotted paths, e.g. "inherited.site". Information which is null counts as +missing. For a metric without metadata, information is None, apart from the fallbacks to the +dataset metadata. + +For SERIES and SINGLETSFRAME metadata, the metric_id can be omitted, which refers to their single +metric. MULTITSFRAME metadata requires it. +""" + +import datetime +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import Enum +from functools import cached_property +from typing import TYPE_CHECKING, Any, ClassVar, Literal + +from pydantic import BaseModel + +if TYPE_CHECKING: + from hdhelpers.metadata.conventions import ( + MetricMetadata, + SharedValueDimensionMetadata, + ValueDimensionMetadata, + _DatasetMetadataBase, + ) + +DISPLAY_NAME_FIELDS = ("display_name", "name") +SHORT_DISPLAY_NAME_FIELDS = ("short_display_name", *DISPLAY_NAME_FIELDS) + +# default aggregation method of metrics without one by the data type of their values +_DEFAULT_AGGREGATION_METHOD_BY_VALUE_DATA_TYPE = {"boolean": "LAST", "string": "LAST"} +_DEFAULT_AGGREGATION_METHOD = "MEAN" +_AGGREGATION_METHOD_ALIASES = {"AVG": "MEAN"} + +_MISSING = object() + + +@dataclass(frozen=True) +class Recording: + """Immutable recording settings for a metric: mode, frequency and interval alignment. + + Metadata lookups use dataset frequency settings when the metric provides none. + Recording modes describe when values are recorded: + + * ``periodic``: one value per interval, timestamped at its start. Missing values are gaps. + * ``on_change``: record changes exceeding the value dimension's deadband. A value holds + until the next one, provided the heartbeat interval is not exceeded. + * ``irregular``: values at arbitrary points in time. + + Attributes: + recording_mode (str | None): One of the modes above; None if unknown. + data_frequency (str | None): Pandas offset alias or timedelta string, e.g. "15min" + or "1MS". For on_change, the maximum time between recordings (heartbeat), + even without a change. None if unknown. + data_frequency_offset (str | None): Shift of the frequency intervals as a timedelta + string, e.g. "5min". None means zero when a frequency is set. + data_frequency_timezone (str | None): Time zone for aligning frequency intervals, + e.g. "Europe/Berlin". None means UTC when a frequency is set. + recording_aggregation_method (str | None): For periodic values, how each interval + is aggregated, e.g. "MEAN" or "MAX". None for samples taken at the timestamp. + + data_frequency_offset and data_frequency_timezone belong to data_frequency: They are None + if no data_frequency applies. + """ + + recording_mode: str | None = None + data_frequency: str | None = None + data_frequency_offset: str | None = None + data_frequency_timezone: str | None = None + recording_aggregation_method: str | None = None + + +_Frequency = tuple[str | None, str | None, str | None] + + +def lookup(obj: Any, path: str) -> Any: + """A field, extra field or dotted path of fields of a model or mapping, None if missing + + Members of enums are given as their values, e.g. "MEAN" for AggregationMethod.MEAN, as in + the JSON form of the metadata. + """ + for key in path.split("."): + obj = _field(obj, key) + if obj is None: + return None + return _plain(obj) + + +def id_of(metric: "MetricMetadata", metric_key: str) -> Any: + """The id of a metric: its field named metric_key, which may contain dots, None if missing""" + return _plain(_field(metric, metric_key)) + + +def _field(obj: Any, key: str) -> Any: + """A field or extra field of a model or a key of a mapping, None if missing""" + if isinstance(obj, BaseModel): + # __dict__ holds the fields, without warning about deprecated ones like getattr + value = obj.__dict__.get(key, _MISSING) + return (obj.__pydantic_extra__ or {}).get(key) if value is _MISSING else value + if isinstance(obj, Mapping): + return obj.get(key) + return None + + +def _plain(value: Any) -> Any: + """The value of an enum member, e.g. "MEAN" instead of AggregationMethod.MEAN""" + return value.value if isinstance(value, Enum) else value + + +def _non_empty(value: Any) -> str | None: + value = _plain(value) + return value if isinstance(value, str) and value != "" else None + + +def _frequency(obj: Any, fields: tuple[str, str, str]) -> _Frequency: + """Frequency, offset and time zone of obj, all None if it has no frequency""" + frequency, offset, timezone = (_non_empty(lookup(obj, field)) for field in fields) + return (None, None, None) if frequency is None else (frequency, offset, timezone) + + +def _value_dimension_info( + fields: tuple[str, ...], + metric: "MetricMetadata", + value_dimensions: Mapping[str, "ValueDimensionMetadata"], + shared: Mapping[str, "SharedValueDimensionMetadata"], + column: str, + include_inherited: bool, +) -> Any: + value_dimension = value_dimensions.get(column) + shared_value_dimension = shared.get(column) + for field in fields: + info = lookup(value_dimension, field) + if info is None and column == "value": + info = lookup(metric, field) + if info is None: + info = lookup(shared_value_dimension, field) + if info is not None: + return info + if include_inherited and column == "value" and metric.inherited is not None: + for field in fields: + info = lookup(metric.inherited, field) + if info is not None: + return info + return None + + +def _by_column( + value_dimensions: "Sequence[ValueDimensionMetadata] | Sequence[SharedValueDimensionMetadata]", +) -> dict[str, Any]: + return {value_dimension.column: value_dimension for value_dimension in value_dimensions} + + +def _fields(fields: str | Sequence[str]) -> tuple[str, ...]: + return (fields,) if isinstance(fields, str) else tuple(fields) + + +class MetadataReading: + """Lookups with the fallbacks of the conventions, see the module docstring""" + + if TYPE_CHECKING: + dataset_metadata: _DatasetMetadataBase + metrics: tuple[MetricMetadata, ...] + value_dimensions_shared: tuple[SharedValueDimensionMetadata, ...] | None + _data_type: ClassVar[Literal["SERIES", "SINGLETSFRAME", "MULTITSFRAME"]] + + # metrics + + def metric_ids(self) -> list[str]: + """The ids of the metrics, i.e. their values of the field named by the metric key""" + return list(self._metrics_by_id) + + def metric(self, metric_id: str | None = None) -> "MetricMetadata": + """The metadata of a metric, KeyError if there is none""" + metric = self._metric_or_none(metric_id) + if metric is None: + raise KeyError(metric_id) + return metric + + @cached_property + def _metrics_by_id(self) -> "dict[str, MetricMetadata]": + """The metrics by id, built on first use + + Cached in __dict__ apart from the fields, which pydantic ignores when comparing models: + Reading must not change the equality, since pandas keeps only equal attrs, e.g. on concat. + """ + metric_key = self.dataset_metadata.metric_key + return {id_of(metric, metric_key): metric for metric in self.metrics} + + def _metric_or_none(self, metric_id: str | None) -> "MetricMetadata | None": + if metric_id is None: + if self._data_type == "MULTITSFRAME": + raise TypeError("The metric_id is required for MULTITSFRAME metadata.") + return self.metrics[0] + return self._metrics_by_id.get(metric_id) + + def _shared_by_column(self) -> "dict[str, SharedValueDimensionMetadata]": + return _by_column(self.value_dimensions_shared or ()) + + # information of value dimensions + + def value_dimension_info( + self, + fields: str | Sequence[str], + metric_id: str | None = None, + column: str = "value", + *, + include_inherited: bool = False, + ) -> Any: + """Information of a value dimension, trying the fields in turn""" + metric = self._metric_or_none(metric_id) + if metric is None: + return None + return _value_dimension_info( + _fields(fields), + metric, + _by_column(metric.value_dimensions), + self._shared_by_column(), + column, + include_inherited, + ) + + def value_dimension_infos( + self, fields: str | Sequence[str], *, include_inherited: bool = False + ) -> dict[str, dict[str, Any]]: + """Information of the value dimensions by column by metric + + The value dimensions of a metric are its own ones, "value" and the shared ones. + """ + fields = _fields(fields) + shared = self._shared_by_column() + result = {} + for metric_id, metric in self._metrics_by_id.items(): + value_dimensions = _by_column(metric.value_dimensions) + columns = dict.fromkeys([*shared, "value", *value_dimensions]) + result[metric_id] = { + column: _value_dimension_info( + fields, metric, value_dimensions, shared, column, include_inherited + ) + for column in columns + } + return result + + def unit( + self, + metric_id: str | None = None, + column: str = "value", + *, + include_inherited: bool = False, + ) -> str | None: + return self.value_dimension_info( # type: ignore[no-any-return] + "unit", metric_id, column, include_inherited=include_inherited + ) + + def units(self, *, include_inherited: bool = False) -> dict[str, dict[str, str | None]]: + return self.value_dimension_infos("unit", include_inherited=include_inherited) + + def measurement( + self, + metric_id: str | None = None, + column: str = "value", + *, + include_inherited: bool = False, + ) -> str | None: + return self.value_dimension_info( # type: ignore[no-any-return] + "measurement", metric_id, column, include_inherited=include_inherited + ) + + def measurements(self, *, include_inherited: bool = False) -> dict[str, dict[str, str | None]]: + return self.value_dimension_infos("measurement", include_inherited=include_inherited) + + def name( + self, + metric_id: str | None = None, + column: str = "value", + *, + include_inherited: bool = False, + ) -> str | None: + return self.value_dimension_info( # type: ignore[no-any-return] + "name", metric_id, column, include_inherited=include_inherited + ) + + def names(self, *, include_inherited: bool = False) -> dict[str, dict[str, str | None]]: + return self.value_dimension_infos("name", include_inherited=include_inherited) + + def display_name( + self, + metric_id: str | None = None, + column: str = "value", + *, + include_inherited: bool = False, + ) -> str | None: + """The display name, falling back to the name""" + return self.value_dimension_info( # type: ignore[no-any-return] + DISPLAY_NAME_FIELDS, metric_id, column, include_inherited=include_inherited + ) + + def display_names(self, *, include_inherited: bool = False) -> dict[str, dict[str, str | None]]: + return self.value_dimension_infos(DISPLAY_NAME_FIELDS, include_inherited=include_inherited) + + def short_display_name( + self, + metric_id: str | None = None, + column: str = "value", + *, + include_inherited: bool = False, + ) -> str | None: + """The short display name, falling back to the display name and the name""" + return self.value_dimension_info( # type: ignore[no-any-return] + SHORT_DISPLAY_NAME_FIELDS, metric_id, column, include_inherited=include_inherited + ) + + def short_display_names( + self, *, include_inherited: bool = False + ) -> dict[str, dict[str, str | None]]: + return self.value_dimension_infos( + SHORT_DISPLAY_NAME_FIELDS, include_inherited=include_inherited + ) + + # information of metrics + + def metric_info( + self, field: str, metric_id: str | None = None, *, include_inherited: bool = True + ) -> Any: + """Information of a metric, see the module docstring for the fallbacks""" + metric = self._metric_or_none(metric_id) + if metric is None: + return None + return self._metric_info(field, metric, self._shared_by_column(), include_inherited) + + def metric_infos(self, field: str, *, include_inherited: bool = True) -> dict[str, Any]: + shared = self._shared_by_column() + return { + metric_id: self._metric_info(field, metric, shared, include_inherited) + for metric_id, metric in self._metrics_by_id.items() + } + + @staticmethod + def _metric_info( + field: str, + metric: "MetricMetadata", + shared: Mapping[str, "SharedValueDimensionMetadata"], + include_inherited: bool, + ) -> Any: + info = lookup(metric, field) + if info is None: + info = lookup(_by_column(metric.value_dimensions).get("value"), field) + if info is None: + info = lookup(shared.get("value"), field) + if info is None and include_inherited: + info = lookup(metric.inherited, field) + return info + + # time zones, recording, aggregation and the queried interval + + def default_timezone(self, metric_id: str | None = None) -> str | None: + """The default time zone of the metric, otherwise the one of the dataset metadata""" + metric = self._metric_or_none(metric_id) + own = None if metric is None else _non_empty(metric.default_timezone) + return own or dataset_default_timezone(self) + + def default_timezones(self) -> dict[str, str | None]: + return {metric_id: self.default_timezone(metric_id) for metric_id in self._metrics_by_id} + + def recording(self, metric_id: str | None = None) -> Recording: + """How the values of the metric are recorded + + The frequency with its offset and time zone falls back to the ones of the dataset + metadata as a whole. + """ + dataset_frequency = _dataset_frequency(self) + metric = self._metric_or_none(metric_id) + if metric is None: + return Recording(None, *dataset_frequency, None) + own_frequency = _frequency( + metric, ("data_frequency", "data_frequency_offset", "data_frequency_timezone") + ) + return Recording( + _non_empty(metric.recording_mode), + *(own_frequency if own_frequency[0] is not None else dataset_frequency), + _non_empty(metric.recording_aggregation_method), + ) + + def recordings(self) -> dict[str, Recording]: + return {metric_id: self.recording(metric_id) for metric_id in self._metrics_by_id} + + def default_aggregation_method(self, metric_id: str | None = None) -> str: + """The default aggregation method of the metric, see the module docstring""" + metric = self._metric_or_none(metric_id) + if metric is None: + return _DEFAULT_AGGREGATION_METHOD + own_method = _non_empty(metric.default_aggregation_method) + if own_method is not None: + return _AGGREGATION_METHOD_ALIASES.get(own_method, own_method) + value_data_type = _plain( + _value_dimension_info( + ("value_data_type",), + metric, + _by_column(metric.value_dimensions), + self._shared_by_column(), + "value", + include_inherited=False, + ) + ) + return _DEFAULT_AGGREGATION_METHOD_BY_VALUE_DATA_TYPE.get( + value_data_type, _DEFAULT_AGGREGATION_METHOD + ) + + def default_aggregation_methods(self) -> dict[str, str]: + return { + metric_id: self.default_aggregation_method(metric_id) + for metric_id in self._metrics_by_id + } + + def queried_interval(self) -> tuple[datetime.datetime | None, datetime.datetime | None]: + """Start and end of the queried interval (the ref interval), None if missing""" + return ( + _datetime(self.dataset_metadata.ref_interval_start_timestamp), + _datetime(self.dataset_metadata.ref_interval_end_timestamp), + ) + + +def _datetime(timestamp: str | None) -> datetime.datetime | None: + return None if timestamp is None else datetime.datetime.fromisoformat(timestamp) + + +def _dataset_frequency(meta: MetadataReading) -> _Frequency: + return _frequency( + meta.dataset_metadata, + ("ref_data_frequency", "ref_data_frequency_offset", "ref_data_frequency_timezone"), + ) + + +def dataset_default_timezone(meta: MetadataReading) -> str | None: + """The default time zone of the dataset metadata, which applies to metrics without one""" + return _non_empty(meta.dataset_metadata.default_timezone) + + +def dataset_recording(meta: MetadataReading) -> Recording: + """The recording of a metric without metadata: only the frequency of the dataset metadata""" + return Recording(None, *_dataset_frequency(meta), None) diff --git a/src/hdhelpers/metadata/_recording.py b/src/hdhelpers/metadata/_recording.py deleted file mode 100644 index 5ae2dc3..0000000 --- a/src/hdhelpers/metadata/_recording.py +++ /dev/null @@ -1,169 +0,0 @@ -"""How the values of the metrics are recorded and how they should be aggregated by default - -The frequency of the recorded values of a metric falls back as a whole: If the metric has a -"data_frequency", it applies together with the metric's "data_frequency_offset" and -"data_frequency_timezone". Otherwise "ref_data_frequency" of the dataset metadata applies -together with "ref_data_frequency_offset" and "ref_data_frequency_timezone". -""" - -from collections import defaultdict -from dataclasses import dataclass -from typing import Any, cast - -import pandas as pd - -import hdhelpers.metadata._current_convention as _current_convention -from hdhelpers.metadata._helpers import _select_single_metric, _string_or_none - -_FREQUENCY_FIELDS = ("data_frequency", "data_frequency_offset", "data_frequency_timezone") -_DATASET_FREQUENCY_FIELDS = ( - "ref_data_frequency", - "ref_data_frequency_offset", - "ref_data_frequency_timezone", -) - -# default aggregation method of metrics without one by the data type of their values -_DEFAULT_AGGREGATION_METHOD_BY_VALUE_DATA_TYPE = {"boolean": "LAST", "string": "LAST"} -_DEFAULT_AGGREGATION_METHOD = "MEAN" -_AGGREGATION_METHOD_ALIASES = {"AVG": "MEAN"} - - -@dataclass(frozen=True) -class Recording: - """How the values of a metric are recorded, as given in its metadata - - The fields mean the following: - - Attributes: - recording_mode (str | None): "periodic": one value per interval of data_frequency, timestamped with the start of the interval. A missing value is a gap. "on_change": a value is recorded when it changes by more than the deadband of its value dimension and holds until the next one. Missing timestamps are no gaps. "irregular": values at arbitrary points in time. None if unknown. - data_frequency (str | None): Frequency as Pandas date offset alias or timedelta string, e.g. "15min", "1D" or "1MS". For "on_change", the interval after which the value is recorded again at the latest, also without a change (heartbeat). - data_frequency_offset (str | None): Timedelta string by which the intervals of data_frequency are shifted, e.g. "5min". None means 0. - data_frequency_timezone (str | None): Time zone the intervals of data_frequency are aligned to, e.g. "Europe/Berlin". None means UTC. - recording_aggregation_method (str | None): Only for "periodic": the method each value aggregates its interval with, e.g. "MEAN" or "MAX". None if the values are samples, e.g. the value holding at the timestamp. - - data_frequency_offset and data_frequency_timezone belong to data_frequency: They are None - if no data_frequency applies. - """ - - recording_mode: str | None = None - data_frequency: str | None = None - data_frequency_offset: str | None = None - data_frequency_timezone: str | None = None - recording_aggregation_method: str | None = None - - -_Frequency = tuple[str | None, str | None, str | None] -_NO_FREQUENCY: _Frequency = (None, None, None) - - -def _frequency(obj: Any, fields: tuple[str, str, str]) -> _Frequency: - """Frequency, offset and time zone of obj, all None if it has no frequency - - Values which are no (non-empty) strings count as missing. - """ - if not isinstance(obj, dict): - return _NO_FREQUENCY - frequency, offset, timezone = (_string_or_none(obj.get(field)) for field in fields) - if frequency is None: - return _NO_FREQUENCY - return frequency, offset, timezone - - -def _dataset_frequency(attrs: Any) -> _Frequency: - dataset_metadata = attrs.get("dataset_metadata") if isinstance(attrs, dict) else None - return _frequency(dataset_metadata, _DATASET_FREQUENCY_FIELDS) - - -def _recording(metric: dict, dataset_frequency: _Frequency) -> Recording: - own_frequency = _frequency(metric, _FREQUENCY_FIELDS) - frequency, offset, timezone = ( - own_frequency if own_frequency[0] is not None else dataset_frequency - ) - return Recording( - recording_mode=_string_or_none(metric.get("recording_mode")), - data_frequency=frequency, - data_frequency_offset=offset, - data_frequency_timezone=timezone, - recording_aggregation_method=_string_or_none(metric.get("recording_aggregation_method")), - ) - - -def recording_by_metric(data: pd.DataFrame | pd.Series) -> defaultdict[str, Recording]: - """The recording of each metric, falling back to the frequency of the dataset metadata - - The default of the returned defaultdict is the recording of a metric without metadata, i.e. - with the frequency of the dataset metadata. - """ - dataset_frequency = _dataset_frequency(data.attrs) - gathered = _current_convention.gather(data.attrs) - metrics = ( - {} - if gathered is None - else { - metric: gathered_metric.metric for metric, gathered_metric in gathered.metrics.items() - } - ) - return defaultdict( - lambda: Recording(None, *dataset_frequency, None), - {metric: _recording(obj, dataset_frequency) for metric, obj in metrics.items()}, - ) - - -def single_metric_recording(data: pd.DataFrame | pd.Series) -> Recording: - """The recording of the single metric of a Series or SingleTSFrame - - Falls back to the frequency of the dataset metadata, also if the single metric is not found. - """ - return cast( - Recording, - _select_single_metric( - recording_by_metric(data), - data.attrs, - empty=Recording(None, *_dataset_frequency(data.attrs), None), - ), - ) - - -def _default_aggregation_method(method: Any, value_data_type: Any) -> str: - own_method = _string_or_none(method) - if own_method is not None: - return _AGGREGATION_METHOD_ALIASES.get(own_method, own_method) - return _DEFAULT_AGGREGATION_METHOD_BY_VALUE_DATA_TYPE.get( - value_data_type, _DEFAULT_AGGREGATION_METHOD - ) - - -def default_aggregation_method_by_metric(data: pd.DataFrame | pd.Series) -> defaultdict[str, str]: - """The default aggregation method of each metric - - The method of the metric, with "AVG" normalized to its alias "MEAN". If the metric does not - provide one, "LAST" for values of the data type "boolean" or "string" (of the value - dimension "value", see value_dimension_info) and "MEAN" otherwise, which is also the default - of the returned defaultdict. - """ - gathered = _current_convention.gather(data.attrs) - if gathered is None: - return defaultdict(lambda: _DEFAULT_AGGREGATION_METHOD) - methods = _current_convention.own_metric_info(gathered, "default_aggregation_method") - value_data_types = _current_convention.value_dimension_info( - gathered, ["value_data_type"], include_inherited=False - ) - return defaultdict( - lambda: _DEFAULT_AGGREGATION_METHOD, - { - metric: _default_aggregation_method(method, value_data_types[metric]["value"]) - for metric, method in methods.items() - }, - ) - - -def single_metric_default_aggregation_method(data: pd.DataFrame | pd.Series) -> str: - """The default aggregation method of the single metric of a Series or SingleTSFrame""" - return cast( - str, - _select_single_metric( - default_aggregation_method_by_metric(data), - data.attrs, - empty=_DEFAULT_AGGREGATION_METHOD, - ), - ) diff --git a/src/hdhelpers/metadata/_specs.py b/src/hdhelpers/metadata/_specs.py deleted file mode 100644 index 021a638..0000000 --- a/src/hdhelpers/metadata/_specs.py +++ /dev/null @@ -1,201 +0,0 @@ -from glom import Check, Coalesce, Iter, Merge, Spec, T - -# The metadatum specs of the individual accessors, including their fallback chains. -# Defined once here and shared by the MultiTSFrame and the SingleTSFrame accessors, which -# only differ in whether the result is keyed by metric first - not in what they extract. -UNIT: Spec = "unit" -MEASUREMENT: Spec = "measurement" -NAME: Spec = Coalesce("name", default=None) - -# The fallback chains of the display names. For metadata following the current metadata -# convention, each field is looked up on all levels (value dimension, metric, shared value -# dimensions) before the next field is tried, see _current_convention.value_dimension_info. -# DISPLAY_NAME and SHORT_DISPLAY_NAME are the corresponding specs for older metadata structures. -DISPLAY_NAME_FIELDS = ("display_name", "name") -SHORT_DISPLAY_NAME_FIELDS = ("short_display_name", *DISPLAY_NAME_FIELDS) -DISPLAY_NAME: Spec = Coalesce(*DISPLAY_NAME_FIELDS, default=None) -SHORT_DISPLAY_NAME: Spec = Coalesce(*SHORT_DISPLAY_NAME_FIELDS, default=None) - -# Key of the dict in a metric object holding the properties the metric inherits from objects -# higher up in an asset hierarchy, e.g. the latitude of the plant a sensor belongs to. -INHERITED = "inherited" - -# Field of a metric and of the dataset metadata: the time zone the timeseries should be -# evaluated in by default, e.g. for bucketing days when aggregating. -DEFAULT_TIMEZONE = "default_timezone" - - -def by_metric_key_by_val_dimension(metadatum_key: str | Spec) -> Spec: - """Provides glom spec that extracts a metadatum by metric by value dimension - - Only for older metadata structures, metadata following the current metadata convention is - handled by _current_convention. - - The generated glom spec returns a defaultdict of defaultdicts: - {metric_key: {value_dimension_column_name: metadatum_value}} - defaulting to None in the inner default dict. - - Properly falls back to respective field in metric metadatum for the "value" - value_dimension if this value dimension is not explicitely included in the - metadata of the metric. - - Properly falls back to "value_dimensions_shared" metadata if a value_dimension - is not given for a metric if its available there. - - metadatum_key can also be any glom spec. - """ - return Coalesce( - _spec_platform_convention_series_metric(metadatum_key), - _spec_platform_convention_series_value_dim(metadatum_key), - _spec_older_convention1_metric(metadatum_key), - _spec_older_convention1_value_dim(metadatum_key), - _spec_older_convention2(metadatum_key), - _spec_older_convention4(metadatum_key), - default={}, - ) - - -def _spec_older_convention1_value_dim(metadatum_key: str | Spec) -> Spec: - return ( - "by_metric", - Check(instance_of=dict), - _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - ( - Coalesce("value_dimensions", default={}), - Check(instance_of=dict), - _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - Coalesce(metadatum_key) - ), - ) - ), - ) - - -def _spec_older_convention1_metric(metadatum_key: str | Spec) -> Spec: - return ( - "by_metric", - Check(instance_of=dict), - _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - ( - Coalesce("metric", default={}), - Check(instance_of=dict), - {"value": Coalesce(metadatum_key)}, - ) - ), - ) - - -def _spec_older_convention2(metadatum_key: str | Spec) -> Spec: - return ( - "metrics", - Check(instance_of=dict), - _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - {"value": Coalesce(metadatum_key, default=None)} # only SERIES / only value column. - ), - ) - - -def _spec_platform_convention_series_metric(metadatum_key: str | Spec) -> Spec: - return ( - "single_metric_metadata.structured_metadata.metric", - Check(instance_of=dict), - {"series": {"value": Coalesce(metadatum_key, default=None)}}, - ) - - -def _spec_platform_convention_series_value_dim(metadatum_key: str | Spec) -> Spec: - return ( - "single_metric_metadata.structured_metadata.value_dimensions.value", - Check(instance_of=dict), - {"series": {"value": Coalesce(metadatum_key, default=None)}}, - ) - - -def _spec_older_convention4(metadatum_key: str | Spec) -> Spec: - return ( - ( - "metric_metadata", - Check(instance_of=dict), - _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - {"value": Coalesce(metadatum_key, default=None)} # only SERIES / only value column. - ), - ), - ) - - -def spec_by_metric_key(metadatum_key: str | Spec) -> Spec: - """Provides glom spec that extracts a metadatum from the metric objects, keyed by metric - - Only for older metadata structures, metadata following the current metadata convention is - handled by _current_convention. - """ - return Coalesce( - ( # some older, simpler metadata structure - "by_metric", - _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - Coalesce(metadatum_key, default=None) - ), - ), - ( # another older / simplified metadata structure - "metrics", - _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - Coalesce(metadatum_key, default=None) - ), - ), - ) - - -def _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - deeper_glom_spec: Spec, -) -> Spec: - """Create dicts with keys from current dict and values from deeper in their value objects - - This function provides a glom spec to do this. - It uses https://glom.readthedocs.io/en/latest/tutorial.html#data-driven-assignment. - - deeper_glom_spec is the spec to get to the deeper values in each value object. - - .. doctest:: - - >>> from hdhelpers.metadata._specs import _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested - >>> from glom import glom - >>> data = { - ... 'some_other_field': 'value', - ... 'by_item': { - ... 'item1': { - ... 'metadata': { - ... 'properties': { - ... 'unit': 'kg' - ... } - ... } - ... }, - ... 'item2': { - ... 'info': { - ... 'details': { - ... 'unit': 'meters' - ... } - ... } - ... }, - ... 'item3': { - ... 'unit': 'liters' - ... } - ... } - ... } - >>> glom( - ... data, - ... ("by_item", - ... _glom_dict_with_keys_of_current_dict_and_values_something_deeper_nested( - ... Coalesce( - ... "metadata.properties.unit", "info.details.unit", "unit", default=None - ... ) - ... ), - ... ), - ... ) - {'item1': 'kg', 'item2': 'meters', 'item3': 'liters'} - """ - # T is a stand-in for the target: everything done to T is recorded and executed by glom - return ( - T.items(), # treat it as list of (key, value) tuples - Iter({T[0]: (T[1], deeper_glom_spec)}), - Merge(), - ) diff --git a/src/hdhelpers/metadata/attached.py b/src/hdhelpers/metadata/attached.py new file mode 100644 index 0000000..f0b6dda --- /dev/null +++ b/src/hdhelpers/metadata/attached.py @@ -0,0 +1,306 @@ +"""Metadata of the conventions attached to pandas objects as model + +The runtime of the hetida designer parses the metadata of each workflow input once and attaches +the model under attrs["hd_metadata"] (META_KEY), in place of the keys of the conventions +("dataset_metadata", "metrics", "value_dimensions_shared"). Other keys of attrs stay as they are. + +pandas deep-copies attrs for (almost) every object derived from another one. The models are +immutable and return themselves when copied, so that all derived objects share the model +instead of copying the metadata. + +Component code reads the metadata via read_metadata and attaches a changed version (see the +with_* methods of the models) to its own result via set_metadata. Everything serializing attrs +to JSON needs attrs_to_json, since the model is no JSON value. +""" + +from collections.abc import Mapping +from typing import Any, Literal, overload + +import pandas as pd +from pydantic import ValidationError + +from hdhelpers.metadata.conventions import ( + METADATA_MODELS, + MetadataConventionError, + MetadataConventionIssue, + MetadataIssueSeverity, + MultiTSFrameMetadata, + SeriesMetadata, + SingleTSFrameMetadata, + TimeseriesDataType, + _TimeseriesMetadataBase, + check_metadata_conventions, +) + +META_KEY = "hd_metadata" + +# the top-level keys of attrs which belong to the conventions, i.e. the fields of the models +CONVENTION_KEYS = frozenset( + field for model in METADATA_MODELS.values() for field in model.model_fields +) + +AnyMetadata = SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata + + +def infer_data_type(obj: pd.Series | pd.DataFrame) -> TimeseriesDataType: + """SERIES for a Series, MULTITSFRAME for a frame with a "metric" column, else SINGLETSFRAME""" + if isinstance(obj, pd.Series): + return "SERIES" + return "MULTITSFRAME" if "metric" in obj.columns else "SINGLETSFRAME" + + +def _convention_attrs(attrs: Mapping[Any, Any]) -> dict[str, Any]: + return {key: value for key, value in attrs.items() if key in CONVENTION_KEYS} + + +def attach_metadata( + obj: pd.Series | pd.DataFrame, data_type: TimeseriesDataType | None = None +) -> AnyMetadata | None: + """Parse metadata in attrs and attach the resulting immutable model. + + Replace convention fields with attrs["hd_metadata"]; preserve other attrs. An already + attached model is returned unchanged. + + Args: + obj: Series or DataFrame to update in place. + data_type: + Convention for raw attrs, or None to infer it; see + :ref:`metadata-type-selection`. Used only when no model is attached. + + Returns: + SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata | None: + Attached model, or None for absent/invalid metadata. Failure leaves attrs + unchanged. + + Code example: + + Using :ref:`metadata-example-data`, attach a model while preserving unrelated attrs: + + .. doctest:: metadata.attach_metadata + + >>> data = series.copy() + >>> data.attrs["source"] = "example" + >>> meta = md.attach_metadata(data) + >>> meta.unit() + 'm' + >>> sorted(data.attrs) + ['hd_metadata', 'source'] + >>> md.read_metadata(data) is meta + True + >>> md.attach_metadata(data) is meta + True + >>> md.attach_metadata(pd.Series([1.0])) is None + True + """ + attached = obj.attrs.get(META_KEY) + if isinstance(attached, _TimeseriesMetadataBase): + return attached # type: ignore[return-value] + convention_attrs = _convention_attrs(obj.attrs) + if len(convention_attrs) == 0: + return None + model = METADATA_MODELS[data_type or infer_data_type(obj)] + try: + meta = model.model_validate(convention_attrs) + except ValidationError: + return None + obj.attrs = {key: value for key, value in obj.attrs.items() if key not in CONVENTION_KEYS} | { + META_KEY: meta + } + return meta # type: ignore[return-value] + + +@overload +def read_metadata( + obj: pd.Series | pd.DataFrame, data_type: Literal["SERIES"], *, required: Literal[True] +) -> SeriesMetadata: ... +@overload +def read_metadata( + obj: pd.Series | pd.DataFrame, data_type: Literal["SERIES"], *, required: bool = False +) -> SeriesMetadata | None: ... +@overload +def read_metadata( + obj: pd.Series | pd.DataFrame, data_type: Literal["SINGLETSFRAME"], *, required: Literal[True] +) -> SingleTSFrameMetadata: ... +@overload +def read_metadata( + obj: pd.Series | pd.DataFrame, data_type: Literal["SINGLETSFRAME"], *, required: bool = False +) -> SingleTSFrameMetadata | None: ... +@overload +def read_metadata( + obj: pd.Series | pd.DataFrame, data_type: Literal["MULTITSFRAME"], *, required: Literal[True] +) -> MultiTSFrameMetadata: ... +@overload +def read_metadata( + obj: pd.Series | pd.DataFrame, data_type: Literal["MULTITSFRAME"], *, required: bool = False +) -> MultiTSFrameMetadata | None: ... +@overload +def read_metadata( + obj: pd.Series | pd.DataFrame, data_type: None = None, *, required: Literal[True] +) -> AnyMetadata: ... +@overload +def read_metadata( + obj: pd.Series | pd.DataFrame, data_type: None = None, *, required: bool = False +) -> AnyMetadata | None: ... +def read_metadata( + obj: pd.Series | pd.DataFrame, + data_type: TimeseriesDataType | None = None, + *, + required: bool = False, +) -> AnyMetadata | None: + """Read metadata as a model without changing the input. + + Reuse an attached model; otherwise parse convention fields in attrs. + + Args: + obj: Series or DataFrame to read. + data_type: + Expected convention. None keeps an attached model's type or infers it for raw + attrs; see :ref:`metadata-type-selection`. + required: + If True, raise on missing, invalid or wrong-type metadata instead of returning + None. + + Returns: + SeriesMetadata | SingleTSFrameMetadata | MultiTSFrameMetadata | None: + Attached or newly parsed model; None for missing, invalid or wrong-type + metadata. + + Raises: + MetadataConventionError: Metadata cannot be returned and required=True. + + Code example: + + Using :ref:`metadata-example-data`, read a model without changing the original attrs: + + .. doctest:: metadata.read_metadata + + >>> meta = md.read_metadata(series, required=True) + >>> meta.unit() + 'm' + >>> "hd_metadata" in series.attrs + False + >>> missing = pd.Series([1.0]) + >>> md.read_metadata(missing) is None + True + >>> try: + ... md.read_metadata(missing, required=True) + ... except md.MetadataConventionError as error: + ... print(error.issues[0].message) + No metadata in attrs. + """ + attached = obj.attrs.get(META_KEY) + if isinstance(attached, _TimeseriesMetadataBase): + if data_type is None or attached._data_type == data_type: + return attached # type: ignore[return-value] + _no_metadata( + required, + f"The attached metadata is {attached._data_type} metadata, not {data_type} metadata.", + ) + return None + + convention_attrs = _convention_attrs(obj.attrs) + if len(convention_attrs) == 0: + _no_metadata(required, "No metadata in attrs.") + return None + data_type = data_type or infer_data_type(obj) + try: + return METADATA_MODELS[data_type].model_validate(convention_attrs) # type: ignore[return-value] + except ValidationError as e: + if not required: + return None + issues = [ + issue + for issue in check_metadata_conventions(convention_attrs, data_type) + if issue.severity == MetadataIssueSeverity.ERROR + ] + raise MetadataConventionError(issues) from e + + +def _no_metadata(required: bool, message: str) -> None: + """None, or with required raise a MetadataConventionError""" + if required: + raise MetadataConventionError( + [ + MetadataConventionIssue( + severity=MetadataIssueSeverity.ERROR, location="", message=message + ) + ] + ) + + +def set_metadata(obj: pd.Series | pd.DataFrame, meta: AnyMetadata) -> None: + """Attach the supplied metadata model to a result object. + + Store it under attrs["hd_metadata"], replacing convention fields and preserving + unrelated attrs. + + Args: + obj: Result Series or DataFrame created by your component; updated in place. + meta: Validated metadata model; shared without copying or revalidation. + + Returns: + None: Updates obj in place. + + Code example: + + Convert the metre values from :ref:`metadata-example-data` to centimetres and set matching + metadata on the result. The original metadata stays unchanged: + + .. doctest:: metadata.set_metadata + + >>> meta = md.read_metadata(series, required=True) + >>> result = series * 100 + >>> changed = meta.with_value_dimension(None, "value", unit="cm") + >>> md.set_metadata(result, changed) + >>> md.get_series_unit(result), md.get_series_unit(series) + ('cm', 'm') + >>> md.read_metadata(result) is changed + True + """ + obj.attrs = {key: value for key, value in obj.attrs.items() if key not in CONVENTION_KEYS} | { + META_KEY: meta + } + + +def attrs_to_json(attrs: Mapping[Any, Any]) -> dict[Any, Any]: + """Replace attached metadata with its JSON fields in a new attrs dictionary. + + Keep unrelated attrs as-is. Omit optional metadata fields set to None or their defaults. + + Args: + attrs: Pandas attrs mapping; left unchanged. + + Returns: + dict[Any, Any]: + Dictionary for json.dumps or export. Unrelated attrs must also be + JSON-compatible. + + Code example: + + Using :ref:`metadata-example-data`, convert attached models to JSON-compatible dictionaries. + This function returns a dictionary; json.dumps produces the JSON string: + + .. doctest:: metadata.attrs_to_json + + >>> import json + >>> data = series.copy() + >>> _ = md.attach_metadata(data) + >>> attrs = md.attrs_to_json(data.attrs) + >>> isinstance(attrs, dict), "hd_metadata" in attrs + (True, False) + >>> encoded = json.dumps(attrs) + >>> json.loads(encoded)["metrics"][0]["value_dimensions"][0]["unit"] + 'm' + >>> export = data.copy() + >>> export.attrs = md.attrs_to_json(export.attrs) + >>> "hd_metadata" in data.attrs, "hd_metadata" in export.attrs + (True, False) + + The export copy can now be written with ``export.to_frame().to_parquet("series.parquet")`` + if a pandas Parquet engine is installed. The original keeps its attached model. + """ + attached = attrs.get(META_KEY) + if not isinstance(attached, _TimeseriesMetadataBase): + return dict(attrs) + rest = {key: value for key, value in attrs.items() if key != META_KEY} + return rest | attached.model_dump(mode="json") diff --git a/src/hdhelpers/metadata/conventions.py b/src/hdhelpers/metadata/conventions.py new file mode 100644 index 0000000..d5d9c5b --- /dev/null +++ b/src/hdhelpers/metadata/conventions.py @@ -0,0 +1,1830 @@ +"""Metadata conventions for timeseries data + +The models in this module specify the hetida designer metadata conventions for the metadata +(attrs) of SERIES, SINGLETSFRAME and MULTITSFRAME objects, one model per data type. See the user +guide page on attached metadata for an introduction. + +Raw metadata is stored as convention fields in pandas attrs. A parsed model can be attached +under attrs["hd_metadata"]; read_metadata accepts both forms. Adapters exchange the raw form, +e.g. in the Data-Attributes header of generic REST adapter timeseries endpoints or the +__metadata__ field of the direct provisioning adapter's wrapped format. Use attrs_to_json +to convert attached models back to raw fields for export. + +The conventions are strict: Metadata adhering to them provides the required fields, possibly as +null if the information is unknown, e.g. the unit of a value dimension. Consumers like components +should nevertheless cope with missing metadata, since validation of metadata received from +adapters is optional. + +The models document the request body of the hetida designer backend endpoint for checking +metadata. Hence they occur as JSON Schemas in the openapi.json of the backend, so that third party +projects, e.g. adapters providing or receiving such metadata, can validate against them. +Additional fields are allowed at every level. The models are immutable, see ConventionModel. +Metadata timestamps are UTC ISO strings with an explicit Z or +00:00 offset. + +check_metadata_conventions additionally checks the rules which JSON Schema cannot express, +optionally against the actual data. +""" + +import datetime +import difflib +import json +import math +import re +from collections import Counter +from collections.abc import Mapping +from enum import StrEnum +from functools import cache, lru_cache +from importlib import resources +from typing import Annotated, Any, ClassVar, Literal, NoReturn, Self + +import pandas as pd +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + GetJsonSchemaHandler, + ModelWrapValidatorHandler, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, + ValidationError, + ValidationInfo, + model_validator, +) +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema, PydanticCustomError + +from hdhelpers.metadata._reading import MetadataReading, id_of + +TimeseriesDataType = Literal["SERIES", "SINGLETSFRAME", "MULTITSFRAME"] + + +class IntervalType(StrEnum): + """Which boundaries belong to a time interval + + closed: both, open: none, left_closed / right_open: only the start, + right_closed / left_open: only the end. + """ + + CLOSED = "closed" + LEFT_CLOSED = "left_closed" + RIGHT_CLOSED = "right_closed" + OPEN = "open" + LEFT_OPEN = "left_open" + RIGHT_OPEN = "right_open" + + +# [0-9] instead of \d, since \d also matches non-ASCII digits in some regex dialects +UTC_TIMESTAMP_PATTERN = ( + r"^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])" + r"T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?(Z|\+00:00)$" +) + + +def _check_point_in_time(timestamp: str) -> str: + """Reject timestamps of the pattern which are no actual points in time, e.g. February 30""" + try: + datetime.datetime.fromisoformat(timestamp) + except ValueError as e: + raise ValueError(f"{timestamp!r} is not an actual point in time: {e}.") from e + return timestamp + + +def _utc_isoformat(value: Any) -> Any: + """A datetime with UTC offset 0 as isoformat string, so that producers can pass datetimes""" + if not isinstance(value, datetime.datetime): + return value + if value.utcoffset() is None: + raise ValueError(f"{value!r} is naive, but needs to be in UTC.") + if value.utcoffset() != datetime.timedelta(0): + raise ValueError(f"{value!r} is not in UTC.") + return value.isoformat() + + +# The pattern specifies the format in the JSON Schema, which cannot express that the date and +# time must exist. Pydantic's datetime type is not used, since it accepts further formats and +# offsets, so that the check would be more lenient than the published JSON Schema. +UtcTimestamp = Annotated[ + str, + Field(pattern=UTC_TIMESTAMP_PATTERN, json_schema_extra={"format": "date-time"}), + AfterValidator(_check_point_in_time), + # last, so that it runs first and the JSON Schema keeps the pattern + BeforeValidator(_utc_isoformat), +] + + +class AggregationMethod(StrEnum): + """Method for aggregating timeseries values into coarser intervals. + + Metadata lookups normalize AVG to MEAN; they are distinct enum values. + """ + + MEAN = "MEAN" + AVG = "AVG" + MEDIAN = "MEDIAN" + SUM = "SUM" + MIN = "MIN" + MAX = "MAX" + FIRST = "FIRST" + LAST = "LAST" + COUNT = "COUNT" + + +class ValueDataType(StrEnum): + """Data type of the values of a value dimension""" + + FLOAT = "float" + INTEGER = "integer" + BOOLEAN = "boolean" + STRING = "string" + + +class RecordingMode(StrEnum): + """When the values of a timeseries are recorded + + periodic: one value per interval of data_frequency, timestamped with the start of the + interval. A missing value is a gap. + on_change: a value is recorded when it changes (by more than the deadband of its value + dimension) and holds until the next one. Missing timestamps are no gaps, as long as + data_frequency (the heartbeat) is not exceeded. + irregular: values at arbitrary points in time. + """ + + PERIODIC = "periodic" + ON_CHANGE = "on_change" + IRREGULAR = "irregular" + + +_FREQUENCY_FORMAT = 'as Pandas date offset alias or timedelta string, e.g. "15min" or "1D"' +_OFFSET_DESCRIPTION = ( + "Offset of the timestamps against the starts of the intervals of {frequency} (see" + ' {timezone}) as timedelta string, e.g. "4min": not negative and smaller than {frequency}. 0' + " if missing or null. Requires {frequency}." +) + +_TIMEZONE_FORMAT = ( + 'as IANA time zone name like "Europe/Berlin" or "UTC" (preferred, since it respects daylight' + ' saving time) or as fixed UTC offset like "+02:00"' +) + +_ALIGNMENT_DESCRIPTION = ( + "Time zone the intervals of {frequency} are aligned to, " + + _TIMEZONE_FORMAT + + ". Without it, the intervals start at the multiples of {frequency} since" + " 1970-01-01T00:00:00Z. With it, for a {frequency} shorter than a day, the intervals restart" + " at each local midnight: They start at local midnight plus multiples of {frequency}, and the" + " last interval of a day ends at the next local midnight, so it may be shorter, e.g. on the" + " day of a daylight saving time change. For a {frequency} of a day or longer, the intervals" + " follow the calendar of the time zone, e.g. days from local midnight to local midnight. The" + " offset shifts the intervals in both cases. Requires {frequency}." +) +_TIMEZONE_PURPOSE = ( + " Components evaluating the timeseries in calendar intervals use it, e.g. a report" + " aggregating days, which start at midnight of this time zone, so the results differ between" + " time zones. This is not the time zone timestamps are displayed in: That is the plot target" + " timezone of the execution context, which depends on the viewer, while this time zone belongs" + " to the data, e.g. the time zone of the plant." +) + + +def _immutable(self: Any, *_args: Any, **_kwargs: Any) -> NoReturn: + raise TypeError(f"{type(self).__name__} is immutable") + + +class FrozenDict(dict[str, Any]): + """A dict which cannot be changed + + Holds dict values of extra fields of the convention models, since the models are shared + between pandas objects instead of being copied. Use it as a read-only mapping, not as a dict: + The builtin frozendict of Python 3.15, which is planned to replace it, is no dict subclass. + """ + + __slots__ = () + __setitem__ = __delitem__ = __ior__ = _immutable # type: ignore[assignment] + clear = pop = popitem = setdefault = update = _immutable # type: ignore[assignment] + + def __copy__(self) -> Self: + return self + + def __deepcopy__(self, memo: dict[int, Any]) -> Self: + return self + + def __reduce__(self) -> tuple[Any, ...]: + return (FrozenDict, (dict(self),)) + + +def _freeze(value: Any) -> Any: + """A JSON value with dicts and lists as FrozenDicts and tuples, recursively""" + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + raise ValueError("Keys of JSON objects must be strings.") + return FrozenDict({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, list | tuple): + return tuple(_freeze(item) for item in value) + if ( + value is None + or isinstance(value, str | int) + or (isinstance(value, float) and math.isfinite(value)) + ): + return value + raise ValueError(f"{value!r} is not a JSON value.") + + +_JsonFloat = Annotated[StrictFloat, Field(allow_inf_nan=False)] + +# Scalars are validated within pydantic-core. Only dicts and lists need Python for freezing them. +_FrozenValue = Annotated[ + StrictStr + | StrictInt + | _JsonFloat + | StrictBool + | None + | Annotated[Any, AfterValidator(_freeze)], + Field(union_mode="left_to_right"), +] + + +def optional(default: Any = None, **kwargs: Any) -> Any: + """Field for an optional value, left out of dumps while it is None or the default""" + return Field(default, exclude_if=lambda value: value is None or value == default, **kwargs) + + +def _item_count(min_length: int, max_length: int | None = None) -> BeforeValidator: + """Check the number of items before validating them + + Pydantic counts the items of a tuple after validating them, so that an invalid item would + also be reported as missing. The JSON Schema states the counts via json_schema_extra. + """ + + def items(count: int) -> str: + return f"{count} item" if count == 1 else f"{count} items" + + def check(value: Any) -> Any: + if isinstance(value, list | tuple): + if len(value) < min_length: + raise PydanticCustomError( + "too_short", f"Should have at least {items(min_length)}, not {len(value)}" + ) + if max_length is not None and len(value) > max_length: + raise PydanticCustomError( + "too_long", f"Should have at most {items(max_length)}, not {len(value)}" + ) + # a tuple, since strict validation after a before validator rejects lists + return tuple(value) + return value + + return BeforeValidator(check) + + +class ConventionModel(BaseModel): + """Base of the models of the metadata conventions + + The models are immutable, including the values of extra fields, so that pandas objects can + share them instead of copying them: pandas deep-copies the attrs of an object for (almost) + every object derived from it. A changed version of a model is a new model, which shares all + unchanged parts with the original. + """ + + # Validation is lax, so that Python input may contain lists for the tuples. Fields whose type + # lax validation would coerce (bool, int, float) use the strict types instead, so that the + # results equal strict validation of JSON input. + model_config = ConfigDict(extra="allow", frozen=True) + __pydantic_extra__: dict[str, _FrozenValue] + + def __deepcopy__(self, memo: dict[int, Any] | None = None) -> Self: + return self + + def model_copy( + self, + *, + update: Mapping[str, Any] | None = None, + deep: bool = False, # noqa: ARG002 + ) -> Self: + """Copy with the given fields replaced, without validating them + + Always shallow, since the model is immutable. A deep copy would apply the update to the + original, since __deepcopy__ returns the original. + """ + return super().model_copy(update=update, deep=False) + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + json_schema = handler(core_schema) + # Any JSON value is allowed in extra fields, as without the typed __pydantic_extra__ + handler.resolve_ref_schema(json_schema)["additionalProperties"] = True + return json_schema + + +class SharedValueDimensionMetadata(ConventionModel): + """Default metadata for a value column shared across metrics. + + Matched by column name ("value" for a SERIES) and used when the corresponding field + is missing from metric-specific metadata. + """ + + column: str = Field( + ..., + description="The actual column name in the MULTITSFRAME or SINGLETSFRAME, connecting the" + ' value dimension to its column. "value" for a SERIES.', + ) + name: str | None = optional( + description='Name of the value dimension, e.g. "temperature". May equal column.' + ) + display_name: str | None = optional( + description="A shorter name for labels, captions or plot titles. Falls back to name.", + ) + short_display_name: str | None = optional( + description="A short name for legends in plots. Falls back to display_name." + ) + description: str | None = optional( + description="A description. Should be considered empty if missing." + ) + unit: str | None = optional( + description='Unit of the values, e.g. "°C" or "m/s", "" for dimensionless quantities', + ) + measurement: str | None = optional(description='The measured quantity, e.g. "temperature"') + value_data_type: ValueDataType | None = optional(description="Data type of the values") + deadband: StrictFloat | None = optional( + ge=0, + allow_inf_nan=False, + description="For a metric with recording_mode on_change: the change of the values, in" + " their unit, which has to be exceeded for a value to be recorded. 0 records every" + " change.", + ) + decimals: StrictInt | None = optional( + ge=0, + description="Number of decimal places numeric values should be displayed with, in their" + " unit, e.g. 2 for 12.35. Only affects how values are displayed, e.g. in tables, tooltips" + " or reports; the values themselves are not rounded. Up to the consumer if missing or" + " null.", + ) + + +class ValueDimensionMetadata(SharedValueDimensionMetadata): + """Metadata for one metric's value column, such as its unit and measured quantity. + + Each metric describes its value columns; a SERIES uses "value". Non-null fields take + precedence over shared metadata for the same column. Unit and measurement are required + fields, but may be None if unknown. + """ + + unit: str | None = Field( + ..., + description='Unit of the values, e.g. "°C" or "m/s". "" for dimensionless quantities, null' + " if unknown.", + ) + measurement: str | None = Field( + ..., description='The measured quantity, e.g. "temperature". Null if unknown.' + ) + + +class InheritedMetricMetadata(ConventionModel): + """Properties supplied by a source system for a metric and its parent assets. + + Stores already-resolved values, e.g. a plant's latitude; the source system resolves the + hierarchy. Metadata lookups use these properties as fallbacks only with + ``include_inherited=True``. Convention-defined fields such as name, id and unit belong in + the metric or value-dimension metadata. + + Use JSON types matching each value's meaning: numbers, booleans and UTC ISO timestamp + strings for points in time. For example, hetida platform supplies properties by label, + using a signal's own value or that of the nearest parent asset providing one. + """ + + +class MetricCommentReply(ConventionModel): + """A reply to a comment attached to a metric""" + + id: str | None = optional(description="Id of the reply, e.g. a UUID") + created_at: UtcTimestamp | None = optional(description="When the reply was created") + edited_at: UtcTimestamp | None = optional( + description="When the reply was last edited. Null or missing if never edited." + ) + content: str | None = optional(description="Free text of the reply") + username: str | None = optional(description="Username of the author of the reply") + + +class MetricComment(ConventionModel): + """A comment attached to a metric, describing a time range of its timeseries data + + E.g. the hetida platform provides the comments on a signal whose time range overlaps the + queried interval and which existed at query time, newest first. + + Start and end of the time range must be provided together. They are equal for a comment on a + single point in time. + """ + + id: str | None = optional(description="Id of the comment, e.g. a UUID") + start_date: UtcTimestamp | None = optional( + description="Start of the time range the comment refers to" + ) + end_date: UtcTimestamp | None = optional( + description="End of the time range the comment refers to" + ) + created_at: UtcTimestamp | None = optional(description="When the comment was created") + edited_at: UtcTimestamp | None = optional( + description="When the comment was last edited. Null or missing if never edited." + ) + content: str | None = optional(description="Free text of the comment") + username: str | None = optional(description="Username of the author of the comment") + tags: tuple[str, ...] | None = optional(description="Free text tags classifying the comment") + replies: tuple[MetricCommentReply, ...] | None = optional( + description="Replies to the comment, oldest first" + ) + + +class MetricMetadata(ConventionModel): + """Metadata of a metric, i.e. of a single, possibly multidimensional timeseries + + A metric is identified by its field named by dataset_metadata.metric_key, whose values occur + in the metric column of a MULTITSFRAME. Every metric must have this field as a string, + unique among the metrics. + """ + + id: str = Field( + ..., + description="Id of the metric, also if another field identifies the metric in the data" + " via dataset_metadata.metric_key. E.g. the hetida platform provides the signal id here.", + ) + name: str = Field(..., description="Long name of the metric") + display_name: str | None = optional( + description="A shorter name for labels, captions or plot titles" + ) + short_display_name: str | None = optional(description="A short name for legends in plots") + description: str | None = optional(description="A one sentence description") + external_id: str | None = optional( + description="Unique id / key in external source systems. Often used to identify the" + " metric in the metric column, i.e. as metric_key.", + ) + signal_id: str | None = optional(description="Actual platform signal id") + channel_id: str | None = optional( + description="Actual platform channel id. Deprecated, since the platform calls channels" + " signals now.", + deprecated='Use "signal_id" instead.', + ) + value_dimensions: Annotated[tuple[ValueDimensionMetadata, ...], _item_count(1)] = Field( + ..., + json_schema_extra={"minItems": 1}, + description="Metadata of the value dimensions of this metric: an entry for each value" + ' column of the data, i.e. exactly the value dimension "value" for a SERIES. Each column' + " may occur at most once.", + ) + default_aggregation_method: AggregationMethod | None = optional( + description="How the timeseries should be aggregated by default, e.g. when components" + " plot or report it in coarser intervals. If missing or null: LAST if the value_data_type" + ' of the value dimension "value" is boolean or string, otherwise MEAN. AVG is an alias' + " of MEAN. E.g. the hetida platform provides the method it aggregates the signal with when" + " bucketing it, for a signal group member the one configured for the member.", + ) + default_timezone: str | None = optional( + description="Time zone the timeseries should be evaluated in by default, " + + _TIMEZONE_FORMAT + + "." + + _TIMEZONE_PURPOSE + + " Falls back to dataset_metadata.default_timezone.", + ) + path: tuple[str, ...] | None = optional( + description="Location of the metric in the hierarchy of its source system: the names of" + " the levels from the top down to the object the metric belongs to, without the metric" + ' itself, e.g. ["Plant A", "Pickling Unit", "Influx"]. Corresponds to the breadcrumb' + " path of the sources of generic REST adapters.", + ) + origin: str | None = optional( + description="Where the values come from, as free text of the source system, e.g." + ' "OPC_UA", "MQTT" or "FORM" for values entered manually.', + ) + recording_mode: RecordingMode | None = optional( + description="When the values are recorded. Unknown if missing or null. periodic requires" + " data_frequency (or dataset_metadata.ref_data_frequency).", + ) + data_frequency: str | None = optional( + description="Interval in which values of the metric are recorded " + + _FREQUENCY_FORMAT + + ": For recording_mode periodic the interval between subsequent values, for on_change" + " the interval after which the value is recorded again at the latest, also without a" + " change (heartbeat). Together with data_frequency_offset and data_frequency_timezone it" + " takes precedence over ref_data_frequency, ref_data_frequency_offset and" + " ref_data_frequency_timezone of the dataset metadata for this metric.", + ) + data_frequency_offset: str | None = optional( + description=_OFFSET_DESCRIPTION.format( + frequency="data_frequency", timezone="data_frequency_timezone" + ), + ) + data_frequency_timezone: str | None = optional( + description=_ALIGNMENT_DESCRIPTION.format(frequency="data_frequency") + ) + recording_aggregation_method: AggregationMethod | None = optional( + description="Only for recording_mode periodic: how each value aggregates the values of" + " the interval [timestamp, timestamp + data_frequency), e.g. MEAN for an average or MAX" + " for a maximum. Null or missing if the values are samples, e.g. the value holding at" + " the timestamp.", + ) + inherited: InheritedMetricMetadata | None = optional() + comments: tuple[MetricComment, ...] | None = optional( + description="Comments attached to the metric, describing situations in its timeseries data", + ) + + +class SignalGroupMetadata(ConventionModel): + """A group of signals a dataset was read from + + E.g. the hetida platform provides the signal group a MULTITSFRAME was read from. + """ + + id: str | None = optional(description="Id of the signal group") + name: str | None = optional(description="Name of the signal group") + description: str | None = optional(description="Description of the signal group") + + +REF_INTERVAL_FIELDS = ( + "ref_interval_start_timestamp", + "ref_interval_end_timestamp", + "ref_interval_type", +) + +# JSON Schema of the rule that the ref interval is required unless ref_dataset_discrete is true +_REF_INTERVAL_REQUIRED_SCHEMA: dict[str, Any] = { + "if": { + "properties": {"ref_dataset_discrete": {"const": True}}, + "required": ["ref_dataset_discrete"], + }, + "else": { + "required": list(REF_INTERVAL_FIELDS), + "properties": {field: {"type": "string"} for field in REF_INTERVAL_FIELDS}, + }, +} + + +class _DatasetMetadataBase(ConventionModel): + model_config = ConfigDict(json_schema_extra=_REF_INTERVAL_REQUIRED_SCHEMA) + + metric_key: str = Field( + ..., + description="Which field of the metric objects under metrics identifies a metric, i.e." + ' occurs in the metric column of a MULTITSFRAME, e.g. "id" or "external_id".', + ) + queried_metrics: tuple[str, ...] = Field( + ..., + description="The requested metrics. Since not all queried metrics may have data in the" + " queried interval, a metric may be missing in the data.", + ) + signal_group: SignalGroupMetadata | None = optional() + ref_dataset_discrete: StrictBool | None = optional( + False, + description="Whether the ref dataset should be considered as discrete, i.e. isolated" + " datapoints instead of an interval. If true, the ref interval is not required.", + ) + ref_interval_start_timestamp: UtcTimestamp | None = optional( + description="Start of the queried time interval (ref interval) in UTC isoformat." + " Required unless ref_dataset_discrete is true.", + ) + ref_interval_end_timestamp: UtcTimestamp | None = optional( + description="End of the queried time interval (ref interval) in UTC isoformat." + " Required unless ref_dataset_discrete is true.", + ) + ref_interval_type: IntervalType | None = optional( + description="Type of the ref interval. Required unless ref_dataset_discrete is true.", + ) + ref_data_frequency: str | None = optional( + description="Frequency of the timeseries " + + _FREQUENCY_FORMAT + + ": the data_frequency of all metrics without their own, with the same meaning as" + " there. Together with ref_data_frequency_offset and ref_data_frequency_timezone.", + ) + ref_data_frequency_offset: str | None = optional( + description=_OFFSET_DESCRIPTION.format( + frequency="ref_data_frequency", timezone="ref_data_frequency_timezone" + ) + + " Applies to all metrics without their own data_frequency.", + ) + ref_data_frequency_timezone: str | None = optional( + description=_ALIGNMENT_DESCRIPTION.format(frequency="ref_data_frequency") + + " Applies to all metrics without their own data_frequency.", + ) + query_timestamp: UtcTimestamp | None = optional( + description="The point in time whose state of the data was delivered in UTC isoformat:" + " Data stored, changed or invalidated later is not included. A source supporting it" + " delivers the same data again when queried for this point in time, e.g. for" + " reproducing an execution.", + ) + default_aggregation_interval: str | None = optional( + description="Interval to aggregate the timeseries to by default, e.g. when components" + " plot or report them, " + + _FREQUENCY_FORMAT + + ". The metrics are aggregated with their default_aggregation_method. E.g. a hetida" + " platform signal group is configured with such an interval.", + ) + default_timezone: str | None = optional( + description="Time zone the timeseries should be evaluated in by default, " + + _TIMEZONE_FORMAT + + "." + + _TIMEZONE_PURPOSE + + " Applies to all metrics without their own default_timezone.", + ) + invalidation_interval_start: UtcTimestamp | None = optional( + description="Start of an invalidation interval separate from the ref interval in UTC" + " isoformat", + ) + invalidation_interval_end: UtcTimestamp | None = optional( + description="End of an invalidation interval separate from the ref interval in UTC" + " isoformat", + ) + invalidation_interval_type: IntervalType | None = optional( + IntervalType.CLOSED, description="Type of the invalidation interval" + ) + invalidate_dataset: StrictBool | None = optional( + description="Whether existing data should be invalidated: in the invalidation interval if" + " specified, otherwise in the ref interval. If neither is specified, a closed ref interval" + " from the first to the last datapoint is inferred. Without datapoints no invalidation can" + " occur then. What null means is up to the adapter.", + ) + delete_invalidated: StrictBool | None = optional( + description="Whether data to be invalidated should be deleted instead. What null means is" + " up to the adapter. Adapters do not need to support deletion.", + ) + invalidation_timestamp: UtcTimestamp | None = optional( + description="Invalidation timestamp for all invalidated datapoints. If null, the" + " execution_timestamp query parameter sent to generic REST adapters or, if that is not" + " present either, the current time should be used.", + ) + only_invalidate: StrictBool | None = optional( + False, + description="If true, existing data should only be invalidated and the new data should" + " not be stored. Cannot be true if invalidate_dataset is false.", + ) + new_data_invalidation_date: UtcTimestamp | None = optional( + description="An invalidation date to be stored together with all new datapoints, i.e." + " invalidating them in advance. Ignored if null.", + ) + + +class SingleMetricDatasetMetadata(_DatasetMetadataBase): + """Metadata associated with a SERIES or SINGLETSFRAME as a whole + + E.g. the queried time interval or how existing data should be handled when the dataset is + sent to an adapter. The fields concerning invalidation and deletion are only relevant for + adapters actually supporting these operations, like the built-in SQL adapter. Other adapters + may ignore them. + + Start and end of an interval must be provided together. + """ + + single_metric: str = Field( + ..., description="The metric_key field value of the single metric under metrics." + ) + + +class MultiTSFrameDatasetMetadata(_DatasetMetadataBase): + """Metadata associated with a MULTITSFRAME as a whole + + E.g. the queried time interval or how existing data should be handled when the dataset is + sent to an adapter. The fields concerning invalidation and deletion are only relevant for + adapters actually supporting these operations, like the built-in SQL adapter. Other adapters + may ignore them. + + Start and end of an interval must be provided together. + """ + + single_metric: str | None = optional( + description="If the MULTITSFRAME holds a single metric: its metric_key field value. Must" + " not be set if more than one metric is present.", + ) + + +class _TimeseriesMetadataBase(MetadataReading, ConventionModel): + """Base of the root models, the metadata of a SERIES, SINGLETSFRAME or MULTITSFRAME + + The methods of MetadataReading look up information with the fallbacks of the conventions. + The with_* methods return changed versions, which share all unchanged parts with this one. + """ + + _data_type: ClassVar[TimeseriesDataType] + + value_dimensions_shared: tuple[SharedValueDimensionMetadata, ...] | None = optional( + description="Metadata of value dimensions that have the same meaning for all metrics." + " Consumers use it as fallback, but it does not replace the value dimensions of the" + " metrics. Each column may occur at most once.", + ) + + @model_validator(mode="wrap") + @classmethod + def _conforms( + cls, data: Any, handler: ModelWrapValidatorHandler[Self], info: ValidationInfo + ) -> Self: + """Reject metadata violating the rules of the conventions which JSON Schema cannot express + + check_metadata_conventions reports these errors itself, at their exact locations. + """ + model = handler(data) + if (info.context or {}).get(_SKIP_RULES) is True: + return model + errors = _rule_errors(data, model, cls._data_type) + if len(errors) > 0: + raise MetadataConventionError(errors) + return model + + def model_copy(self, *, update: Mapping[str, Any] | None = None, deep: bool = False) -> Self: + copied = super().model_copy(update=update, deep=deep) + copied.__dict__.pop("_metrics_by_id", None) # the metrics may differ + return copied + + def _converted[T: "_TimeseriesMetadataBase"](self, model: type[T], **fields: Any) -> T: + """The metadata as model with the given fields, keeping the extra fields""" + return model.model_validate(self.__pydantic_extra__ | fields) + + def with_metric(self, metric_id: str | None = None, /, **changes: Any) -> Self: + """A version with fields of a metric changed, the other metrics are shared + + The changed metric is validated, including the rules of the conventions which concern it. + """ + old = self.metric(metric_id) + new = type(old).model_validate(old.model_dump() | changes) + errors = _metric_rule_errors(self, new) + new_id = id_of(new, self.dataset_metadata.metric_key) + if new_id != id_of(old, self.dataset_metadata.metric_key) and new_id in self.metric_ids(): + errors.append(_error("metrics", f'Metric "{new_id}" occurs more than once.')) + if len(errors) > 0: + raise MetadataConventionError(errors) + return self.model_copy( + update={"metrics": tuple(new if metric is old else metric for metric in self.metrics)} + ) + + def with_value_dimension( + self, metric_id: str | None = None, column: str = "value", /, **changes: Any + ) -> Self: + """A version with fields of a value dimension of a metric changed, see with_metric""" + metric = self.metric(metric_id) + if column not in {value_dimension.column for value_dimension in metric.value_dimensions}: + raise KeyError(column) + return self.with_metric( + metric_id, + value_dimensions=tuple( + value_dimension.model_dump() | changes + if value_dimension.column == column + else value_dimension + for value_dimension in metric.value_dimensions + ), + ) + + def with_dataset_metadata(self, **changes: Any) -> Self: + """A version with fields of the dataset metadata changed, the metrics are shared + + Validated including all rules of the conventions, since the dataset metadata concerns + all metrics. + """ + old = self.dataset_metadata + changed = self.model_copy( + update={"dataset_metadata": type(old).model_validate(old.model_dump() | changes)} + ) + errors = _rule_errors(None, changed, self._data_type) + if len(errors) > 0: + raise MetadataConventionError(errors) + return changed + + +def _value_dimension(column: str, unit: str | None, measurement: str | None) -> dict[str, Any]: + return {"column": column, "unit": unit, "measurement": measurement} + + +SERIES_METADATA_EXAMPLE: dict[str, Any] = { + "dataset_metadata": { + "metric_key": "id", + "single_metric": "7ab8c419-8745-4714-b588-57c0c772cc95", + "queried_metrics": ["7ab8c419-8745-4714-b588-57c0c772cc95"], + "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + "ref_interval_type": "right_open", + "query_timestamp": "2026-01-02T00:05:00+00:00", + }, + "metrics": [ + { + "id": "7ab8c419-8745-4714-b588-57c0c772cc95", + "name": "Water level north", + "value_dimensions": [_value_dimension("value", "m", "level") | {"decimals": 2}], + "default_aggregation_method": "MEAN", + "default_timezone": "Europe/Berlin", + "path": ["Waterworks North", "Gauge North"], + "origin": "OPC_UA", + "recording_mode": "periodic", + "data_frequency": "15min", + "recording_aggregation_method": "MEAN", + } + ], +} + +SINGLETSFRAME_METADATA_EXAMPLE: dict[str, Any] = { + "dataset_metadata": { + "metric_key": "external_id", + "single_metric": "abc.temp", + "queried_metrics": ["abc.temp"], + "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + "ref_interval_type": "closed", + }, + "metrics": [ + { + "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "external_id": "abc.temp", + "name": "ABC temperature", + "value_dimensions": [ + _value_dimension("value", "°C", "temperature"), + _value_dimension("state", None, None) | {"name": "measurement state"}, + ], + } + ], +} + +MULTITSFRAME_METADATA_EXAMPLE: dict[str, Any] = { + "dataset_metadata": { + "metric_key": "external_id", + "queried_metrics": ["plant_a.pressure", "plant_a.temperature"], + "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + "ref_interval_type": "closed", + "default_aggregation_interval": "1h", + "default_timezone": "Europe/Berlin", + }, + "metrics": [ + { + "id": "c5a8e2f1-0b3d-4e6a-9f7c-1d2e3f4a5b6c", + "external_id": "plant_a.pressure", + "name": "Plant A pressure", + "value_dimensions": [_value_dimension("value", "bar", "pressure")], + }, + { + "id": "8d7c6b5a-4f3e-4d2c-8b1a-0f9e8d7c6b5a", + "external_id": "plant_a.temperature", + "name": "Plant A temperature", + "value_dimensions": [ + _value_dimension("value", "°C", "temperature") | {"deadband": 0.5, "decimals": 1} + ], + "default_aggregation_method": "MAX", + "recording_mode": "on_change", + "data_frequency": "1h", + }, + ], +} + +_SINGLE_METRIC_DESCRIPTION = ( + "Metadata of the single metric: exactly one metric, which has the field named by" + " dataset_metadata.metric_key with the value of dataset_metadata.single_metric." +) + + +# the metric id field set when a metric gets another id, if the metric key field is taken +_METRIC_NAME_FIELD = "metric" + + +def _renamed[T: SharedValueDimensionMetadata]( + value_dimensions: tuple[T, ...] | None, column: str, new_column: str +) -> tuple[T, ...] | None: + """Only the value dimensions of column, renamed to new_column, None if there are none""" + renamed = tuple( + value_dimension.model_copy(update={"column": new_column}) + for value_dimension in value_dimensions or () + if value_dimension.column == column + ) + return renamed if len(renamed) > 0 else None + + +def _with_value_column(metric: MetricMetadata, column: str, new_column: str) -> MetricMetadata: + """The metric with only the value dimension of column, renamed to new_column""" + return metric.model_copy( + update={"value_dimensions": _renamed(metric.value_dimensions, column, new_column) or ()} + ) + + +class SeriesMetadata(_TimeseriesMetadataBase): + """Immutable metadata for a SERIES: one metric with a single "value" dimension. + + Read from a pandas Series with read_metadata. See the module documentation for storage, + adapter exchange and common field rules. + """ + + model_config = ConfigDict(json_schema_extra={"examples": [SERIES_METADATA_EXAMPLE]}) + + _data_type: ClassVar[TimeseriesDataType] = "SERIES" + + dataset_metadata: SingleMetricDatasetMetadata + metrics: Annotated[tuple[MetricMetadata, ...], _item_count(1, 1)] = Field( + ..., + json_schema_extra={"minItems": 1, "maxItems": 1}, + description=_SINGLE_METRIC_DESCRIPTION, + ) + + def as_singletsframe(self, column: str = "value") -> "SingleTSFrameMetadata": + """The metadata of the SINGLETSFRAME with the values of the SERIES in column + + Renames the value dimension "value" of the metric and the shared one to column. + """ + return self._converted( + SingleTSFrameMetadata, + dataset_metadata=self.dataset_metadata, + metrics=(_with_value_column(self.metric(), "value", column),), + value_dimensions_shared=_renamed(self.value_dimensions_shared, "value", column), + ) + + +class SingleTSFrameMetadata(_TimeseriesMetadataBase): + """Immutable metadata for a SINGLETSFRAME: one metric with one or more value columns. + + Read from a pandas DataFrame with read_metadata. See the module documentation for storage, + adapter exchange and common field rules. + """ + + model_config = ConfigDict(json_schema_extra={"examples": [SINGLETSFRAME_METADATA_EXAMPLE]}) + + _data_type: ClassVar[TimeseriesDataType] = "SINGLETSFRAME" + + dataset_metadata: SingleMetricDatasetMetadata + metrics: Annotated[tuple[MetricMetadata, ...], _item_count(1, 1)] = Field( + ..., + json_schema_extra={"minItems": 1, "maxItems": 1}, + description=_SINGLE_METRIC_DESCRIPTION, + ) + + def as_series(self, column: str = "value") -> SeriesMetadata: + """The metadata of the SERIES with the values of column + + Keeps only the value dimensions of column, renamed to "value". KeyError if the metric + has no value dimension of column. + """ + if column not in {vd.column for vd in self.metric().value_dimensions}: + raise KeyError(column) + return self._converted( + SeriesMetadata, + dataset_metadata=self.dataset_metadata, + metrics=(_with_value_column(self.metric(), column, "value"),), + value_dimensions_shared=_renamed(self.value_dimensions_shared, column, "value"), + ) + + def as_multitsframe(self, metric_id: str | None = None) -> "MultiTSFrameMetadata": + """The metadata of the MULTITSFRAME of the single metric, named metric_id if given + + If metric_id differs from the id of the metric, it becomes its id: in the field named by + the metric key if the metric does not have it, otherwise in the field "metric", which + then becomes the metric key. + """ + metric = self.metric() + dataset_metadata = self.dataset_metadata.model_dump() + if metric_id is not None and metric_id != self.metric_ids()[0]: + metric_key = dataset_metadata["metric_key"] + if id_of(metric, metric_key) is not None: + metric_key = dataset_metadata["metric_key"] = _METRIC_NAME_FIELD + metric = type(metric).model_validate(metric.model_dump() | {metric_key: metric_id}) + metric_id = id_of(metric, dataset_metadata["metric_key"]) + return self._converted( + MultiTSFrameMetadata, + dataset_metadata=dataset_metadata + | {"single_metric": metric_id, "queried_metrics": [metric_id]}, + metrics=(metric,), + value_dimensions_shared=self.value_dimensions_shared, + ) + + +class MultiTSFrameMetadata(_TimeseriesMetadataBase): + """Immutable metadata for a MULTITSFRAME: multiple metrics identified by a metric column. + + Each metric can have several value dimensions; a MULTITSFRAME may also hold one metric. + Read from a pandas DataFrame with read_metadata. See the module documentation for storage, + adapter exchange and common field rules. + """ + + model_config = ConfigDict(json_schema_extra={"examples": [MULTITSFRAME_METADATA_EXAMPLE]}) + + _data_type: ClassVar[TimeseriesDataType] = "MULTITSFRAME" + + dataset_metadata: MultiTSFrameDatasetMetadata + metrics: tuple[MetricMetadata, ...] = Field( + ..., + description="Metadata of the metrics: one for each metric in the data. Each metric must" + " have the field named by dataset_metadata.metric_key as a string, unique among the" + " metrics.", + ) + + def narrowed_to(self, metric_id: str) -> SingleTSFrameMetadata: + """The metadata of the SINGLETSFRAME of one metric, KeyError if it has no metadata + + The metric is shared, not copied. It becomes the single and only queried metric. + """ + return self._converted( + SingleTSFrameMetadata, + dataset_metadata=self.dataset_metadata.model_dump() + | {"single_metric": metric_id, "queried_metrics": [metric_id]}, + metrics=(self.metric(metric_id),), + value_dimensions_shared=self.value_dimensions_shared, + ) + + +METADATA_MODELS: dict[str, type[_TimeseriesMetadataBase]] = { + "SERIES": SeriesMetadata, + "SINGLETSFRAME": SingleTSFrameMetadata, + "MULTITSFRAME": MultiTSFrameMetadata, +} + + +class MetadataIssueSeverity(StrEnum): + """Severity of an issue found when checking metadata against the metadata conventions + + error: Violates the conventions, so helpers and components may misinterpret the metadata. + warning: Likely a mistake, e.g. metadata not matching the data. + """ + + ERROR = "error" + WARNING = "warning" + + +class MetadataConventionIssue(BaseModel): + """A metadata validation problem, reported as an error or warning. + + Attributes: + severity (MetadataIssueSeverity): Error for a violation; warning for a likely mistake. + location (str): Path to the affected field, e.g. "metrics[0].name"; empty for the + metadata as a whole. + message (str): Explanation of the problem. + """ + + severity: MetadataIssueSeverity + location: str = Field( + ..., + description="Where the issue occurs, e.g. metrics[0].value_dimensions[1].column in the" + " checked metadata. Empty for the checked object as a whole.", + ) + message: str + + +class MetadataConventionError(ValueError): + """Raised when required metadata is missing, invalid or of the wrong type. + + Attributes: + issues (list[MetadataConventionIssue]): Individual problems with severity, location + and message. + """ + + def __init__(self, issues: list[MetadataConventionIssue]) -> None: + self.issues = issues + super().__init__( + "Violates the metadata conventions: " + + _listing([f"{issue.location}: {issue.message}" for issue in issues], 5) + ) + + +def _error(location: str, message: str) -> MetadataConventionIssue: + return MetadataConventionIssue( + severity=MetadataIssueSeverity.ERROR, location=location, message=message + ) + + +def _warning(location: str, message: str) -> MetadataConventionIssue: + return MetadataConventionIssue( + severity=MetadataIssueSeverity.WARNING, location=location, message=message + ) + + +def _location(loc: tuple[int | str, ...]) -> str: + """Format a pydantic error location like metrics[0].value_dimensions[1].column""" + location = "" + for part in loc: + if isinstance(part, int): + location += f"[{part}]" + else: + location += f".{part}" if location != "" else part + return location + + +def _listing(items: list[str], max_items: int = 10) -> str: + listed = ", ".join(f'"{item}"' for item in items[:max_items]) + if len(items) > max_items: + listed += f" and {len(items) - max_items} more" + return listed + + +def _check_duplicate_columns( + value_dimensions: list[dict[str, Any]], location: str +) -> list[MetadataConventionIssue]: + counts = Counter(value_dimension["column"] for value_dimension in value_dimensions) + return [ + _error(location, f'Value dimension column "{column}" occurs more than once.') + for column, count in counts.items() + if count > 1 + ] + + +def _check_interval( + obj: dict[str, Any], location: str, start_key: str, end_key: str +) -> list[MetadataConventionIssue]: + """Check an interval given by start and end fields of the object at location""" + start = obj.get(start_key) + end = obj.get(end_key) + if (start is None) != (end is None): + missing_key = start_key if start is None else end_key + return [ + _error( + f"{location}.{missing_key}", + f"Start and end of an interval must be provided together, but {missing_key}" + " is missing.", + ) + ] + if start is not None and end is not None and pd.Timestamp(start) > pd.Timestamp(end): + return [_error(f"{location}.{start_key}", f"Start is after end {end}.")] + return [] + + +@lru_cache(maxsize=1024) +def _is_frequency(value: str) -> bool: + """Whether value is a Pandas date offset alias or a timedelta string + + A unit is required, since e.g. "1" would be a timedelta string of 1 nanosecond. + """ + if re.search("[A-Za-z]", value) is None: + return False + try: + pd.tseries.frequencies.to_offset(value) + except ValueError: + try: + # e.g. "nan" gives NaT + return bool(pd.notna(pd.to_timedelta(value))) + except ValueError: + return False + return True + + +@lru_cache(maxsize=1024) +def _duration(value: str) -> pd.Timedelta | None: + """The fixed duration of a frequency, None if calendar based like "MS" or no frequency""" + try: + offset = pd.tseries.frequencies.to_offset(value) + except ValueError: + try: + # e.g. "P1D" or "1 days" + duration = pd.to_timedelta(value) + except ValueError: + return None + return None if pd.isna(duration) else duration + if isinstance(offset, pd.offsets.Day): + # a calendar day since pandas 3, i.e. 24 hours in UTC + return pd.Timedelta(days=offset.n) + if isinstance(offset, pd.offsets.Tick): + return pd.Timedelta(offset) + # calendar based, e.g. months + return None + + +@lru_cache(maxsize=1024) +def _offset_count(value: str) -> int: + """The count n of a calendar based frequency, e.g. 3 for "3MS\"""" + return int(pd.tseries.frequencies.to_offset(value).n) + + +def _not_a_frequency(location: str, value: str) -> MetadataConventionIssue: + return _error( + location, + f'"{value}" is not a Pandas date offset alias or timedelta string, e.g. "15min".', + ) + + +def _check_frequency( + obj: dict[str, Any], field: str, location: str +) -> list[MetadataConventionIssue]: + """Check that a frequency field is a positive frequency""" + value = obj.get(field) + if value is None: + return [] + if not _is_frequency(value): + return [_not_a_frequency(f"{location}.{field}", value)] + duration = _duration(value) + positive = duration > pd.Timedelta(0) if duration is not None else _offset_count(value) > 0 + return [] if positive else [_error(f"{location}.{field}", f'"{value}" is not positive.')] + + +def _check_offset( + obj: dict[str, Any], frequency_field: str, offset_field: str, location: str +) -> list[MetadataConventionIssue]: + """Check the offset of the timestamps against the intervals of a valid frequency""" + offset = obj[offset_field] + offset_location = f"{location}.{offset_field}" + if not _is_frequency(offset): + return [_not_a_frequency(offset_location, offset)] + offset_duration = _duration(offset) + if offset_duration is None or offset_duration < pd.Timedelta(0): + return [ + _error(offset_location, f'"{offset}" is not a non-negative timedelta, e.g. "4min".') + ] + # None for calendar based frequencies, which offsets cannot be compared with + frequency_duration = _duration(obj[frequency_field]) + if frequency_duration is not None and offset_duration >= frequency_duration: + return [ + _error( + offset_location, + f'"{offset}" is not smaller than {frequency_field} "{obj[frequency_field]}".', + ) + ] + return [] + + +def _check_frequency_with_alignment( + obj: dict[str, Any], frequency_field: str, offset_field: str, timezone_field: str, location: str +) -> list[MetadataConventionIssue]: + """Check a frequency and the fields aligning its intervals, the offset and the time zone + + They are only meaningful together with the frequency on the same level, since the frequency + of a metric falls back to the one of the dataset metadata as a whole. + """ + issues = _check_frequency(obj, frequency_field, location) + valid_frequency = len(issues) == 0 and obj.get(frequency_field) is not None + for field in (offset_field, timezone_field): + if obj.get(field) is not None and obj.get(frequency_field) is None: + issues.append(_error(f"{location}.{field}", f"Requires {frequency_field}.")) + issues += _check_timezone(obj, location, timezone_field) + if obj.get(offset_field) is not None and valid_frequency: + issues += _check_offset(obj, frequency_field, offset_field, location) + return issues + + +_UTC_OFFSET_PATTERN = re.compile(r"^[+-](0[0-9]|1[0-4]):[0-5][0-9]$") + + +@cache +def _timezone_names() -> frozenset[str]: + """The IANA time zone names of the tzdata package, independent of the system + + Not zoneinfo.available_timezones, which also lists the files of the system's time zone + database, e.g. "localtime" on Debian or Ubuntu, which is no time zone name. + """ + with resources.files("tzdata").joinpath("zones").open(encoding="utf-8") as zones: + return frozenset(line.strip() for line in zones if line.strip() != "") + + +def _is_timezone(value: str) -> bool: + """Whether value is an IANA time zone name or a fixed UTC offset, e.g. "+02:00" """ + return _UTC_OFFSET_PATTERN.match(value) is not None or value in _timezone_names() + + +def _check_timezone( + obj: dict[str, Any], location: str, field: str = "default_timezone" +) -> list[MetadataConventionIssue]: + timezone = obj.get(field) + if timezone is None or _is_timezone(timezone): + return [] + return [ + _error( + f"{location}.{field}", + f'"{timezone}" is neither an IANA time zone name like "Europe/Berlin" nor a UTC' + ' offset like "+02:00".', + ) + ] + + +def _check_recording( + metric: dict[str, Any], dataset_metadata: dict[str, Any], location: str +) -> list[MetadataConventionIssue]: + """Check that the recording information of a metric fits its recording mode""" + issues = [] + recording_mode = metric.get("recording_mode") + if ( + metric.get("recording_aggregation_method") is not None + and recording_mode != RecordingMode.PERIODIC + ): + issues.append( + _error( + f"{location}.recording_aggregation_method", + "Only meaningful for recording_mode periodic.", + ) + ) + if ( + recording_mode == RecordingMode.PERIODIC + and metric.get("data_frequency") is None + and dataset_metadata.get("ref_data_frequency") is None + ): + issues.append( + _error( + f"{location}.recording_mode", + "periodic requires data_frequency or dataset_metadata.ref_data_frequency, the" + " interval of the values.", + ) + ) + if recording_mode != RecordingMode.ON_CHANGE: + issues += [ + _warning( + f"{location}.value_dimensions[{vd_index}].deadband", + "Only meaningful if the recording_mode of the metric is on_change.", + ) + for vd_index, value_dimension in enumerate(metric["value_dimensions"]) + if value_dimension.get("deadband") is not None + ] + return issues + + +def _known_fields(model: type[BaseModel]) -> set[str]: + return set(model.model_fields) + + +# hints for fields placed where the conventions do not expect them +_MISPLACED_FIELD_HINTS: dict[tuple[type[BaseModel], str], str] = { + (MetricMetadata, "unit"): 'Provide the unit of the value dimension "value" under' + " value_dimensions instead.", + (MetricMetadata, "measurement"): 'Provide the measurement of the value dimension "value"' + " under value_dimensions instead.", +} + + +def _unknown_field_issues( + obj: dict[str, Any], model: type[BaseModel], location: str, ignored: set[str] | None = None +) -> list[MetadataConventionIssue]: + """Warnings for fields not defined by the conventions, which may be misspelled""" + known = _known_fields(model) + issues = [] + for field in obj: + if field in known or (ignored is not None and field in ignored): + continue + hint = _MISPLACED_FIELD_HINTS.get((model, field)) + if hint is None: + snake_case = re.sub(r"(? 0 + else "Additional fields are allowed, but make sure it is not misspelled." + ) + issues.append( + _warning( + f"{location}.{field}" if location != "" else field, + f'Unknown field "{field}". {hint}', + ) + ) + return issues + + +def _dicts(value: Any) -> list[tuple[int, dict[str, Any]]]: + """The dicts in value with their index, if value is a list""" + if not isinstance(value, list): + return [] + return [(index, item) for index, item in enumerate(value) if isinstance(item, dict)] + + +def _check_unknown_fields( + attrs: Any, model: type[_TimeseriesMetadataBase] +) -> list[MetadataConventionIssue]: + """Warnings for fields not defined by the conventions + + Also applied to structurally invalid metadata as far as possible, since a misspelled field + often explains a missing required one. + """ + if not isinstance(attrs, dict): + return [] + issues = _unknown_field_issues(attrs, model, "") + + dataset_metadata = attrs.get("dataset_metadata") + metric_key = None + if isinstance(dataset_metadata, dict): + metric_key = dataset_metadata.get("metric_key") + dataset_metadata_model = model.model_fields["dataset_metadata"].annotation + assert isinstance(dataset_metadata_model, type) # for mypy # noqa: S101 + issues += _unknown_field_issues( + dataset_metadata, dataset_metadata_model, "dataset_metadata" + ) + if isinstance(dataset_metadata.get("signal_group"), dict): + issues += _unknown_field_issues( + dataset_metadata["signal_group"], + SignalGroupMetadata, + "dataset_metadata.signal_group", + ) + + for index, value_dimension in _dicts(attrs.get("value_dimensions_shared")): + issues += _unknown_field_issues( + value_dimension, SharedValueDimensionMetadata, f"value_dimensions_shared[{index}]" + ) + for index, metric in _dicts(attrs.get("metrics")): + location = f"metrics[{index}]" + # the field identifying the metric may be any field + issues += _unknown_field_issues(metric, MetricMetadata, location, ignored={str(metric_key)}) + for vd_index, value_dimension in _dicts(metric.get("value_dimensions")): + issues += _unknown_field_issues( + value_dimension, ValueDimensionMetadata, f"{location}.value_dimensions[{vd_index}]" + ) + for comment_index, comment in _dicts(metric.get("comments")): + comment_location = f"{location}.comments[{comment_index}]" + issues += _unknown_field_issues(comment, MetricComment, comment_location) + for reply_index, reply in _dicts(comment.get("replies")): + issues += _unknown_field_issues( + reply, MetricCommentReply, f"{comment_location}.replies[{reply_index}]" + ) + return issues + + +_DEPRECATED_METRIC_FIELDS = { + name: field.deprecated + for name, field in MetricMetadata.model_fields.items() + if field.deprecated is not None +} + + +def _check_consistency(attrs: dict[str, Any]) -> list[MetadataConventionIssue]: + """Check the rules of the conventions which the JSON Schema cannot express + + Expects attrs to be valid against the JSON Schema of the data type. + """ + issues: list[MetadataConventionIssue] = [] + dataset_metadata = attrs["dataset_metadata"] + metrics = attrs["metrics"] + metric_key = dataset_metadata["metric_key"] + + metric_ids = [] + for index, metric in enumerate(metrics): + metric_id = metric.get(metric_key) + if not isinstance(metric_id, str): + issues.append( + _error( + f"metrics[{index}]", + f'Metric has no string field "{metric_key}", which identifies metrics' + " according to dataset_metadata.metric_key.", + ) + ) + else: + metric_ids.append(metric_id) + issues += [ + _warning(f"metrics[{index}].{name}", f"Deprecated. {deprecated}") + for name, deprecated in _DEPRECATED_METRIC_FIELDS.items() + if metric.get(name) is not None + ] + issues += _check_duplicate_columns( + metric["value_dimensions"], f"metrics[{index}].value_dimensions" + ) + issues += _check_frequency_with_alignment( + metric, + "data_frequency", + "data_frequency_offset", + "data_frequency_timezone", + f"metrics[{index}]", + ) + issues += _check_timezone(metric, f"metrics[{index}]") + issues += _check_recording(metric, dataset_metadata, f"metrics[{index}]") + for comment_index, comment in enumerate(metric.get("comments") or []): + issues += _check_interval( + comment, f"metrics[{index}].comments[{comment_index}]", "start_date", "end_date" + ) + issues += [ + _error("metrics", f'Metric "{metric_id}" occurs more than once.') + for metric_id, count in Counter(metric_ids).items() + if count > 1 + ] + issues += _check_duplicate_columns( + attrs.get("value_dimensions_shared") or [], "value_dimensions_shared" + ) + + single_metric = dataset_metadata.get("single_metric") + if single_metric is not None and len(metrics) > 1: + issues.append( + _error( + "dataset_metadata.single_metric", + f"Must not be set, since {len(metrics)} metrics are present under metrics.", + ) + ) + elif single_metric is not None and len(metric_ids) == 1 and single_metric not in metric_ids: + issues.append( + _error( + "dataset_metadata.single_metric", + f'"{single_metric}" is not the "{metric_key}" of the metric under metrics.', + ) + ) + elif single_metric is not None and single_metric not in dataset_metadata["queried_metrics"]: + issues.append(_error("dataset_metadata.single_metric", "Missing in queried_metrics.")) + + if dataset_metadata.get("only_invalidate") is True and ( + dataset_metadata.get("invalidate_dataset") is False + ): + issues.append( + _error( + "dataset_metadata.only_invalidate", + "Cannot be true if invalidate_dataset is false.", + ) + ) + + if dataset_metadata.get("ref_dataset_discrete") is not True: + issues += [ + _error( + f"dataset_metadata.{field}", + "Required unless dataset_metadata.ref_dataset_discrete is true.", + ) + for field in REF_INTERVAL_FIELDS + if dataset_metadata.get(field) is None + ] + issues += _check_interval( + dataset_metadata, + "dataset_metadata", + "ref_interval_start_timestamp", + "ref_interval_end_timestamp", + ) + issues += _check_interval( + dataset_metadata, + "dataset_metadata", + "invalidation_interval_start", + "invalidation_interval_end", + ) + issues += _check_frequency_with_alignment( + dataset_metadata, + "ref_data_frequency", + "ref_data_frequency_offset", + "ref_data_frequency_timezone", + "dataset_metadata", + ) + issues += _check_frequency(dataset_metadata, "default_aggregation_interval", "dataset_metadata") + issues += _check_timezone(dataset_metadata, "dataset_metadata") + return issues + + +_CLOSED_BY_INTERVAL_TYPE: dict[str, Literal["both", "neither", "left", "right"]] = { + "closed": "both", + "open": "neither", + "left_closed": "left", + "right_open": "left", + "right_closed": "right", + "left_open": "right", +} + + +def _check_data_in_ref_interval( + dataset_metadata: dict[str, Any], data: pd.Series | pd.DataFrame +) -> list[MetadataConventionIssue]: + """Warn if the data has timestamps outside of the ref interval""" + start = dataset_metadata.get("ref_interval_start_timestamp") + end = dataset_metadata.get("ref_interval_end_timestamp") + interval_type = dataset_metadata.get("ref_interval_type") + timestamps = data.index if isinstance(data, pd.Series) else data.get("timestamp") + if ( + dataset_metadata.get("ref_dataset_discrete") is True + or start is None + or end is None + or interval_type is None + or pd.Timestamp(start) > pd.Timestamp(end) + or timestamps is None + or len(timestamps) == 0 + or not pd.api.types.is_datetime64_any_dtype(timestamps) + ): + return [] + ref_interval = pd.Interval( + pd.Timestamp(start), pd.Timestamp(end), closed=_CLOSED_BY_INTERVAL_TYPE[interval_type] + ) + # naive timestamps are considered to be UTC + first, last = ( + timestamp if timestamp.tzinfo is not None else timestamp.tz_localize("UTC") + for timestamp in (timestamps.min(), timestamps.max()) + ) + if first in ref_interval and last in ref_interval: + return [] + return [ + _warning( + "dataset_metadata", + f"The data has timestamps outside of the {interval_type} ref interval: The first is" + f" {first.isoformat()}, the last {last.isoformat()}.", + ) + ] + + +def _check_metrics_of_data( + attrs: dict[str, Any], data_type: TimeseriesDataType, data: pd.Series | pd.DataFrame | None +) -> list[MetadataConventionIssue]: + """Check that the metrics of the data and the queried metrics are described""" + issues: list[MetadataConventionIssue] = [] + dataset_metadata = attrs["dataset_metadata"] + # metrics without a string id are reported by _check_consistency + metric_ids = { + metric_id + for metric in attrs["metrics"] + if isinstance(metric_id := metric.get(dataset_metadata["metric_key"]), str) + } + data_metrics: list[str] = [] + if data_type == "MULTITSFRAME" and isinstance(data, pd.DataFrame) and "metric" in data.columns: + data_metrics = [str(metric) for metric in data["metric"].unique()] + if len(missing := sorted(set(data_metrics) - metric_ids)) > 0: + issues.append( + _error( + "metrics", + f"Metrics of the metric column missing under metrics: {_listing(missing)}", + ) + ) + queried_metrics = dataset_metadata["queried_metrics"] + if len(unqueried := sorted(set(data_metrics) - set(queried_metrics))) > 0: + issues.append( + _error( + "dataset_metadata.queried_metrics", + f"Metrics of the metric column missing in queried_metrics:" + f" {_listing(unqueried)}", + ) + ) + # queried metrics occurring in the data are reported as missing under metrics above + undescribed_metrics = sorted( + set(dataset_metadata["queried_metrics"]) - metric_ids - set(data_metrics) + ) + if len(undescribed_metrics) > 0: + issues.append( + _warning( + "metrics", + f"Queried metrics without metadata under metrics: {_listing(undescribed_metrics)}", + ) + ) + return issues + + +def _check_consistency_with_data_type( + attrs: dict[str, Any], data_type: TimeseriesDataType, data: pd.Series | pd.DataFrame | None +) -> list[MetadataConventionIssue]: + """Check the rules specific to the data type and whether the metadata matches the data""" + metrics = attrs["metrics"] + issues = _check_metrics_of_data(attrs, data_type, data) + if data is not None: + issues += _check_data_in_ref_interval(attrs["dataset_metadata"], data) + + # the value columns of frames are only known from their data + value_columns: set[str] | None = None + if data_type == "SERIES": + value_columns = {"value"} + elif isinstance(data, pd.DataFrame): + value_columns = set(data.columns) - ( + {"timestamp", "metric"} if data_type == "MULTITSFRAME" else {"timestamp"} + ) + if value_columns is None: + return issues + + for index, metric in enumerate(metrics): + described_columns = { + value_dimension["column"] for value_dimension in metric["value_dimensions"] + } + if len(undescribed := sorted(value_columns - described_columns)) > 0: + issues.append( + _error( + f"metrics[{index}].value_dimensions", + f"Value columns of the {data_type} without value dimension:" + f" {_listing(undescribed)}", + ) + ) + + value_dimension_lists = [ + (f"metrics[{index}].value_dimensions", metric["value_dimensions"]) + for index, metric in enumerate(metrics) + ] + [("value_dimensions_shared", attrs.get("value_dimensions_shared") or [])] + for location, value_dimensions in value_dimension_lists: + for index, value_dimension in enumerate(value_dimensions): + if value_dimension["column"] not in value_columns: + issues.append( + _warning( + f"{location}[{index}].column", + f'Column "{value_dimension["column"]}" is not a value column of the' + f" {data_type}.", + ) + ) + return issues + + +_SKIP_RULES = "skip_rules_of_metadata_conventions" + + +def _rule_errors( + data: Any, model: _TimeseriesMetadataBase, data_type: TimeseriesDataType +) -> list[MetadataConventionIssue]: + """The errors of the rules which JSON Schema cannot express, for valid structure + + The rules are checked on the input if it is in JSON form, otherwise on the model's dump, e.g. + for input with nested models. + """ + + def errors(attrs: dict[str, Any]) -> list[MetadataConventionIssue]: + issues = _check_consistency(attrs) + _check_consistency_with_data_type( + attrs, data_type, None + ) + return [issue for issue in issues if issue.severity == MetadataIssueSeverity.ERROR] + + if isinstance(data, dict): + try: + return errors(data) + except (AttributeError, KeyError, TypeError): + pass + return errors(model.model_dump(mode="json")) + + +def _metric_rule_errors( + meta: _TimeseriesMetadataBase, metric: MetricMetadata +) -> list[MetadataConventionIssue]: + """The rule errors concerning a single metric, together with the dataset metadata""" + attrs = { + "dataset_metadata": meta.dataset_metadata.model_dump(mode="json"), + "metrics": [metric.model_dump(mode="json")], + } + if meta.value_dimensions_shared is not None: + attrs["value_dimensions_shared"] = [ + value_dimension.model_dump(mode="json") + for value_dimension in meta.value_dimensions_shared + ] + issues = _check_consistency(attrs) + _check_consistency_with_data_type( + attrs, meta._data_type, None + ) + return [issue for issue in issues if issue.severity == MetadataIssueSeverity.ERROR] + + +def check_metadata_conventions( + obj: pd.Series | pd.DataFrame | Mapping[str, Any], + data_type: TimeseriesDataType | None = None, +) -> list[MetadataConventionIssue]: + """Report metadata errors and warnings, optionally checking against pandas data. + + For pandas inputs, also check metric IDs, value columns and time bounds. An empty list + means no issues; warnings alone do not make metadata invalid. + + Args: + obj: Series/DataFrame to check, or a mapping containing metadata. + data_type: + Convention to check; required for mappings. None infers it from pandas input; + see :ref:`metadata-type-selection`. + + Returns: + list[MetadataConventionIssue]: + Issues with severity, location and message. Invalid metadata is reported as + issues. + + Raises: + ValueError: A metadata mapping is provided without data_type. + + Code example: + + Using :ref:`metadata-example-data`, validate the data and its metadata together, or pass + plain metadata with an explicit data type. Issues describe the severity, location and cause: + + .. doctest:: metadata.check_metadata_conventions + + >>> md.check_metadata_conventions(series) + [] + >>> md.check_metadata_conventions(series.attrs, "SERIES") + [] + >>> invalid = series.copy() + >>> invalid.attrs["metrics"][0]["default_timezone"] = "Mars/Base" + >>> issues = md.check_metadata_conventions(invalid) + >>> [(issue.severity.value, issue.location) for issue in issues] + [('error', 'metrics[0].default_timezone')] + >>> print(issues[0].message) # doctest: +ELLIPSIS + "Mars/Base" is neither an IANA time zone name like "Europe/Berlin" nor a UTC offset... + """ + # imported here, since hdhelpers.metadata.attached imports this module + from hdhelpers.metadata.attached import attrs_to_json # noqa: PLC0415 + + data = obj if isinstance(obj, pd.Series | pd.DataFrame) else None + attrs = data.attrs if data is not None else obj + if isinstance(attrs, Mapping): + # attached metadata in its JSON form + attrs = attrs_to_json(attrs) + if data_type is None and isinstance(data, pd.Series): + data_type = "SERIES" + elif data_type is None and isinstance(data, pd.DataFrame): + data_type = "MULTITSFRAME" if "metric" in data.columns else "SINGLETSFRAME" + elif data_type is None: + raise ValueError("The data type is required for checking metadata without data.") + model = METADATA_MODELS[data_type] + + try: + attrs_json = json.dumps(attrs, allow_nan=False) + except (TypeError, ValueError) as e: + return [ + _error( + "", + f"Metadata is not JSON serializable, so it cannot be exchanged with adapters: {e}", + ) + ] + + try: + model.model_validate_json(attrs_json, context={_SKIP_RULES: True}) + except ValidationError as e: + issues = [] + for error in e.errors(): + message = error["msg"].removeprefix("Value error, ") + if (error.get("ctx") or {}).get("pattern") == UTC_TIMESTAMP_PATTERN: + message = ( + "Should be a UTC isoformat timestamp with explicit offset, i.e. ending with Z" + f" or +00:00, got {error['input']!r}." + ) + elif ( + error["loc"] == ("metrics",) + and isinstance(attrs, Mapping) + and isinstance(attrs.get("metrics"), dict) + ): + message += ( + ". Metrics as a mapping is an older metadata convention, the current one" + " expects a list of metric objects." + ) + issues.append(_error(_location(error["loc"]), message)) + return issues + _check_unknown_fields(json.loads(attrs_json), model) + + json_attrs = json.loads(attrs_json) + return ( + _check_consistency(json_attrs) + + _check_consistency_with_data_type(json_attrs, data_type, data) + + _check_unknown_fields(json_attrs, model) + ) diff --git a/src/hdhelpers/metadata/helpers.py b/src/hdhelpers/metadata/helpers.py index a225895..3f6bf14 100644 --- a/src/hdhelpers/metadata/helpers.py +++ b/src/hdhelpers/metadata/helpers.py @@ -1,1417 +1,885 @@ -"""Helper functions for metadata extraction +"""Functions accessing the metadata of SERIES, SINGLETSFRAMEs and MULTITSFRAMEs -This module provides functions to help extracting information from metadata -provided as .attrs with pandas DataFrame / Series objects following the hetida -designer metadata conventions. +The functions are thin wrappers over the methods of the models of the metadata conventions, +see read_metadata. The methods resolve the fallbacks of the conventions, e.g. a missing display +name falls back to the name, see hdhelpers.metadata._reading. -They properly cascade defaults / fallbacks -and try to provide backwards compatible access to metadata for different versions -of the metadata conventions or simpler metadata structures. +The metadata is the model attached to the object (see attach_metadata), whatever data type it +belongs to, e.g. the metadata of a SINGLETSFRAME on a Series taken from it. Otherwise the +metadata in attrs is parsed as the data type of the function. Metadata which does not follow +the conventions gives no information: None or empty defaultdicts. For a SERIES or SINGLETSFRAME, +the single metric is the one named by dataset_metadata.single_metric, otherwise the only one. + +Fields of the functions taking one can be dotted paths, e.g. "inherited.site". """ import datetime from collections import defaultdict -from typing import Any, cast +from collections.abc import Callable +from typing import Any import pandas as pd -from glom import Coalesce, Spec, glom - -import hdhelpers.metadata._specs as _specs -from hdhelpers.metadata._helpers import ( - check_dataframe, - check_series, - check_series_or_dataframe, - default_timezone_by_metric, - extract_from_metadata, - get_value_dimension_info, - metric_info_by_metric, - series_value_info, - single_metric_default_timezone, - singlets_metric_info, - singlets_value_dimension_info, - singlets_value_dimension_info_with_fallbacks, - spec_not_none, - value_dimension_info_with_fallbacks, -) -from hdhelpers.metadata._recording import ( + +from hdhelpers.metadata._reading import ( + DISPLAY_NAME_FIELDS, + SHORT_DISPLAY_NAME_FIELDS, Recording, - default_aggregation_method_by_metric, - recording_by_metric, - single_metric_default_aggregation_method, - single_metric_recording, + dataset_default_timezone, + dataset_recording, ) +from hdhelpers.metadata.attached import META_KEY, AnyMetadata, infer_data_type, read_metadata +from hdhelpers.metadata.conventions import TimeseriesDataType, _TimeseriesMetadataBase + +_DEFAULT_AGGREGATION_METHOD = "MEAN" + + +def check_dataframe(data: pd.DataFrame) -> None: + if not isinstance(data, pd.DataFrame): + raise TypeError("Input is not a pd.DataFrame") + + +def check_series(series: pd.Series) -> None: + if not isinstance(series, pd.Series): + raise TypeError("Input is not a pd.Series.") + + +def check_series_or_dataframe(data: pd.Series | pd.DataFrame) -> None: + if not isinstance(data, pd.Series) and not isinstance(data, pd.DataFrame): + raise TypeError("Input is not a pd.Series or pd.DataFrame.") + + +def _metadata(data: pd.Series | pd.DataFrame, data_type: TimeseriesDataType) -> AnyMetadata | None: + """The attached metadata of any data type, otherwise the attrs parsed as data_type""" + attached = data.attrs.get(META_KEY) + if isinstance(attached, _TimeseriesMetadataBase): + return attached # type: ignore[return-value] + return read_metadata(data, data_type) + + +def _single_metric(meta: AnyMetadata | None) -> str | None: + """The single metric: dataset_metadata.single_metric if described, otherwise the only one""" + if meta is None: + return None + metric_ids = meta.metric_ids() + single_metric = meta.dataset_metadata.single_metric + if single_metric is not None: + return single_metric if single_metric in metric_ids else None + return metric_ids[0] if len(metric_ids) == 1 else None + + +def _by_metric_by_column( + data: pd.DataFrame, fields: str | tuple[str, ...], include_inherited: bool +) -> defaultdict[str, defaultdict[str, Any]]: + check_dataframe(data) + meta = _metadata(data, "MULTITSFRAME") + infos = ( + {} + if meta is None + else meta.value_dimension_infos(fields, include_inherited=include_inherited) + ) + return defaultdict( + lambda: defaultdict(lambda: None), + {metric: defaultdict(lambda: None, info) for metric, info in infos.items()}, + ) + + +def _single_metric_by_column( + data: pd.DataFrame, fields: str | tuple[str, ...], include_inherited: bool +) -> defaultdict[str, Any]: + check_dataframe(data) + meta = _metadata(data, "SINGLETSFRAME") + metric = _single_metric(meta) + if meta is None or metric is None: + return defaultdict(lambda: None) + return defaultdict( + lambda: None, + meta.value_dimension_infos(fields, include_inherited=include_inherited)[metric], + ) + + +def _series_value(series: pd.Series, fields: str | tuple[str, ...], include_inherited: bool) -> Any: + check_series(series) + meta = _metadata(series, "SERIES") + metric = _single_metric(meta) + if meta is None or metric is None: + return None + return meta.value_dimension_info(fields, metric, "value", include_inherited=include_inherited) + + +def _single_metric_or_dataset( + data: pd.Series | pd.DataFrame, + data_type: TimeseriesDataType, + of_metric: Callable[[AnyMetadata, str], Any], + of_dataset: Callable[[AnyMetadata], Any], + empty: Any, +) -> Any: + """Information of the single metric, of the dataset metadata if the metric is not found""" + meta = _metadata(data, data_type) + if meta is None: + return empty + metric = _single_metric(meta) + return of_dataset(meta) if metric is None else of_metric(meta, metric) + + +# value dimensions of MULTITSFRAMEs def get_units( multitsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, defaultdict[str, str | None]]: - """Gets unit of value dimensions in MTSF metrics from Metadata + """Read units for each metric and value column. Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + multitsframe: DataFrame with per-metric metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - dict[str, dict[str | None] | None]: Dictionary of metrics containing the names of the value dimensions. - If the short display name of the value_dimension is not present it returns the result of get_value_dimension_info(). - - Raises: - TypeError: If `multitsframe` is not a DataFrame. + defaultdict[str, defaultdict[str, str | None]]: + Units as result[metric_id][column]; unavailable units are None. Code example: .. doctest:: metadata.get_units - >>> attr = { - ... "by_metric": { - ... "metric1": { - ... "value_dimensions": { - ... "value_dim_1": { - ... "unit": "m" - ... } - ... } - ... }, - ... "metric3": { - ... "value_dimensions": { - ... "value_dim_1": { - ... "unit": None, - ... } - ... } - ... } - ... } - ... } - >>> dataframe.attrs = attr - >>> result = get_units(dataframe) - >>> result["metric1"]['value_dim_1'] - 'm' - >>> result["metric3"]['value_dim_1'] is None - True - >>> result["metric2"]['value_dim_1'] is None + >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE + >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE + >>> units = md.get_units(multitsframe) + >>> units["plant_a.temperature"]["value"] + '°C' + >>> units["unknown metric"]["value"] is None True - """ - check_dataframe(multitsframe) - return get_value_dimension_info(multitsframe, _specs.UNIT, include_inherited) + See :ref:`metadata-getter-values` for shared examples and return shapes. + """ # noqa: E501 + return _by_metric_by_column(multitsframe, "unit", include_inherited) def get_names( multitsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, defaultdict[str, str | None]]: - """Gets names of the MTSF metrics from the metadata + """Read metadata names for each metric and value column. + + For the "value" column, try the metric's name after the column's own name. Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + multitsframe: DataFrame with per-metric metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - dict[str, str | None]: Dictionary of metrics containing the names. If the name is not present for a metric the corresponding value is None. - - Raises: - TypeError: If `multitsframe` is not a DataFrame. + defaultdict[str, defaultdict[str, str | None]]: + Names as result[metric_id][column]; unavailable names are None. Code example: .. doctest:: metadata.get_names - >>> attr = { "by_metric": { "metric1": {"metric": {"name": "name_of_metric1"}}, - ... "metric2": {"metric": {"name": None }} }} - >>> dataframe.attrs = attr - >>> result = get_names(dataframe) - >>> result["metric1"]["value"] - 'name_of_metric1' - >>> result["metric2"]["value"] is None - True - - Lets try another MTSF format: + >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE + >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE + >>> md.get_names(multitsframe)["plant_a.pressure"]["value"] + 'Plant A pressure' - .. doctest:: metadata.get_names - - >>> attr = { "dataset_metadata": {"metric_key": "external_id"}, - ... "metrics": [{"external_id": "ruhr-temperature", - ... "name": "Ruhr temperature [°C]", - ... "display_name": "temperature [°C]", - ... "short_display_name": "[°C]", - ... "value_dimensions": [{"column": "temp", "measurement": "temperature", "unit": "°C"}]}]} - >>> dataframe.attrs = attr - >>> result = get_names(dataframe) - >>> result["ruhr-temperature"]["value"] - 'Ruhr temperature [°C]' + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - - check_dataframe(multitsframe) - return get_value_dimension_info(multitsframe, _specs.NAME, include_inherited) + return _by_metric_by_column(multitsframe, "name", include_inherited) def get_display_names( multitsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, defaultdict[str, str | None]]: - """Gets display names of the MTSF metrics from the metadata + """Read display labels for each metric and value column. + + Use display_name, then name. Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + multitsframe: DataFrame with per-metric metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, defaultdict[str, str | None]]: Dictionary of metrics containing the display names. - If the display name of the metrics is not present it returns the result of :func:`hdhelpers.metadata.get_names`. - For metadata following the current convention, the display name is looked up on the value dimension, on - the metric (for the value dimension "value") and in "value_dimensions_shared" before falling back to the - name. Fields provided as null count as missing. - - Raises: - TypeError: If `multitsframe` is not a DataFrame. - - Code example: + defaultdict[str, defaultdict[str, str | None]]: + Labels as result[metric_id][column]; unavailable labels are None. - .. doctest:: metadata.get_display_names - - >>> attr = { "by_metric": { "metric1": {"metric": {"display_name": "display_name_of_metric1"}}, - ... "metric2": {"metric": {"name": "name_of_metric2"}}}} - >>> dataframe.attrs = attr - >>> result = get_display_names(dataframe) - >>> result["metric1"]["value"] - 'display_name_of_metric1' - >>> result["metric2"]["value"] - 'name_of_metric2' - - Lets try another MTSF format: - - .. doctest:: metadata.get_display_names - - >>> attr = { "dataset_metadata": {"metric_key": "external_id"}, - ... "metrics": [{"external_id": "ruhr-temperature", - ... "name": "Ruhr temperature [°C]", - ... "display_name": "temperature [°C]", - ... "short_display_name": "[°C]", - ... "value_dimensions": [{"column": "temp", "measurement": "temperature", "unit": "°C"}]}]} - >>> dataframe.attrs = attr - >>> result = get_display_names(dataframe) - >>> result["ruhr-temperature"]["value"] - 'temperature [°C]' + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - - check_dataframe(multitsframe) - return value_dimension_info_with_fallbacks( - multitsframe, _specs.DISPLAY_NAME_FIELDS, _specs.DISPLAY_NAME, include_inherited - ) + return _by_metric_by_column(multitsframe, DISPLAY_NAME_FIELDS, include_inherited) def get_short_display_names( multitsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, defaultdict[str, str | None]]: - """Gets short display names of the MTSF metrics from the metadata + """Read short display labels for each metric and value column. + + Use short_display_name, then display_name, then name. Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + multitsframe: DataFrame with per-metric metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, defaultdict[str, str | None]]: Dictionary of metrics containing the short display names. - If the short display name of the metrics is not present it returns the result of :func:`hdhelpers.metadata.get_display_names`. - For metadata following the current convention, each of short display name, display name and name is - looked up on the value dimension, on the metric (for the value dimension "value") and in - "value_dimensions_shared" before falling back to the next one. Fields provided as null count as missing. + defaultdict[str, defaultdict[str, str | None]]: + Labels as result[metric_id][column]; unavailable labels are None. - Raises: - TypeError: If `multitsframe` is not a DataFrame. - - Code example: - - .. doctest:: metadata.get_short_display_names - - >>> attr = { "by_metric": { "metric1": {"metric": {"short_display_name": "short_display_name_of_metric1"}}, - ... "metric2": {"metric": {"name": "name_of_metric2"}}, - ... "metric3": {"metric": {"name": None}} }} - >>> dataframe.attrs = attr - >>> result = get_short_display_names(dataframe) - >>> result["metric1"]["value"] - 'short_display_name_of_metric1' - >>> result["metric2"]["value"] - 'name_of_metric2' - >>> result["metric3"]["value"] is None - True - - Lets try another MTSF format: - - .. doctest:: metadata.get_short_display_names - - >>> attr = { "dataset_metadata": {"metric_key": "external_id"}, - ... "metrics": [{"external_id": "ruhr-temperature", - ... "name": "Ruhr temperature [°C]", - ... "display_name": "temperature [°C]", - ... "short_display_name": "temp. [°C]", - ... "value_dimensions": [{"column": "temp", "measurement": "temperature", "unit": "°C"}]}]} - >>> dataframe.attrs = attr - >>> result = get_short_display_names(dataframe) - >>> result["ruhr-temperature"]["value"] - 'temp. [°C]' + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - - check_dataframe(multitsframe) - return value_dimension_info_with_fallbacks( - multitsframe, _specs.SHORT_DISPLAY_NAME_FIELDS, _specs.SHORT_DISPLAY_NAME, include_inherited - ) + return _by_metric_by_column(multitsframe, SHORT_DISPLAY_NAME_FIELDS, include_inherited) def get_measurements( multitsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, defaultdict[str, str | None]]: - """Gets measurement (type) of the MTSF metrics from the metadata + """Read physical quantities for each metric and value column. + + For example, "temperature" is a measurement and "°C" is its unit. Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + multitsframe: DataFrame with per-metric metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, defaultdict[str, str | None]]: Dictionary of metrics containing the measurement (type) of the MTSF metrics. - If the short measurement (type) of the MTSF metrics is not present it returns None. - - Raises: - TypeError: If `multitsframe` is not a DataFrame. - - Code example: - - .. doctest:: metadata.get_measurements - - >>> attr = { "dataset_metadata": {"metric_key": "external_id"}, - ... "metrics": [{"external_id": "column_name", "value_dimensions": [{"column": "temp", "measurement": "temperature"}]}]} - >>> dataframe.attrs = attr - >>> result = get_measurements(dataframe) - >>> result["column_name"]["temp"] - 'temperature' - + defaultdict[str, defaultdict[str, str | None]]: + Quantities as result[metric_id][column]; unavailable quantities are None. + See :ref:`metadata-getter-values` for shared examples and return shapes. """ - check_dataframe(multitsframe) - return get_value_dimension_info(multitsframe, _specs.MEASUREMENT, include_inherited) + return _by_metric_by_column(multitsframe, "measurement", include_inherited) def get_metric_info( - multitsframe: pd.DataFrame, metric_info: str | Spec, *, include_inherited: bool = True + multitsframe: pd.DataFrame, metric_info: str, *, include_inherited: bool = True ) -> defaultdict[str, Any]: - """Gets a dictionary of metadata associated to metrics. + """Read a metadata field for each metric. - In contrast to metadata associated to concrete value dimensions, this - function abstracts access to metadata associated to the underlying metric. - - The information is looked up in the metric itself, then in its value dimension "value", - then in "value_dimensions_shared" and finally in the properties the metric inherits from - objects higher up in an asset hierarchy (given under "inherited" in the metric metadata), - e.g. the latitude of the plant a sensor belongs to. The latter can be switched off via - `include_inherited`. Information provided as null counts as missing. Note that in contrast - to this function, the functions for dedicated information like :func:`get_units` or - :func:`get_display_names` do not fall back to inherited properties by default. + Search the metric, its "value" column, shared "value" metadata, then inherited fields + (if enabled). Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. - metric_info (str | Spec): Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like "sensor.manufacturer" into sub dictionaries or any other glom Spec. See "How to query nested information?" in the first steps. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the metric, its value dimension "value" nor "value_dimensions_shared" provide the information. Defaults to True. + multitsframe: DataFrame with per-metric metadata in attrs. + metric_info: Field name or dotted path, e.g. "sensor.model". + include_inherited: Use inherited fields as a last fallback. Returns: - defaultdict[str, Any]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are the entries specified via "metric_info" in the metrics metadata. Defaults to None, also if there is no metric metadata at all. + defaultdict[str, Any]: Field values by metric ID; unresolved fields are None. Code example: .. doctest:: metadata.get_metric_info - >>> multitsframe.attrs = { - ... "dataset_metadata": { - ... "metric_key": "id" - ... }, - ... "metrics": [ - ... { - ... "id": "first", - ... "external_id": "external_first", - ... "unit": "m", - ... "display_name": "first display name", - ... "sensor": {"manufacturer": "Acme"}, - ... "value_dimensions": [ - ... { - ... "column": "temp", - ... "unit": "C", - ... "measurement": "temperature" - ... } - ... ] - ... }, - ... { - ... "id": "second", - ... "name": "second name", - ... "external_id": "external_second", - ... "value_dimensions": [ - ... { - ... "column": "temp", - ... "unit": "C" - ... } - ... ] - ... } - ... ] - ... } - >>> result = get_metric_info(multitsframe, "external_id") - >>> result["first"] - 'external_first' - >>> result["second"] - 'external_second' - >>> result["not-given"] is None - True - - A dotted path reaches into sub dictionaries: - - .. doctest:: metadata.get_metric_info + >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE + >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE + >>> md.get_metric_info(multitsframe, "recording_mode")["plant_a.temperature"] + 'on_change' - >>> result = get_metric_info(multitsframe, "sensor.manufacturer") - >>> result["first"] - 'Acme' - >>> result["second"] is None - True + See :ref:`metadata-getter-fields` for shared examples and return shapes. """ check_dataframe(multitsframe) - return defaultdict( - lambda: None, metric_info_by_metric(multitsframe, metric_info, include_inherited) + meta = _metadata(multitsframe, "MULTITSFRAME") + infos = ( + {} if meta is None else meta.metric_infos(metric_info, include_inherited=include_inherited) ) + return defaultdict(lambda: None, infos) + + +# the value dimension "value" of SERIES def get_series_info( - series: pd.Series, value_dim_info: str | Spec, *, include_inherited: bool = True + series: pd.Series, value_dim_info: str, *, include_inherited: bool = True ) -> Any: - """Gets an arbitrary series info + """Read a metadata field for this Series. - Since a series has only one value dimension named "value", this information is - equivalent to information on the metric. + Search the "value" column, the metric, shared "value" metadata, then inherited fields + (if enabled). Args: - series (pd.Series): Series with metadata following the convention. - value_dim_info (str | Spec): Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like "sensor.manufacturer" into sub dictionaries or any other glom Spec. See "How to query nested information?" in the first steps. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to True. + series: Series with metadata in attrs. + value_dim_info: Field name or dotted path, e.g. "sensor.model". + include_inherited: Use inherited fields as a last fallback. Returns: - Any: Retrieved information defined by `value_dim_info` + Any: Field value, or None when unavailable. Code example: .. doctest:: metadata.get_series_info - >>> attr = { "by_metric": { "series": {"value_dimensions": {"value": {"unit": "m"}}}}} - >>> series.attrs = attr - >>> get_series_info(series, "unit") - 'm' - >>> get_series_info(series, "not-given") is None - True - """ - - check_series(series) - return _series_info(series, (value_dim_info,), value_dim_info, include_inherited) - - -def _series_info( - series: pd.Series, fields: tuple[str | Spec, ...], legacy_spec: Spec, include_inherited: bool -) -> Any: - """Info of a Series, falling back to further fields - - Since a Series has only one value dimension named "value", it falls back to the metric - metadata. For metadata following the current metadata convention, each field is looked up - on the value dimension, the metric and "value_dimensions_shared" before the next field is - tried, see value_dimension_info_with_fallbacks. Older metadata structures are handled via - legacy_spec, keeping their established behaviour. - """ - from_metrics = series_value_info(series, fields, legacy_spec, include_inherited) - if from_metrics is not None: - return from_metrics - - # compatibility with some older format - return glom( - series.attrs, - Coalesce( - spec_not_none( - ( - "single_metric_metadata.structured_metadata.value_dimensions.value", - legacy_spec, - ) - ), - spec_not_none( - ( - "single_metric_metadata.structured_metadata.metric", - legacy_spec, - ) - ), - default=None, - ), - ) - - -def _series_info_with_fallbacks( - series: pd.Series, fields: tuple[str | Spec, ...], include_inherited: bool -) -> Any: - """Info of a Series, falling back to further fields, which may be provided as null + >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE + >>> series.attrs = SERIES_METADATA_EXAMPLE + >>> md.get_series_info(series, "decimals") + 2 - Older metadata structures keep their established behaviour of using the first of the - fields present on the first level providing any. + See :ref:`metadata-getter-fields` for shared examples and return shapes. """ - legacy_spec = Coalesce(*(spec_not_none(field) for field in fields)) - return _series_info(series, fields, legacy_spec, include_inherited) + return _series_value(series, value_dim_info, include_inherited) def get_series_unit(series: pd.Series, *, include_inherited: bool = False) -> str | None: - """Gets name of the series from metadata + """Read this Series' unit from metadata. Args: - series (pd.Series): Series with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + series: Series with metadata in attrs. + include_inherited: Use inherited fields as a last fallback. Returns: - str | None: - Returns the unit of series. - If the unit of the series is not present it returns None. - - Raises: - TypeError: If `series` is not a Series. - - Let's test what happens if series has no attr: - - .. doctest:: metadata.get_series_unit - - >>> series.attrs = {} - >>> get_series_unit(series) is None - True + str | None: Unit string; "" means dimensionless and None means unavailable. - Let's test what happens if series has attr but no entry for unit: + Code example: .. doctest:: metadata.get_series_unit - >>> attr = { "by_metric": { "series": {"value_dimensions": {"value": {"unit":None}}}}} - >>> series.attrs = attr - >>> get_series_unit(series) is None - True - - Let's test what happens if series has unit in attr: - - .. doctest:: metadata.get_series_unit + >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE + >>> series.attrs = SERIES_METADATA_EXAMPLE + >>> md.get_series_unit(series) + 'm' - >>> attr = { "by_metric": { "series": {"value_dimensions": {"value": {"unit":"m/s"}}}}} - >>> series.attrs = attr - >>> get_series_unit(series) - 'm/s' + See :ref:`metadata-getter-values` for shared examples and return shapes. """ - - check_series(series) - return cast( - str | None, - _series_info_with_fallbacks(series, (_specs.UNIT,), include_inherited), - ) + return _series_value(series, "unit", include_inherited) # type: ignore[no-any-return] def get_series_name(series: pd.Series, *, include_inherited: bool = False) -> str | None: - """Gets name of the series from metadata + """Read this Series' name from metadata. + + Try the "value" column's name, then the metric's name. Args: - series (pd.Series): Series with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + series: Series with metadata in attrs. + include_inherited: Use inherited fields as a last fallback. Returns: - str | None: - Returns the name of the value. - If the name of the metric is not present it returns the name of the value. - If the value name is not present it returns None. - - Raises: - TypeError: If `series` is not a Series. - - Let's test what happens if series has name in value_dimensions: - - .. doctest:: metadata.get_series_name - - >>> attr = { "by_metric": { "series": {"value_dimensions": {"value": {"name": "value_name_of_series"}}}}} - >>> series.attrs = attr - >>> get_series_name(series) - 'value_name_of_series' + str | None: Resolved name, or None when unavailable. - Let's test what happens if series has name in metric: + Code example: .. doctest:: metadata.get_series_name - >>> attr = { "by_metric": { "series": {"metric": {"name": "name_of_series_1"}}}} - >>> series.attrs = attr - >>> get_series_name(series) - 'name_of_series_1' - - Let's test what happens if series has name in metric and value_dimensions: - - .. doctest:: metadata.get_series_name + >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE + >>> series.attrs = SERIES_METADATA_EXAMPLE + >>> md.get_series_name(series) + 'Water level north' - >>> attr = { "single_metric_metadata": { "structured_metadata": {"metric": {"name": "name_of_series_2"}}, - ... "value_dimensions": {"value": {"name": "value_name_of_series"}}}} - >>> series.attrs = attr - >>> get_series_name(series) - 'name_of_series_2' + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - check_series(series) - return cast( - str | None, - _series_info_with_fallbacks(series, ("name",), include_inherited), - ) + return _series_value(series, "name", include_inherited) # type: ignore[no-any-return] def get_series_display_name(series: pd.Series, *, include_inherited: bool = False) -> str | None: - """Gets display name of the series from metadata + """Read the display label for this Series. + + Use display_name, then name. Args: - series (pd.Series): Series with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + series: Series with metadata in attrs. + include_inherited: Use inherited fields as a last fallback. Returns: - str | None: - Returns the display name of the value. - If the display name of the metric is not present it returns the display name of the value. - If the metric display name is not present it returns the result of :func:`hdhelpers.metadata.get_series_name`. - - Raises: - TypeError: If `series` is not a Series. + str | None: Resolved label, or None when unavailable. - Code example: - - .. doctest:: metadata.get_series_display_name - - >>> attr = { "by_metric": { "series": {"metric": {"display_name": "display_name_of_series"}}}} - >>> series.attrs = attr - >>> get_series_display_name(series) - 'display_name_of_series' + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - check_series(series) - return cast( - str | None, - _series_info_with_fallbacks(series, _specs.DISPLAY_NAME_FIELDS, include_inherited), + return _series_value( # type: ignore[no-any-return] + series, DISPLAY_NAME_FIELDS, include_inherited ) def get_series_short_display_name( series: pd.Series, *, include_inherited: bool = False ) -> str | None: - """Gets short display name of the Series from metadata + """Read the short display label for this Series. + + Use short_display_name, then display_name, then name. Args: - series (pd.Series): Series with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + series: Series with metadata in attrs. + include_inherited: Use inherited fields as a last fallback. Returns: - str | None: - Returns the short display name of the value. - If the short display name of the metric is not present it returns the short display name of the value. - If the metric short display name is not present it returns the result of :func:`hdhelpers.metadata.get_series_display_name`. - - Raises: - TypeError: If `series` is not a Series. - - Code example: + str | None: Resolved label, or None when unavailable. - .. doctest:: metadata.get_series_short_display_name - - >>> attr = { "by_metric": { "series": { "metric": {"short_display_name": "short_display_name_of_series"}}}} - >>> series.attrs = attr - >>> get_series_short_display_name(series) - 'short_display_name_of_series' + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - check_series(series) - return cast( - str | None, - _series_info_with_fallbacks(series, _specs.SHORT_DISPLAY_NAME_FIELDS, include_inherited), + return _series_value( # type: ignore[no-any-return] + series, SHORT_DISPLAY_NAME_FIELDS, include_inherited ) def get_series_measurement(series: pd.Series, *, include_inherited: bool = False) -> str | None: - """Gets measurement (type) of the Series from metadata + """Read this Series' physical quantity from metadata. + + For example, "temperature" describes the quantity and "°C" describes its unit. Args: - series (pd.Series): Series with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + series: Series with metadata in attrs. + include_inherited: Use inherited fields as a last fallback. Returns: - str | None: - Returns the measurement (type) of the value. - If "measurement" is not given in the metadata, None is returned. - - Raises: - TypeError: If `series` is not a Series. + str | None: Quantity name, or None when unavailable. - Code example: + See :ref:`metadata-getter-values` for shared examples and return shapes. + """ + return _series_value(series, "measurement", include_inherited) # type: ignore[no-any-return] - .. doctest:: metadata.get_series_measurement - >>> attr = { "by_metric": { "series": { "metric": {"measurement": "temperature"}}}} - >>> series.attrs = attr - >>> get_series_measurement(series) - 'temperature' - """ - check_series(series) - return cast( - str | None, get_series_info(series, "measurement", include_inherited=include_inherited) - ) +# value dimensions of SINGLETSFRAMEs def get_singlets_info( - singletsframe: pd.DataFrame, value_dim_info: str | Spec, *, include_inherited: bool = True -) -> Any: - """Gets an arbitrary info of the value dimensions of a SingleTSFrame - - A SingleTSFrame (STSF) holds exactly one metric - like a Series - but arbitrarily many - value dimensions - like a MultiTSFrame. Therefore, in contrast to the MTSF functions, - the result is keyed by value dimension only and not by metric first. + singletsframe: pd.DataFrame, value_dim_info: str, *, include_inherited: bool = True +) -> defaultdict[str, Any]: + """Read a metadata field for each value column of a single-metric frame. - The single metric is identified via "single_metric" in "dataset_metadata". If that is - missing but the metadata contains exactly one metric, that metric is used, since a STSF - cannot be ambiguous in this respect. + For "value", search column, metric, shared and inherited metadata (if enabled), in that + order. Other columns use their own and shared metadata. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. - value_dim_info (str | Spec): Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like "sensor.manufacturer" into sub dictionaries or any other glom Spec. See "How to query nested information?" in the first steps. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to True. + singletsframe: DataFrame with one metric's metadata in attrs. + value_dim_info: Field name or dotted path, e.g. "sensor.model". + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, Any]: Dictionary of value dimensions containing the retrieved information. If the information is not present for a value dimension the corresponding value is None. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. + defaultdict[str, Any]: Field values as result[column]; unresolved fields are None. Code example: .. doctest:: metadata.get_singlets_info - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, - ... "metrics": [ - ... { - ... "external_id": "abc.temp", - ... "value_dimensions": [ - ... {"column": "value", "unit": "°C"}, - ... {"column": "state"}, - ... ], - ... } - ... ], - ... } - >>> result = get_singlets_info(dataframe, "unit") - >>> result["value"] - '°C' - >>> result["state"] is None - True - """ + >>> from hdhelpers.metadata.conventions import SINGLETSFRAME_METADATA_EXAMPLE + >>> dataframe.attrs = SINGLETSFRAME_METADATA_EXAMPLE + >>> md.get_singlets_info(dataframe, "name")["state"] + 'measurement state' - check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, value_dim_info, include_inherited) + See :ref:`metadata-getter-fields` for shared examples and return shapes. + """ + return _single_metric_by_column(singletsframe, value_dim_info, include_inherited) def get_singlets_units( singletsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, str | None]: - """Gets units of the value dimensions of a SingleTSFrame from metadata + """Read units for each value column of a single-metric frame. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + singletsframe: DataFrame with one metric's metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, str | None]: Dictionary of value dimensions containing the units. If the unit is not present for a value dimension the corresponding value is None. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. + defaultdict[str, str | None]: + Units as result[column]; "" means dimensionless and None means unavailable. - Note that a value dimension falls back to "value_dimensions_shared" if it carries no unit - of its own: + Code example: .. doctest:: metadata.get_singlets_units - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, - ... "metrics": [ - ... { - ... "external_id": "abc.temp", - ... "value_dimensions": [ - ... {"column": "value", "unit": "°C"}, - ... {"column": "state"}, - ... ], - ... } - ... ], - ... "value_dimensions_shared": [{"column": "state", "unit": "UNKNOWN"}], - ... } - >>> result = get_singlets_units(dataframe) - >>> result["value"] + >>> from hdhelpers.metadata.conventions import SINGLETSFRAME_METADATA_EXAMPLE + >>> dataframe.attrs = SINGLETSFRAME_METADATA_EXAMPLE + >>> md.get_singlets_units(dataframe)["value"] '°C' - >>> result["state"] - 'UNKNOWN' - >>> result["not-given"] is None - True - - The "metric_key" is optional and defaults to "id": - - .. doctest:: metadata.get_singlets_units - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp"}, - ... "metrics": [ - ... {"id": "abc.temp", "value_dimensions": [{"column": "value", "unit": "m"}]} - ... ], - ... } - >>> get_singlets_units(dataframe)["value"] - 'm' + See :ref:`metadata-getter-values` for shared examples and return shapes. """ - - check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, _specs.UNIT, include_inherited) + return _single_metric_by_column(singletsframe, "unit", include_inherited) def get_singlets_names( singletsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, str | None]: - """Gets names of the value dimensions of a SingleTSFrame from metadata + """Read metadata names for each value column of a single-metric frame. + + For "value", try the metric's name after the column's own name. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + singletsframe: DataFrame with one metric's metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, str | None]: Dictionary of value dimensions containing the names. If the name is not present for a value dimension the corresponding value is None. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. - - Code example: + defaultdict[str, str | None]: Names as result[column]; unavailable names are None. - .. doctest:: metadata.get_singlets_names - - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, - ... "metrics": [ - ... { - ... "external_id": "abc.temp", - ... "value_dimensions": [ - ... {"column": "value", "name": "temperature"}, - ... {"column": "state"}, - ... ], - ... } - ... ], - ... } - >>> result = get_singlets_names(dataframe) - >>> result["value"] - 'temperature' - >>> result["state"] is None - True + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - - check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, _specs.NAME, include_inherited) + return _single_metric_by_column(singletsframe, "name", include_inherited) def get_singlets_display_names( singletsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, str | None]: - """Gets display names of the value dimensions of a SingleTSFrame from metadata + """Read display labels for each value column of a single-metric frame. - Falls back to the name of the value dimension if no display name is given. The display name - is looked up on the value dimension, on the metric (for the value dimension "value") and in - "value_dimensions_shared" before falling back to the name. Fields provided as null count as - missing. + Use display_name, then name. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + singletsframe: DataFrame with one metric's metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, str | None]: Dictionary of value dimensions containing the display names. If neither display name nor name is present for a value dimension the corresponding value is None. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. + defaultdict[str, str | None]: Labels as result[column]; unavailable labels are None. - Code example: - - .. doctest:: metadata.get_singlets_display_names - - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, - ... "metrics": [ - ... { - ... "external_id": "abc.temp", - ... "value_dimensions": [ - ... {"column": "value", "name": "temperature", "display_name": "temp"}, - ... {"column": "state", "name": "measurement state"}, - ... ], - ... } - ... ], - ... } - >>> result = get_singlets_display_names(dataframe) - >>> result["value"] - 'temp' - >>> result["state"] - 'measurement state' + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - - check_dataframe(singletsframe) - return singlets_value_dimension_info_with_fallbacks( - singletsframe, _specs.DISPLAY_NAME_FIELDS, _specs.DISPLAY_NAME, include_inherited - ) + return _single_metric_by_column(singletsframe, DISPLAY_NAME_FIELDS, include_inherited) def get_singlets_short_display_names( singletsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, str | None]: - """Gets short display names of the value dimensions of a SingleTSFrame from metadata + """Read short display labels for each value column of a single-metric frame. - Falls back to the display name and then to the name of the value dimension if no short - display name is given. Each of them is looked up on the value dimension, on the metric (for - the value dimension "value") and in "value_dimensions_shared" before falling back to the next - one. Fields provided as null count as missing. + Use short_display_name, then display_name, then name. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + singletsframe: DataFrame with one metric's metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, str | None]: Dictionary of value dimensions containing the short display names. If none of short display name, display name and name is present for a value dimension the corresponding value is None. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. - - Code example: + defaultdict[str, str | None]: Labels as result[column]; unavailable labels are None. - .. doctest:: metadata.get_singlets_short_display_names - - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, - ... "metrics": [ - ... { - ... "external_id": "abc.temp", - ... "value_dimensions": [ - ... {"column": "value", "name": "temperature", "short_display_name": "T"}, - ... {"column": "state", "display_name": "state"}, - ... ], - ... } - ... ], - ... } - >>> result = get_singlets_short_display_names(dataframe) - >>> result["value"] - 'T' - >>> result["state"] - 'state' + See :ref:`metadata-getter-names` for shared examples and return shapes. """ - - check_dataframe(singletsframe) - return singlets_value_dimension_info_with_fallbacks( - singletsframe, - _specs.SHORT_DISPLAY_NAME_FIELDS, - _specs.SHORT_DISPLAY_NAME, - include_inherited, - ) + return _single_metric_by_column(singletsframe, SHORT_DISPLAY_NAME_FIELDS, include_inherited) def get_singlets_measurements( singletsframe: pd.DataFrame, *, include_inherited: bool = False ) -> defaultdict[str, str | None]: - """Gets measurements (types) of the value dimensions of a SingleTSFrame from metadata + """Read physical quantities for each value column of a single-metric frame. + + For example, "temperature" is a measurement and "°C" is its unit. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the value dimension, the metric nor "value_dimensions_shared" provide the information. Defaults to False, since this information should be provided for the metric itself. + singletsframe: DataFrame with one metric's metadata in attrs. + include_inherited: Use inherited fields last, only for the "value" column. Returns: - defaultdict[str, str | None]: Dictionary of value dimensions containing the measurements. If "measurement" is not given for a value dimension the corresponding value is None. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. - - Code example: + defaultdict[str, str | None]: + Quantities as result[column]; unavailable quantities are None. - .. doctest:: metadata.get_singlets_measurements - - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, - ... "metrics": [ - ... { - ... "external_id": "abc.temp", - ... "value_dimensions": [ - ... {"column": "value", "measurement": "temperature"}, - ... ], - ... } - ... ], - ... } - >>> get_singlets_measurements(dataframe)["value"] - 'temperature' + See :ref:`metadata-getter-values` for shared examples and return shapes. """ - - check_dataframe(singletsframe) - return singlets_value_dimension_info(singletsframe, _specs.MEASUREMENT, include_inherited) + return _single_metric_by_column(singletsframe, "measurement", include_inherited) def get_singlets_metric_info( - singletsframe: pd.DataFrame, metric_info: str | Spec, *, include_inherited: bool = True + singletsframe: pd.DataFrame, metric_info: str, *, include_inherited: bool = True ) -> Any: - """Gets metadata associated to the single metric of a SingleTSFrame + """Read one metadata field for the frame's single metric. - Counterpart of :func:`get_metric_info` for SingleTSFrames: instead of a dictionary keyed - by metric this directly returns the requested information for the one metric. Like - :func:`get_metric_info`, it falls back to the value dimension "value", to - "value_dimensions_shared" and finally to the properties the metric inherits. + Search the metric, its "value" column, shared "value" metadata, then inherited fields + (if enabled). Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. - metric_info (str | Spec): Information to retrieve, interpreted as a glom Spec: e.g. a field name, a dotted path like "sensor.manufacturer" into sub dictionaries or any other glom Spec. See "How to query nested information?" in the first steps. - include_inherited (bool): Whether to fall back to the properties a metric inherits from objects higher up in an asset hierarchy (given under "inherited" in the metric metadata) as last resort, i.e. if neither the metric, its value dimension "value" nor "value_dimensions_shared" provide the information. Defaults to True. + singletsframe: DataFrame with one metric's metadata in attrs. + metric_info: Field name or dotted path, e.g. "sensor.model". + include_inherited: Use inherited fields as a last fallback. Returns: - Any: Retrieved information defined by `metric_info`, or None if it cannot be determined - which includes the case of absent metric metadata. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. + Any: One field value, or None when unavailable. Code example: - .. doctest:: metadata.get_singlets_metric_info - - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, - ... "metrics": [{"external_id": "abc.temp", "name": "ABC temperature"}], - ... } - >>> get_singlets_metric_info(dataframe, "name") - 'ABC temperature' - >>> get_singlets_metric_info(dataframe, "not-given") is None - True - - Without any metadata None is returned: + Using :ref:`metadata-example-data`, compare a field of the metric with fields of its value + dimensions. See :ref:`metadata-getter-fields` for the related getters: .. doctest:: metadata.get_singlets_metric_info - >>> dataframe.attrs = {} - >>> get_singlets_metric_info(dataframe, "name") is None - True + >>> md.get_singlets_metric_info(dataframe, "name") + 'ABC temperature' + >>> md.get_singlets_info(dataframe, "name")["state"] + 'measurement state' """ - check_dataframe(singletsframe) - return singlets_metric_info(singletsframe, metric_info, include_inherited) + return _single_metric_or_dataset( + singletsframe, + "SINGLETSFRAME", + lambda meta, metric: meta.metric_info( + metric_info, metric, include_inherited=include_inherited + ), + lambda _meta: None, + empty=None, + ) + + +# queried interval, time zones, recording and aggregation def get_queried_interval( data: pd.Series | pd.DataFrame, ) -> tuple[datetime.datetime | None, datetime.datetime | None]: - """Get queried interval from metadata + """Read the requested UTC time interval from dataset metadata. Args: - data (pd.Series | pd.DataFrame): Series or Dataframe with metadata following the convention + data: Series or DataFrame with dataset metadata in attrs. Returns: - tuple[datetime.datetime | None, datetime.datetime | None]: Tuple of available start and end date of requested interval. - - Raises: - ValueError: If metadata of `data` is not None and not convertible to a datetime-object (ISO-format is expected). - TypeError: If `data` is not a Series or Dataframe. + tuple[datetime.datetime | None, datetime.datetime | None]: + (start, end) as timezone-aware datetimes, or (None, None) if unavailable. Code example: .. doctest:: metadata.get_queried_interval - >>> attr = { - ... "dataset_metadata": { - ... "ref_interval_start_timestamp": "2025-11-05T13:28:00Z", - ... "ref_interval_end_timestamp": "2025-11-06T13:28:00Z" - ... } - ... } - >>> series.attrs = attr - >>> start, end = get_queried_interval(series) + >>> from hdhelpers.metadata.conventions import SERIES_METADATA_EXAMPLE + >>> series.attrs = SERIES_METADATA_EXAMPLE + >>> start, end = md.get_queried_interval(series) >>> start.isoformat() - '2025-11-05T13:28:00+00:00' - >>> end.isoformat() - '2025-11-06T13:28:00+00:00' + '2026-01-01T00:00:00+00:00' + + See :ref:`metadata-getter-values` for shared examples and return shapes. """ check_series_or_dataframe(data) - start = extract_from_metadata(data.attrs, key="ref_interval_start_timestamp", default=None) - end = extract_from_metadata(data.attrs, key="ref_interval_end_timestamp", default=None) - - formatted_start = datetime.datetime.fromisoformat(start) if start else None - formatted_end = datetime.datetime.fromisoformat(end) if end else None - - return formatted_start, formatted_end + meta = _metadata(data, infer_data_type(data)) + return (None, None) if meta is None else meta.queried_interval() def get_default_timezones(multitsframe: pd.DataFrame) -> defaultdict[str, str | None]: - """Gets the default time zone of each metric of a MultiTSFrame - - The default time zone is meant for computations: It is the time zone the timeseries should - be evaluated in by default, e.g. for bucketing days when aggregating, so that each day - starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of - the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the - time zone of the plot target settings is for, which e.g. :func:`hdhelpers.helpers.modify_timezone` - uses by default. See "Which time zone to compute in?" in the first steps. - - The time zone is an IANA time zone name like "Europe/Berlin" or a fixed UTC offset like - "+02:00", both accepted by pandas, e.g. by ``tz_convert``. + """Read the time zone recommended for calculations on each metric. - The default time zone of a metric falls back to the one of the dataset metadata, which - applies to all metrics without their own. It does not fall back to value dimensions or - inherited properties. + Use the metric's default_timezone, then the dataset's default_timezone. Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. + multitsframe: DataFrame with per-metric metadata in attrs. Returns: - defaultdict[str, str | None]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are their default time zones. Defaults to the default time zone of the dataset metadata, also for metrics without metadata, and to None if that is missing as well. - - Raises: - TypeError: If `multitsframe` is not a DataFrame. + defaultdict[str, str | None]: + Zone names or UTC offsets by metric ID, or None for unresolved zones. Missing + IDs use the dataset default. Code example: .. doctest:: metadata.get_default_timezones - >>> multitsframe.attrs = { - ... "dataset_metadata": {"metric_key": "id", "default_timezone": "Europe/Berlin"}, - ... "metrics": [ - ... {"id": "new_york_sensor", "default_timezone": "America/New_York"}, - ... {"id": "berlin_sensor"}, - ... ], - ... } - >>> timezones = get_default_timezones(multitsframe) - >>> timezones["new_york_sensor"] - 'America/New_York' - >>> timezones["berlin_sensor"] - 'Europe/Berlin' - >>> timezones["metric_without_metadata"] + >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE + >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE + >>> md.get_default_timezones(multitsframe)["plant_a.pressure"] 'Europe/Berlin' + + See :ref:`metadata-getter-timezones` for shared examples and return shapes. """ check_dataframe(multitsframe) - return default_timezone_by_metric(multitsframe) + meta = _metadata(multitsframe, "MULTITSFRAME") + if meta is None: + return defaultdict(lambda: None) + dataset_timezone = dataset_default_timezone(meta) + return defaultdict(lambda: dataset_timezone, meta.default_timezones()) def get_series_default_timezone(series: pd.Series) -> str | None: - """Gets the default time zone of a Series - - The default time zone is meant for computations: It is the time zone the timeseries should - be evaluated in by default, e.g. for bucketing days when aggregating, so that each day - starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of - the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the - time zone of the plot target settings is for, which e.g. :func:`hdhelpers.helpers.modify_timezone` - uses by default. See "Which time zone to compute in?" in the first steps. + """Read the time zone recommended for calculations on this Series. - The time zone is an IANA time zone name like "Europe/Berlin" or a fixed UTC offset like - "+02:00", both accepted by pandas, e.g. by ``tz_convert``. - - The default time zone of the metric falls back to the one of the dataset metadata. + Use the metric's default_timezone, then the dataset's default_timezone. Args: - series (pd.Series): Series with metadata following the convention. + series: Series with metadata in attrs. Returns: - str | None: The default time zone, or None if neither the metric nor the dataset metadata provide one. - - Raises: - TypeError: If `series` is not a Series. - - Code example: + str | None: Zone name or UTC offset, or None when neither source provides one. - .. doctest:: metadata.get_series_default_timezone - - >>> series.attrs = { - ... "dataset_metadata": {"single_metric": "water.north.level", "metric_key": "external_id"}, - ... "metrics": [{"external_id": "water.north.level", "default_timezone": "Europe/Berlin"}], - ... } - >>> get_series_default_timezone(series) - 'Europe/Berlin' - >>> series.attrs = {} - >>> get_series_default_timezone(series) is None - True + See :ref:`metadata-getter-timezones` for shared examples and return shapes. """ check_series(series) - return single_metric_default_timezone(series) + return _single_metric_or_dataset( # type: ignore[no-any-return] + series, + "SERIES", + lambda meta, metric: meta.default_timezone(metric), + dataset_default_timezone, + empty=None, + ) def get_singlets_default_timezone(singletsframe: pd.DataFrame) -> str | None: - """Gets the default time zone of the single metric of a SingleTSFrame + """Read the time zone recommended for calculations on a single-metric frame. - The default time zone is meant for computations: It is the time zone the timeseries should - be evaluated in by default, e.g. for bucketing days when aggregating, so that each day - starts at midnight of this time zone. It belongs to the data, e.g. it is the time zone of - the plant a sensor belongs to. It is not meant for displaying timestamps - that is what the - time zone of the plot target settings is for, which e.g. :func:`hdhelpers.helpers.modify_timezone` - uses by default. See "Which time zone to compute in?" in the first steps. - - The time zone is an IANA time zone name like "Europe/Berlin" or a fixed UTC offset like - "+02:00", both accepted by pandas, e.g. by ``tz_convert``. - - The default time zone of the metric falls back to the one of the dataset metadata. + Use the metric's default_timezone, then the dataset's default_timezone. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. + singletsframe: DataFrame with one metric's metadata in attrs. Returns: - str | None: The default time zone, or None if neither the metric nor the dataset metadata provide one. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. - - Code example: - - .. doctest:: metadata.get_singlets_default_timezone + str | None: Zone name or UTC offset, or None when neither source provides one. - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "abc.temp", "default_timezone": "+02:00"}, - ... "metrics": [{"id": "abc.temp", "default_timezone": None}], - ... } - >>> get_singlets_default_timezone(dataframe) - '+02:00' + See :ref:`metadata-getter-timezones` for shared examples and return shapes. """ check_dataframe(singletsframe) - return single_metric_default_timezone(singletsframe) + return _single_metric_or_dataset( # type: ignore[no-any-return] + singletsframe, + "SINGLETSFRAME", + lambda meta, metric: meta.default_timezone(metric), + dataset_default_timezone, + empty=None, + ) def get_recordings(multitsframe: pd.DataFrame) -> defaultdict[str, Recording]: - """Gets how the values of each metric of a MultiTSFrame are recorded + """Read recording settings for each metric. - The recording consists of the "recording_mode" of the metric (e.g. "periodic" or - "on_change"), the frequency of its values with offset and time zone and its - "recording_aggregation_method", see :class:`Recording` for what they mean. - - The frequency falls back as a whole: If the metric has a "data_frequency", it applies - together with the metric's "data_frequency_offset" and "data_frequency_timezone". Otherwise - "ref_data_frequency" of the dataset metadata applies together with - "ref_data_frequency_offset" and "ref_data_frequency_timezone". The values are provided as - given in the metadata. + Use the metric's frequency, offset and time zone together. If its frequency is missing, + use the dataset's three fields instead. Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. + multitsframe: DataFrame with per-metric metadata in attrs. Returns: - defaultdict[str, Recording]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are their recordings. Defaults to a recording with the frequency of the dataset metadata, also for metrics without metadata. - - Raises: - TypeError: If `multitsframe` is not a DataFrame. + defaultdict[str, Recording]: + Settings by metric ID; missing IDs use dataset frequency settings. Code example: .. doctest:: metadata.get_recordings - >>> multitsframe.attrs = { - ... "dataset_metadata": {"metric_key": "id", "ref_data_frequency": "1h"}, - ... "metrics": [ - ... { - ... "id": "average_level", - ... "recording_mode": "periodic", - ... "data_frequency": "15min", - ... "recording_aggregation_method": "MEAN", - ... }, - ... {"id": "pump_state", "recording_mode": "on_change"}, - ... ], - ... } - >>> recordings = get_recordings(multitsframe) - >>> recordings["average_level"].data_frequency - '15min' - >>> recordings["pump_state"].recording_mode + >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE + >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE + >>> md.get_recordings(multitsframe)["plant_a.temperature"].recording_mode 'on_change' - >>> recordings["pump_state"].data_frequency - '1h' + + See :ref:`metadata-getter-recordings` for shared examples and return shapes. """ check_dataframe(multitsframe) - return recording_by_metric(multitsframe) + meta = _metadata(multitsframe, "MULTITSFRAME") + if meta is None: + return defaultdict(Recording) + no_metadata = dataset_recording(meta) + return defaultdict(lambda: no_metadata, meta.recordings()) def get_series_recording(series: pd.Series) -> Recording: - """Gets how the values of a Series are recorded + """Read recording settings for this Series. - See :func:`get_recordings` for the recording and how its frequency falls back to the one of - the dataset metadata. + Use the metric's frequency, offset and time zone together. If its frequency is missing, + use the dataset's three fields instead. Args: - series (pd.Series): Series with metadata following the convention. + series: Series with metadata in attrs. Returns: - Recording: The recording of the metric, with the frequency of the dataset metadata if the metric does not provide one. - - Raises: - TypeError: If `series` is not a Series. + Recording: Recording object; absent metadata yields its default field values. - Code example: - - .. doctest:: metadata.get_series_recording - - >>> series.attrs = { - ... "dataset_metadata": {"single_metric": "water.north.level", "metric_key": "external_id"}, - ... "metrics": [ - ... { - ... "external_id": "water.north.level", - ... "recording_mode": "periodic", - ... "data_frequency": "2h", - ... "data_frequency_timezone": "Europe/Berlin", - ... } - ... ], - ... } - >>> recording = get_series_recording(series) - >>> recording.data_frequency, recording.data_frequency_offset, recording.data_frequency_timezone - ('2h', None, 'Europe/Berlin') + See :ref:`metadata-getter-recordings` for shared examples and return shapes. """ check_series(series) - return single_metric_recording(series) + return _single_metric_or_dataset( # type: ignore[no-any-return] + series, + "SERIES", + lambda meta, metric: meta.recording(metric), + dataset_recording, + empty=Recording(), + ) def get_singlets_recording(singletsframe: pd.DataFrame) -> Recording: - """Gets how the values of the single metric of a SingleTSFrame are recorded + """Read recording settings for a single-metric frame. - See :func:`get_recordings` for the recording and how its frequency falls back to the one of - the dataset metadata. + Use the metric's frequency, offset and time zone together. If its frequency is missing, + use the dataset's three fields instead. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. + singletsframe: DataFrame with one metric's metadata in attrs. Returns: - Recording: The recording of the metric, with the frequency of the dataset metadata if the metric does not provide one. - - Raises: - TypeError: If `singletsframe` is not a DataFrame. + Recording: Recording object; absent metadata yields its default field values. - Code example: - - .. doctest:: metadata.get_singlets_recording - - >>> dataframe.attrs = { - ... "dataset_metadata": { - ... "single_metric": "abc.temp", - ... "ref_data_frequency": "5min", - ... "ref_data_frequency_offset": "1min", - ... }, - ... "metrics": [{"id": "abc.temp", "recording_mode": "periodic"}], - ... } - >>> recording = get_singlets_recording(dataframe) - >>> recording.recording_mode, recording.data_frequency, recording.data_frequency_offset - ('periodic', '5min', '1min') + See :ref:`metadata-getter-recordings` for shared examples and return shapes. """ check_dataframe(singletsframe) - return single_metric_recording(singletsframe) + return _single_metric_or_dataset( # type: ignore[no-any-return] + singletsframe, + "SINGLETSFRAME", + lambda meta, metric: meta.recording(metric), + dataset_recording, + empty=Recording(), + ) def get_default_aggregation_methods(multitsframe: pd.DataFrame) -> defaultdict[str, str]: - """Gets the method each metric of a MultiTSFrame should be aggregated with by default + """Read the default aggregation method for each metric. - E.g. for plotting or reporting it in coarser intervals. It is the "default_aggregation_method" - of the metric, with "AVG" normalized to its alias "MEAN". If the metric does not provide one, - it is "LAST" for values of the data type "boolean" or "string" (the "value_data_type" of the - value dimension "value"), i.e. the state holding at the end of the interval, and "MEAN" - otherwise. It is not taken from inherited properties. + Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata + value_data_type selects LAST for boolean/string and MEAN for other types. Args: - multitsframe (pd.DataFrame): MTSF with metadata following the convention. + multitsframe: DataFrame with per-metric metadata in attrs. Returns: - defaultdict[str, str]: Dictionary, where keys are the entries of the metrics metadata specified via "metric_key" in "dataset_metadata" and values are their default aggregation methods, e.g. "MEAN", "SUM" or "LAST". Defaults to "MEAN" for metrics without metadata. - - Raises: - TypeError: If `multitsframe` is not a DataFrame. + defaultdict[str, str]: Method names by metric ID; missing IDs default to MEAN. Code example: .. doctest:: metadata.get_default_aggregation_methods - >>> multitsframe.attrs = { - ... "dataset_metadata": {"metric_key": "id"}, - ... "metrics": [ - ... {"id": "level", "default_aggregation_method": "AVG"}, - ... {"id": "rainfall", "default_aggregation_method": "SUM"}, - ... { - ... "id": "pump_running", - ... "value_dimensions": [{"column": "value", "value_data_type": "boolean"}], - ... }, - ... ], - ... } - >>> methods = get_default_aggregation_methods(multitsframe) - >>> methods["level"], methods["rainfall"], methods["pump_running"] - ('MEAN', 'SUM', 'LAST') - >>> methods["metric_without_metadata"] - 'MEAN' + >>> from hdhelpers.metadata.conventions import MULTITSFRAME_METADATA_EXAMPLE + >>> multitsframe.attrs = MULTITSFRAME_METADATA_EXAMPLE + >>> methods = md.get_default_aggregation_methods(multitsframe) + >>> methods["plant_a.temperature"], methods["plant_a.pressure"] + ('MAX', 'MEAN') + + See :ref:`metadata-getter-aggregation` for shared examples and return shapes. """ check_dataframe(multitsframe) - return default_aggregation_method_by_metric(multitsframe) + meta = _metadata(multitsframe, "MULTITSFRAME") + methods = {} if meta is None else meta.default_aggregation_methods() + return defaultdict(lambda: _DEFAULT_AGGREGATION_METHOD, methods) def get_series_default_aggregation_method(series: pd.Series) -> str: - """Gets the method a Series should be aggregated with by default + """Read the default aggregation method for this Series. - See :func:`get_default_aggregation_methods` for the method and its default. + Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata + value_data_type selects LAST for boolean/string and MEAN for other types. Args: - series (pd.Series): Series with metadata following the convention. + series: Series with metadata in attrs. Returns: - str: The default aggregation method, e.g. "MEAN", "SUM" or "LAST". - - Raises: - TypeError: If `series` is not a Series. - - Code example: + str: Method name; MEAN if metadata is unavailable. - .. doctest:: metadata.get_series_default_aggregation_method - - >>> series.attrs = { - ... "dataset_metadata": {"single_metric": "rainfall"}, - ... "metrics": [{"id": "rainfall", "default_aggregation_method": "SUM"}], - ... } - >>> get_series_default_aggregation_method(series) - 'SUM' - >>> series.attrs = {} - >>> get_series_default_aggregation_method(series) - 'MEAN' + See :ref:`metadata-getter-aggregation` for shared examples and return shapes. """ check_series(series) - return single_metric_default_aggregation_method(series) + return _single_metric_or_dataset( # type: ignore[no-any-return] + series, + "SERIES", + lambda meta, metric: meta.default_aggregation_method(metric), + lambda _meta: _DEFAULT_AGGREGATION_METHOD, + empty=_DEFAULT_AGGREGATION_METHOD, + ) def get_singlets_default_aggregation_method(singletsframe: pd.DataFrame) -> str: - """Gets the method the single metric of a SingleTSFrame should be aggregated with by default + """Read the default aggregation method for a single-metric frame. - See :func:`get_default_aggregation_methods` for the method and its default. + Use explicit default_aggregation_method (AVG becomes MEAN). Otherwise, metadata + value_data_type selects LAST for boolean/string and MEAN for other types. Args: - singletsframe (pd.DataFrame): STSF with metadata following the convention. + singletsframe: DataFrame with one metric's metadata in attrs. Returns: - str: The default aggregation method, e.g. "MEAN", "SUM" or "LAST". - - Raises: - TypeError: If `singletsframe` is not a DataFrame. - - Code example: + str: Method name; MEAN if metadata is unavailable. - .. doctest:: metadata.get_singlets_default_aggregation_method - - >>> dataframe.attrs = { - ... "dataset_metadata": {"single_metric": "valve"}, - ... "metrics": [ - ... { - ... "id": "valve", - ... "value_dimensions": [{"column": "value", "value_data_type": "string"}], - ... } - ... ], - ... } - >>> get_singlets_default_aggregation_method(dataframe) - 'LAST' + See :ref:`metadata-getter-aggregation` for shared examples and return shapes. """ check_dataframe(singletsframe) - return single_metric_default_aggregation_method(singletsframe) - + return _single_metric_or_dataset( # type: ignore[no-any-return] + singletsframe, + "SINGLETSFRAME", + lambda meta, metric: meta.default_aggregation_method(metric), + lambda _meta: _DEFAULT_AGGREGATION_METHOD, + empty=_DEFAULT_AGGREGATION_METHOD, + ) -def without_metadata[T: (pd.Series, pd.DataFrame)](data: T) -> T: - """Gets a copy of a Series or DataFrame without its metadata, to compute on - pandas deep-copies the metadata (``attrs``) of a Series or DataFrame to every object an - operation creates from it, e.g. when filtering rows, selecting a column or grouping. For - metadata of many metrics, e.g. with comments, loops over the metrics then take much longer - than the computation itself. Compute on the copy without metadata instead and attach the - metadata of the result once at the end. +def copy_without_metadata[T: (pd.Series, pd.DataFrame)](data: T) -> T: + """Copy a Series or DataFrame with all attrs removed. - The copy is cheap: It shares the data with ``data`` until one of them is changed (copy on - write), so changing the copy does not change ``data``. + Data is shared until either object is modified (pandas copy on write). Args: - data (pd.Series | pd.DataFrame): Series or DataFrame, e.g. a MTSF with metadata. + data (pd.Series | pd.DataFrame): Source object; left unchanged. Returns: - pd.Series | pd.DataFrame: Copy of ``data`` with empty ``attrs``. + pd.Series | pd.DataFrame: Copy with empty attrs. Code example: - .. doctest:: metadata.without_metadata + .. doctest:: metadata.copy_without_metadata >>> multitsframe.attrs = {"dataset_metadata": {"metric_key": "id"}, "metrics": []} - >>> data = without_metadata(multitsframe) + >>> data = md.copy_without_metadata(multitsframe) >>> data.attrs {} >>> multitsframe.attrs diff --git a/src/hdhelpers/plot_target_settings.py b/src/hdhelpers/plot_target_settings.py index 08a4623..f4a0529 100644 --- a/src/hdhelpers/plot_target_settings.py +++ b/src/hdhelpers/plot_target_settings.py @@ -28,6 +28,8 @@ class StatusColors(BaseModel): class PlotTargetStyle(BaseModel): + """Display colors for plot backgrounds, axes, grids, traces and status indicators.""" + axes_label_color: str | None = Field( None, description="Color of the tick labels of all axes as a hex code" ) @@ -50,15 +52,10 @@ class PlotTargetStyle(BaseModel): class PlotTargetSettings(BaseModel): - """Settings that plot components can/should use - - Some Plotly settings like locale or the timezone of timestamps must be set - by Python and cannot easily be set by plotly.js in a frontend. - - They can be provided to execution endpoints as part of the ExecByIdBase payload, - are made accessible to components using the execution context. + """Display preferences for plot components: time zone, locale, colors and datetime axes. - hdhelpers provides helper functions to access them at runtime. + Provided through the execution context and read with ``get_plot_target_settings``. + Components use the settings when preparing plots for the target frontend. """ plot_target_timezone: str | None = Field( @@ -101,7 +98,7 @@ def get_plot_target_settings() -> PlotTargetSettings: ) plot_target_settings_in_context: BaseModel = get_runtime_exec_context().plot_target_settings - # Ensure that plot_target_settings is compatible with hdhelpers version of PlotTargetSettings + # Ensure that plot_target_settings is compatible with the PlotTargetSettings of hdhelpers plot_target_settings_as_dict = plot_target_settings_in_context.model_dump(mode="json") plot_target_settings = PlotTargetSettings(**plot_target_settings_as_dict) return plot_target_settings diff --git a/src/hdhelpers/plotting/color.py b/src/hdhelpers/plotting/color.py index 3050101..9fc48c7 100644 --- a/src/hdhelpers/plotting/color.py +++ b/src/hdhelpers/plotting/color.py @@ -40,17 +40,13 @@ def resolve_color(color_str: str) -> str: - """Resolves Fuseki Colors and returns resulting color string - - Non-fuseki (color) strings are returned just as they are. + """Resolve a Fuseki palette name to a color string. Args: - color_str (str): Color string like "#ea1255", "orange" or "ki.shade" (Fuseki color) + color_str: Palette name, e.g. "ki.energy", or any other string. Returns: - str: - Returns the resolved color string. - + str: Resolved color; other strings are returned unchanged. Code example: diff --git a/src/hdhelpers/plotting/plotly_theme.py b/src/hdhelpers/plotting/plotly_theme.py index 17d8278..20f883c 100644 --- a/src/hdhelpers/plotting/plotly_theme.py +++ b/src/hdhelpers/plotting/plotly_theme.py @@ -11,32 +11,32 @@ plot_bgcolor="rgba(128, 128, 128, 0)", # plot_bg_color not 0,0,0,0 because zoom highlighting then does not work properly! # Must be >=128 to actuall work in light mode! - font=dict(color=NEUTRAL_TEXT, family="Inter, Helvetica, sans-serif", size=12), - xaxis=dict( - showgrid=True, - gridcolor=NEUTRAL_GRID, - zerolinecolor=NEUTRAL, - linecolor=NEUTRAL, - tickfont=dict(color=NEUTRAL_TEXT), - ), - yaxis=dict( - showgrid=True, - gridcolor=NEUTRAL_GRID, - zerolinecolor=NEUTRAL, - linecolor=NEUTRAL, - tickfont=dict(color=NEUTRAL_TEXT), - ), - hoverlabel=dict( - bgcolor="rgba(128,128,128,0.85)", - bordercolor="rgba(0,0,0,0.08)", - font=dict(color="#fff", size=12, family="Inter, Helvetica, sans-serif"), - namelength=0, - ), - modebar=dict( - bgcolor="rgba(0,0,0,0)", - color=NEUTRAL_TEXT, - activecolor="rgba(128,128,128,1)", - ), + font={"color": NEUTRAL_TEXT, "family": "Inter, Helvetica, sans-serif", "size": 12}, + xaxis={ + "showgrid": True, + "gridcolor": NEUTRAL_GRID, + "zerolinecolor": NEUTRAL, + "linecolor": NEUTRAL, + "tickfont": {"color": NEUTRAL_TEXT}, + }, + yaxis={ + "showgrid": True, + "gridcolor": NEUTRAL_GRID, + "zerolinecolor": NEUTRAL, + "linecolor": NEUTRAL, + "tickfont": {"color": NEUTRAL_TEXT}, + }, + hoverlabel={ + "bgcolor": "rgba(128,128,128,0.85)", + "bordercolor": "rgba(0,0,0,0.08)", + "font": {"color": "#fff", "size": 12, "family": "Inter, Helvetica, sans-serif"}, + "namelength": 0, + }, + modebar={ + "bgcolor": "rgba(0,0,0,0)", + "color": NEUTRAL_TEXT, + "activecolor": "rgba(128,128,128,1)", + }, ) ) @@ -46,17 +46,13 @@ def set_agnostic_theme(fig: go.Figure) -> None: - """Set Plotly template of figure to a theme agnostic template - - This Plotly template should yield good results for both light and dark themed - backgrounds. + """Apply a Plotly theme suitable for light and dark backgrounds. Args: - fig (plotly Figure object): Plotly figure object whose template will be set. + fig: Figure to style. Returns: - None: - Returns None, since the figure object is modified in place + None: The supplied figure is updated in place. Code example: @@ -65,6 +61,5 @@ def set_agnostic_theme(fig: go.Figure) -> None: >>> import plotly.graph_objects as go >>> fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[1, 4, 9])) >>> set_agnostic_theme(fig) - """ fig.update_layout(template=theme_agnostic_template) diff --git a/src/hdhelpers/plotting/timestamp_ticks.py b/src/hdhelpers/plotting/timestamp_ticks.py index 4fe55dd..edbfded 100644 --- a/src/hdhelpers/plotting/timestamp_ticks.py +++ b/src/hdhelpers/plotting/timestamp_ticks.py @@ -13,32 +13,27 @@ # language agnostic, isoformat inspired, plotl x axis timestamp formatting # defined for multiple zoom levels # always showing a tick with the current day somewhere for reference - dict(dtickrange=[None, 1000], value="%H:%M:%S.%L\n%Y-%m-%d"), - dict(dtickrange=[1000, 60000], value="%H:%M:%S\n%Y-%m-%d"), - dict(dtickrange=[60000, 3600000], value="%H:%M\n%Y-%m-%d"), - dict(dtickrange=[3600000, 86400000], value="%H:%M\n%Y-%m-%d"), - dict(dtickrange=[86400000, 604800000], value="%H:%M\n%Y-%m-%d"), - dict(dtickrange=[604800000, "M1"], value="%Y-%m-%d"), - dict(dtickrange=["M1", "M12"], value="%Y-%m"), - dict(dtickrange=["M12", None], value="%Y"), + {"dtickrange": [None, 1000], "value": "%H:%M:%S.%L\n%Y-%m-%d"}, + {"dtickrange": [1000, 60000], "value": "%H:%M:%S\n%Y-%m-%d"}, + {"dtickrange": [60000, 3600000], "value": "%H:%M\n%Y-%m-%d"}, + {"dtickrange": [3600000, 86400000], "value": "%H:%M\n%Y-%m-%d"}, + {"dtickrange": [86400000, 604800000], "value": "%H:%M\n%Y-%m-%d"}, + {"dtickrange": [604800000, "M1"], "value": "%Y-%m-%d"}, + {"dtickrange": ["M1", "M12"], "value": "%Y-%m"}, + {"dtickrange": ["M12", None], "value": "%Y"}, ] def set_dt_ticks(fig: go.Figure) -> None: - """Set x datetime tick labels to language agnostic format + """Set zoom-dependent, locale-independent date/time labels on all x axes. - Sets the x axes tick format of the Plotly Figure to some isoformat inspired - timestamp format, adequately for multiple zoom levels. - - Infers Plotly locale from plot target settings or returns explicit provided - locale. + Update tickformatstops; timestamps and time zones are unchanged. Args: - fig (plotly Figure object): Plotly figure object whose x axes will be modified. + fig: Figure to format. Returns: - None: - Returns None, since the figure object is modified in place + None: The supplied figure is updated in place. Code example: @@ -47,6 +42,5 @@ def set_dt_ticks(fig: go.Figure) -> None: >>> import plotly.graph_objects as go >>> fig = go.Figure(data=go.Scatter(x=["2024-01-01", "2024-01-02"], y=[1, 4])) >>> set_dt_ticks(fig) - """ fig.update_xaxes(tickformatstops=TICKFORMATSTOPS) diff --git a/tests/benchmarks/benchmark_metadata.py b/tests/benchmarks/benchmark_metadata.py new file mode 100644 index 0000000..fac6f38 --- /dev/null +++ b/tests/benchmarks/benchmark_metadata.py @@ -0,0 +1,104 @@ +"""Timings of the metadata models for MULTITSFRAMEs with many metrics, against the budget + +Not part of the test run. Run with: uv run python tests/benchmarks/benchmark_metadata.py [metrics] +""" + +import copy +import sys +import timeit +from collections.abc import Callable +from typing import Any + +import numpy as np +import pandas as pd + +from hdhelpers.metadata import META_KEY, attach_metadata, attrs_to_json, get_units +from hdhelpers.metadata.conventions import MultiTSFrameMetadata + +# milliseconds per 1000 metrics, for metadata with nested extra fields, recording information and +# comments as below, unless independent of the number of metrics (FIXED) +BUDGET = { + "parse (model_validate incl. rules)": 12.0, + "deepcopy(attrs) with attached model": 0.01, + "filter -> sort -> reset_index": 1.0, + "with_metric": 0.5, + "attrs_to_json": 6.0, + "get_units": 5.0, +} +# pandas shares the attached model instead of copying it +FIXED = {"deepcopy(attrs) with attached model", "filter -> sort -> reset_index"} + + +def attrs_of(metric_count: int) -> dict[str, Any]: + metrics = [] + for index in range(metric_count): + metric: dict[str, Any] = { + "id": f"00000000-0000-4000-8000-{index:012d}", + "external_id": f"plant.m{index}", + "name": f"Metric {index}", + "path": ["site", "area", f"unit{index % 10}"], + "value_dimensions": [{"column": "value", "unit": "bar", "measurement": "pressure"}], + "inherited": {"site": "Site A", "asset": {"type": "pump", "tags": ["a", "b"]}}, + "recording_mode": "periodic", + "data_frequency": "1h", + "default_timezone": "Europe/Berlin", + } + if index % 10 == 0: + metric["comments"] = [ + {"content": "checked", "username": "x", "created_at": "2026-01-01T00:00:00Z"} + ] + metrics.append(metric) + return { + "dataset_metadata": { + "metric_key": "external_id", + "queried_metrics": [f"plant.m{index}" for index in range(metric_count)], + "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + "ref_interval_type": "closed", + }, + "metrics": metrics, + } + + +def milliseconds(function: Callable[[], Any], number: int = 20) -> float: + return min(timeit.repeat(function, number=number, repeat=5)) / number * 1e3 + + +def main(metric_count: int) -> None: + attrs = attrs_of(metric_count) + frame = pd.DataFrame( + { + "timestamp": pd.date_range("2026-01-01", periods=100, freq="h", tz="UTC"), + "metric": "plant.m1", + "value": np.arange(100.0), + } + ) + frame.attrs = copy.deepcopy(attrs) + meta = attach_metadata(frame) + assert isinstance(frame.attrs[META_KEY], MultiTSFrameMetadata) + assert meta is not None + + timings = { + "parse (model_validate incl. rules)": milliseconds( + lambda: MultiTSFrameMetadata.model_validate(attrs), 5 + ), + "deepcopy(attrs) with attached model": milliseconds( + lambda: copy.deepcopy(frame.attrs), 1000 + ), + "filter -> sort -> reset_index": milliseconds( + lambda: frame[frame["value"] > 10].sort_values("value").reset_index(drop=True) + ), + "with_metric": milliseconds(lambda: meta.with_metric("plant.m1", display_name="M1")), + "attrs_to_json": milliseconds(lambda: attrs_to_json(frame.attrs), 5), + "get_units": milliseconds(lambda: get_units(frame), 5), + } + scale = metric_count / 1000 + print(f"{metric_count} metrics, pandas {pd.__version__}") + for name, value in timings.items(): + budget = BUDGET[name] * (1 if name in FIXED else scale) + status = "ok" if value <= budget else "OVER BUDGET" + print(f" {name:40s} {value:9.3f} ms (budget {budget:7.3f} ms) {status}") + + +if __name__ == "__main__": + main(int(sys.argv[1]) if len(sys.argv) > 1 else 1000) diff --git a/tests/conftest.py b/tests/conftest.py index 3d2aac5..4aa3efc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,12 +4,3 @@ import pytest from .data.fixtures.helpers import dataframe, multicolumn_frame, series_summer, series_winter -from .data.fixtures.metadata import ( - empty_mts_with_attr, - empty_mts_with_old_attr, - empty_mts_with_old_attr_real, - empty_series_with_attr, - empty_series_with_old_attr, - empty_series_with_old_attr_real, - empty_singletsframe_with_attr, -) diff --git a/tests/data/convention_schemas.json b/tests/data/convention_schemas.json new file mode 100644 index 0000000..80fd3c9 --- /dev/null +++ b/tests/data/convention_schemas.json @@ -0,0 +1,1727 @@ +{ + "$defs": { + "AggregationMethod": { + "description": "Method for aggregating timeseries values into coarser intervals.\n\nMetadata lookups normalize AVG to MEAN; they are distinct enum values.", + "enum": [ + "MEAN", + "AVG", + "MEDIAN", + "SUM", + "MIN", + "MAX", + "FIRST", + "LAST", + "COUNT" + ], + "title": "AggregationMethod", + "type": "string" + }, + "InheritedMetricMetadata": { + "additionalProperties": true, + "description": "Properties supplied by a source system for a metric and its parent assets.\n\nStores already-resolved values, e.g. a plant's latitude; the source system resolves the\nhierarchy. Metadata lookups use these properties as fallbacks only with\n``include_inherited=True``. Convention-defined fields such as name, id and unit belong in\nthe metric or value-dimension metadata.\n\nUse JSON types matching each value's meaning: numbers, booleans and UTC ISO timestamp\nstrings for points in time. For example, hetida platform supplies properties by label,\nusing a signal's own value or that of the nearest parent asset providing one.", + "properties": {}, + "title": "InheritedMetricMetadata", + "type": "object" + }, + "IntervalType": { + "description": "Which boundaries belong to a time interval\n\nclosed: both, open: none, left_closed / right_open: only the start,\nright_closed / left_open: only the end.", + "enum": [ + "closed", + "left_closed", + "right_closed", + "open", + "left_open", + "right_open" + ], + "title": "IntervalType", + "type": "string" + }, + "MetricComment": { + "additionalProperties": true, + "description": "A comment attached to a metric, describing a time range of its timeseries data\n\nE.g. the hetida platform provides the comments on a signal whose time range overlaps the\nqueried interval and which existed at query time, newest first.\n\nStart and end of the time range must be provided together. They are equal for a comment on a\nsingle point in time.", + "properties": { + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Free text of the comment", + "title": "Content" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "When the comment was created", + "title": "Created At" + }, + "edited_at": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "When the comment was last edited. Null or missing if never edited.", + "title": "Edited At" + }, + "end_date": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "End of the time range the comment refers to", + "title": "End Date" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Id of the comment, e.g. a UUID", + "title": "Id" + }, + "replies": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/MetricCommentReply" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Replies to the comment, oldest first", + "title": "Replies" + }, + "start_date": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Start of the time range the comment refers to", + "title": "Start Date" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Free text tags classifying the comment", + "title": "Tags" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Username of the author of the comment", + "title": "Username" + } + }, + "title": "MetricComment", + "type": "object" + }, + "MetricCommentReply": { + "additionalProperties": true, + "description": "A reply to a comment attached to a metric", + "properties": { + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Free text of the reply", + "title": "Content" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "When the reply was created", + "title": "Created At" + }, + "edited_at": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "When the reply was last edited. Null or missing if never edited.", + "title": "Edited At" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Id of the reply, e.g. a UUID", + "title": "Id" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Username of the author of the reply", + "title": "Username" + } + }, + "title": "MetricCommentReply", + "type": "object" + }, + "MetricMetadata": { + "additionalProperties": true, + "description": "Metadata of a metric, i.e. of a single, possibly multidimensional timeseries\n\nA metric is identified by its field named by dataset_metadata.metric_key, whose values occur\nin the metric column of a MULTITSFRAME. Every metric must have this field as a string,\nunique among the metrics.", + "properties": { + "channel_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "deprecated": true, + "description": "Actual platform channel id. Deprecated, since the platform calls channels signals now.", + "title": "Channel Id" + }, + "comments": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/MetricComment" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Comments attached to the metric, describing situations in its timeseries data", + "title": "Comments" + }, + "data_frequency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Interval in which values of the metric are recorded as Pandas date offset alias or timedelta string, e.g. \"15min\" or \"1D\": For recording_mode periodic the interval between subsequent values, for on_change the interval after which the value is recorded again at the latest, also without a change (heartbeat). Together with data_frequency_offset and data_frequency_timezone it takes precedence over ref_data_frequency, ref_data_frequency_offset and ref_data_frequency_timezone of the dataset metadata for this metric.", + "title": "Data Frequency" + }, + "data_frequency_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Offset of the timestamps against the starts of the intervals of data_frequency (see data_frequency_timezone) as timedelta string, e.g. \"4min\": not negative and smaller than data_frequency. 0 if missing or null. Requires data_frequency.", + "title": "Data Frequency Offset" + }, + "data_frequency_timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time zone the intervals of data_frequency are aligned to, as IANA time zone name like \"Europe/Berlin\" or \"UTC\" (preferred, since it respects daylight saving time) or as fixed UTC offset like \"+02:00\". Without it, the intervals start at the multiples of data_frequency since 1970-01-01T00:00:00Z. With it, for a data_frequency shorter than a day, the intervals restart at each local midnight: They start at local midnight plus multiples of data_frequency, and the last interval of a day ends at the next local midnight, so it may be shorter, e.g. on the day of a daylight saving time change. For a data_frequency of a day or longer, the intervals follow the calendar of the time zone, e.g. days from local midnight to local midnight. The offset shifts the intervals in both cases. Requires data_frequency.", + "title": "Data Frequency Timezone" + }, + "default_aggregation_method": { + "anyOf": [ + { + "$ref": "#/$defs/AggregationMethod" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How the timeseries should be aggregated by default, e.g. when components plot or report it in coarser intervals. If missing or null: LAST if the value_data_type of the value dimension \"value\" is boolean or string, otherwise MEAN. AVG is an alias of MEAN. E.g. the hetida platform provides the method it aggregates the signal with when bucketing it, for a signal group member the one configured for the member." + }, + "default_timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time zone the timeseries should be evaluated in by default, as IANA time zone name like \"Europe/Berlin\" or \"UTC\" (preferred, since it respects daylight saving time) or as fixed UTC offset like \"+02:00\". Components evaluating the timeseries in calendar intervals use it, e.g. a report aggregating days, which start at midnight of this time zone, so the results differ between time zones. This is not the time zone timestamps are displayed in: That is the plot target timezone of the execution context, which depends on the viewer, while this time zone belongs to the data, e.g. the time zone of the plant. Falls back to dataset_metadata.default_timezone.", + "title": "Default Timezone" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A one sentence description", + "title": "Description" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A shorter name for labels, captions or plot titles", + "title": "Display Name" + }, + "external_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Unique id / key in external source systems. Often used to identify the metric in the metric column, i.e. as metric_key.", + "title": "External Id" + }, + "id": { + "description": "Id of the metric, also if another field identifies the metric in the data via dataset_metadata.metric_key. E.g. the hetida platform provides the signal id here.", + "title": "Id", + "type": "string" + }, + "inherited": { + "anyOf": [ + { + "$ref": "#/$defs/InheritedMetricMetadata" + }, + { + "type": "null" + } + ], + "default": null + }, + "name": { + "description": "Long name of the metric", + "title": "Name", + "type": "string" + }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Where the values come from, as free text of the source system, e.g. \"OPC_UA\", \"MQTT\" or \"FORM\" for values entered manually.", + "title": "Origin" + }, + "path": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Location of the metric in the hierarchy of its source system: the names of the levels from the top down to the object the metric belongs to, without the metric itself, e.g. [\"Plant A\", \"Pickling Unit\", \"Influx\"]. Corresponds to the breadcrumb path of the sources of generic REST adapters.", + "title": "Path" + }, + "recording_aggregation_method": { + "anyOf": [ + { + "$ref": "#/$defs/AggregationMethod" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Only for recording_mode periodic: how each value aggregates the values of the interval [timestamp, timestamp + data_frequency), e.g. MEAN for an average or MAX for a maximum. Null or missing if the values are samples, e.g. the value holding at the timestamp." + }, + "recording_mode": { + "anyOf": [ + { + "$ref": "#/$defs/RecordingMode" + }, + { + "type": "null" + } + ], + "default": null, + "description": "When the values are recorded. Unknown if missing or null. periodic requires data_frequency (or dataset_metadata.ref_data_frequency)." + }, + "short_display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A short name for legends in plots", + "title": "Short Display Name" + }, + "signal_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Actual platform signal id", + "title": "Signal Id" + }, + "value_dimensions": { + "description": "Metadata of the value dimensions of this metric: an entry for each value column of the data, i.e. exactly the value dimension \"value\" for a SERIES. Each column may occur at most once.", + "items": { + "$ref": "#/$defs/ValueDimensionMetadata" + }, + "minItems": 1, + "title": "Value Dimensions", + "type": "array" + } + }, + "required": [ + "id", + "name", + "value_dimensions" + ], + "title": "MetricMetadata", + "type": "object" + }, + "MultiTSFrameDatasetMetadata": { + "additionalProperties": true, + "description": "Metadata associated with a MULTITSFRAME as a whole\n\nE.g. the queried time interval or how existing data should be handled when the dataset is\nsent to an adapter. The fields concerning invalidation and deletion are only relevant for\nadapters actually supporting these operations, like the built-in SQL adapter. Other adapters\nmay ignore them.\n\nStart and end of an interval must be provided together.", + "else": { + "properties": { + "ref_interval_end_timestamp": { + "type": "string" + }, + "ref_interval_start_timestamp": { + "type": "string" + }, + "ref_interval_type": { + "type": "string" + } + }, + "required": [ + "ref_interval_start_timestamp", + "ref_interval_end_timestamp", + "ref_interval_type" + ] + }, + "if": { + "properties": { + "ref_dataset_discrete": { + "const": true + } + }, + "required": [ + "ref_dataset_discrete" + ] + }, + "properties": { + "default_aggregation_interval": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Interval to aggregate the timeseries to by default, e.g. when components plot or report them, as Pandas date offset alias or timedelta string, e.g. \"15min\" or \"1D\". The metrics are aggregated with their default_aggregation_method. E.g. a hetida platform signal group is configured with such an interval.", + "title": "Default Aggregation Interval" + }, + "default_timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time zone the timeseries should be evaluated in by default, as IANA time zone name like \"Europe/Berlin\" or \"UTC\" (preferred, since it respects daylight saving time) or as fixed UTC offset like \"+02:00\". Components evaluating the timeseries in calendar intervals use it, e.g. a report aggregating days, which start at midnight of this time zone, so the results differ between time zones. This is not the time zone timestamps are displayed in: That is the plot target timezone of the execution context, which depends on the viewer, while this time zone belongs to the data, e.g. the time zone of the plant. Applies to all metrics without their own default_timezone.", + "title": "Default Timezone" + }, + "delete_invalidated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether data to be invalidated should be deleted instead. What null means is up to the adapter. Adapters do not need to support deletion.", + "title": "Delete Invalidated" + }, + "invalidate_dataset": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether existing data should be invalidated: in the invalidation interval if specified, otherwise in the ref interval. If neither is specified, a closed ref interval from the first to the last datapoint is inferred. Without datapoints no invalidation can occur then. What null means is up to the adapter.", + "title": "Invalidate Dataset" + }, + "invalidation_interval_end": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "End of an invalidation interval separate from the ref interval in UTC isoformat", + "title": "Invalidation Interval End" + }, + "invalidation_interval_start": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Start of an invalidation interval separate from the ref interval in UTC isoformat", + "title": "Invalidation Interval Start" + }, + "invalidation_interval_type": { + "anyOf": [ + { + "$ref": "#/$defs/IntervalType" + }, + { + "type": "null" + } + ], + "default": "closed", + "description": "Type of the invalidation interval" + }, + "invalidation_timestamp": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Invalidation timestamp for all invalidated datapoints. If null, the execution_timestamp query parameter sent to generic REST adapters or, if that is not present either, the current time should be used.", + "title": "Invalidation Timestamp" + }, + "metric_key": { + "description": "Which field of the metric objects under metrics identifies a metric, i.e. occurs in the metric column of a MULTITSFRAME, e.g. \"id\" or \"external_id\".", + "title": "Metric Key", + "type": "string" + }, + "new_data_invalidation_date": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "An invalidation date to be stored together with all new datapoints, i.e. invalidating them in advance. Ignored if null.", + "title": "New Data Invalidation Date" + }, + "only_invalidate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "description": "If true, existing data should only be invalidated and the new data should not be stored. Cannot be true if invalidate_dataset is false.", + "title": "Only Invalidate" + }, + "queried_metrics": { + "description": "The requested metrics. Since not all queried metrics may have data in the queried interval, a metric may be missing in the data.", + "items": { + "type": "string" + }, + "title": "Queried Metrics", + "type": "array" + }, + "query_timestamp": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The point in time whose state of the data was delivered in UTC isoformat: Data stored, changed or invalidated later is not included. A source supporting it delivers the same data again when queried for this point in time, e.g. for reproducing an execution.", + "title": "Query Timestamp" + }, + "ref_data_frequency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Frequency of the timeseries as Pandas date offset alias or timedelta string, e.g. \"15min\" or \"1D\": the data_frequency of all metrics without their own, with the same meaning as there. Together with ref_data_frequency_offset and ref_data_frequency_timezone.", + "title": "Ref Data Frequency" + }, + "ref_data_frequency_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Offset of the timestamps against the starts of the intervals of ref_data_frequency (see ref_data_frequency_timezone) as timedelta string, e.g. \"4min\": not negative and smaller than ref_data_frequency. 0 if missing or null. Requires ref_data_frequency. Applies to all metrics without their own data_frequency.", + "title": "Ref Data Frequency Offset" + }, + "ref_data_frequency_timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time zone the intervals of ref_data_frequency are aligned to, as IANA time zone name like \"Europe/Berlin\" or \"UTC\" (preferred, since it respects daylight saving time) or as fixed UTC offset like \"+02:00\". Without it, the intervals start at the multiples of ref_data_frequency since 1970-01-01T00:00:00Z. With it, for a ref_data_frequency shorter than a day, the intervals restart at each local midnight: They start at local midnight plus multiples of ref_data_frequency, and the last interval of a day ends at the next local midnight, so it may be shorter, e.g. on the day of a daylight saving time change. For a ref_data_frequency of a day or longer, the intervals follow the calendar of the time zone, e.g. days from local midnight to local midnight. The offset shifts the intervals in both cases. Requires ref_data_frequency. Applies to all metrics without their own data_frequency.", + "title": "Ref Data Frequency Timezone" + }, + "ref_dataset_discrete": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "description": "Whether the ref dataset should be considered as discrete, i.e. isolated datapoints instead of an interval. If true, the ref interval is not required.", + "title": "Ref Dataset Discrete" + }, + "ref_interval_end_timestamp": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "End of the queried time interval (ref interval) in UTC isoformat. Required unless ref_dataset_discrete is true.", + "title": "Ref Interval End Timestamp" + }, + "ref_interval_start_timestamp": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Start of the queried time interval (ref interval) in UTC isoformat. Required unless ref_dataset_discrete is true.", + "title": "Ref Interval Start Timestamp" + }, + "ref_interval_type": { + "anyOf": [ + { + "$ref": "#/$defs/IntervalType" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of the ref interval. Required unless ref_dataset_discrete is true." + }, + "signal_group": { + "anyOf": [ + { + "$ref": "#/$defs/SignalGroupMetadata" + }, + { + "type": "null" + } + ], + "default": null + }, + "single_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "If the MULTITSFRAME holds a single metric: its metric_key field value. Must not be set if more than one metric is present.", + "title": "Single Metric" + } + }, + "required": [ + "metric_key", + "queried_metrics" + ], + "title": "MultiTSFrameDatasetMetadata", + "type": "object" + }, + "MultiTSFrameMetadata": { + "additionalProperties": true, + "description": "Immutable metadata for a MULTITSFRAME: multiple metrics identified by a metric column.\n\nEach metric can have several value dimensions; a MULTITSFRAME may also hold one metric.\nRead from a pandas DataFrame with read_metadata. See the module documentation for storage,\nadapter exchange and common field rules.", + "examples": [ + { + "dataset_metadata": { + "default_aggregation_interval": "1h", + "default_timezone": "Europe/Berlin", + "metric_key": "external_id", + "queried_metrics": [ + "plant_a.pressure", + "plant_a.temperature" + ], + "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + "ref_interval_type": "closed" + }, + "metrics": [ + { + "external_id": "plant_a.pressure", + "id": "c5a8e2f1-0b3d-4e6a-9f7c-1d2e3f4a5b6c", + "name": "Plant A pressure", + "value_dimensions": [ + { + "column": "value", + "measurement": "pressure", + "unit": "bar" + } + ] + }, + { + "data_frequency": "1h", + "default_aggregation_method": "MAX", + "external_id": "plant_a.temperature", + "id": "8d7c6b5a-4f3e-4d2c-8b1a-0f9e8d7c6b5a", + "name": "Plant A temperature", + "recording_mode": "on_change", + "value_dimensions": [ + { + "column": "value", + "deadband": 0.5, + "decimals": 1, + "measurement": "temperature", + "unit": "°C" + } + ] + } + ] + } + ], + "properties": { + "dataset_metadata": { + "$ref": "#/$defs/MultiTSFrameDatasetMetadata" + }, + "metrics": { + "description": "Metadata of the metrics: one for each metric in the data. Each metric must have the field named by dataset_metadata.metric_key as a string, unique among the metrics.", + "items": { + "$ref": "#/$defs/MetricMetadata" + }, + "title": "Metrics", + "type": "array" + }, + "value_dimensions_shared": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/SharedValueDimensionMetadata" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metadata of value dimensions that have the same meaning for all metrics. Consumers use it as fallback, but it does not replace the value dimensions of the metrics. Each column may occur at most once.", + "title": "Value Dimensions Shared" + } + }, + "required": [ + "dataset_metadata", + "metrics" + ], + "title": "MultiTSFrameMetadata", + "type": "object" + }, + "RecordingMode": { + "description": "When the values of a timeseries are recorded\n\nperiodic: one value per interval of data_frequency, timestamped with the start of the\ninterval. A missing value is a gap.\non_change: a value is recorded when it changes (by more than the deadband of its value\ndimension) and holds until the next one. Missing timestamps are no gaps, as long as\ndata_frequency (the heartbeat) is not exceeded.\nirregular: values at arbitrary points in time.", + "enum": [ + "periodic", + "on_change", + "irregular" + ], + "title": "RecordingMode", + "type": "string" + }, + "SeriesMetadata": { + "additionalProperties": true, + "description": "Immutable metadata for a SERIES: one metric with a single \"value\" dimension.\n\nRead from a pandas Series with read_metadata. See the module documentation for storage,\nadapter exchange and common field rules.", + "examples": [ + { + "dataset_metadata": { + "metric_key": "id", + "queried_metrics": [ + "7ab8c419-8745-4714-b588-57c0c772cc95" + ], + "query_timestamp": "2026-01-02T00:05:00+00:00", + "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + "ref_interval_type": "right_open", + "single_metric": "7ab8c419-8745-4714-b588-57c0c772cc95" + }, + "metrics": [ + { + "data_frequency": "15min", + "default_aggregation_method": "MEAN", + "default_timezone": "Europe/Berlin", + "id": "7ab8c419-8745-4714-b588-57c0c772cc95", + "name": "Water level north", + "origin": "OPC_UA", + "path": [ + "Waterworks North", + "Gauge North" + ], + "recording_aggregation_method": "MEAN", + "recording_mode": "periodic", + "value_dimensions": [ + { + "column": "value", + "decimals": 2, + "measurement": "level", + "unit": "m" + } + ] + } + ] + } + ], + "properties": { + "dataset_metadata": { + "$ref": "#/$defs/SingleMetricDatasetMetadata" + }, + "metrics": { + "description": "Metadata of the single metric: exactly one metric, which has the field named by dataset_metadata.metric_key with the value of dataset_metadata.single_metric.", + "items": { + "$ref": "#/$defs/MetricMetadata" + }, + "maxItems": 1, + "minItems": 1, + "title": "Metrics", + "type": "array" + }, + "value_dimensions_shared": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/SharedValueDimensionMetadata" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metadata of value dimensions that have the same meaning for all metrics. Consumers use it as fallback, but it does not replace the value dimensions of the metrics. Each column may occur at most once.", + "title": "Value Dimensions Shared" + } + }, + "required": [ + "dataset_metadata", + "metrics" + ], + "title": "SeriesMetadata", + "type": "object" + }, + "SharedValueDimensionMetadata": { + "additionalProperties": true, + "description": "Default metadata for a value column shared across metrics.\n\nMatched by column name (\"value\" for a SERIES) and used when the corresponding field\nis missing from metric-specific metadata.", + "properties": { + "column": { + "description": "The actual column name in the MULTITSFRAME or SINGLETSFRAME, connecting the value dimension to its column. \"value\" for a SERIES.", + "title": "Column", + "type": "string" + }, + "deadband": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "For a metric with recording_mode on_change: the change of the values, in their unit, which has to be exceeded for a value to be recorded. 0 records every change.", + "title": "Deadband" + }, + "decimals": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of decimal places numeric values should be displayed with, in their unit, e.g. 2 for 12.35. Only affects how values are displayed, e.g. in tables, tooltips or reports; the values themselves are not rounded. Up to the consumer if missing or null.", + "title": "Decimals" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A description. Should be considered empty if missing.", + "title": "Description" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A shorter name for labels, captions or plot titles. Falls back to name.", + "title": "Display Name" + }, + "measurement": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The measured quantity, e.g. \"temperature\"", + "title": "Measurement" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the value dimension, e.g. \"temperature\". May equal column.", + "title": "Name" + }, + "short_display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A short name for legends in plots. Falls back to display_name.", + "title": "Short Display Name" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Unit of the values, e.g. \"°C\" or \"m/s\", \"\" for dimensionless quantities", + "title": "Unit" + }, + "value_data_type": { + "anyOf": [ + { + "$ref": "#/$defs/ValueDataType" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Data type of the values" + } + }, + "required": [ + "column" + ], + "title": "SharedValueDimensionMetadata", + "type": "object" + }, + "SignalGroupMetadata": { + "additionalProperties": true, + "description": "A group of signals a dataset was read from\n\nE.g. the hetida platform provides the signal group a MULTITSFRAME was read from.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Description of the signal group", + "title": "Description" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Id of the signal group", + "title": "Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the signal group", + "title": "Name" + } + }, + "title": "SignalGroupMetadata", + "type": "object" + }, + "SingleMetricDatasetMetadata": { + "additionalProperties": true, + "description": "Metadata associated with a SERIES or SINGLETSFRAME as a whole\n\nE.g. the queried time interval or how existing data should be handled when the dataset is\nsent to an adapter. The fields concerning invalidation and deletion are only relevant for\nadapters actually supporting these operations, like the built-in SQL adapter. Other adapters\nmay ignore them.\n\nStart and end of an interval must be provided together.", + "else": { + "properties": { + "ref_interval_end_timestamp": { + "type": "string" + }, + "ref_interval_start_timestamp": { + "type": "string" + }, + "ref_interval_type": { + "type": "string" + } + }, + "required": [ + "ref_interval_start_timestamp", + "ref_interval_end_timestamp", + "ref_interval_type" + ] + }, + "if": { + "properties": { + "ref_dataset_discrete": { + "const": true + } + }, + "required": [ + "ref_dataset_discrete" + ] + }, + "properties": { + "default_aggregation_interval": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Interval to aggregate the timeseries to by default, e.g. when components plot or report them, as Pandas date offset alias or timedelta string, e.g. \"15min\" or \"1D\". The metrics are aggregated with their default_aggregation_method. E.g. a hetida platform signal group is configured with such an interval.", + "title": "Default Aggregation Interval" + }, + "default_timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time zone the timeseries should be evaluated in by default, as IANA time zone name like \"Europe/Berlin\" or \"UTC\" (preferred, since it respects daylight saving time) or as fixed UTC offset like \"+02:00\". Components evaluating the timeseries in calendar intervals use it, e.g. a report aggregating days, which start at midnight of this time zone, so the results differ between time zones. This is not the time zone timestamps are displayed in: That is the plot target timezone of the execution context, which depends on the viewer, while this time zone belongs to the data, e.g. the time zone of the plant. Applies to all metrics without their own default_timezone.", + "title": "Default Timezone" + }, + "delete_invalidated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether data to be invalidated should be deleted instead. What null means is up to the adapter. Adapters do not need to support deletion.", + "title": "Delete Invalidated" + }, + "invalidate_dataset": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether existing data should be invalidated: in the invalidation interval if specified, otherwise in the ref interval. If neither is specified, a closed ref interval from the first to the last datapoint is inferred. Without datapoints no invalidation can occur then. What null means is up to the adapter.", + "title": "Invalidate Dataset" + }, + "invalidation_interval_end": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "End of an invalidation interval separate from the ref interval in UTC isoformat", + "title": "Invalidation Interval End" + }, + "invalidation_interval_start": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Start of an invalidation interval separate from the ref interval in UTC isoformat", + "title": "Invalidation Interval Start" + }, + "invalidation_interval_type": { + "anyOf": [ + { + "$ref": "#/$defs/IntervalType" + }, + { + "type": "null" + } + ], + "default": "closed", + "description": "Type of the invalidation interval" + }, + "invalidation_timestamp": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Invalidation timestamp for all invalidated datapoints. If null, the execution_timestamp query parameter sent to generic REST adapters or, if that is not present either, the current time should be used.", + "title": "Invalidation Timestamp" + }, + "metric_key": { + "description": "Which field of the metric objects under metrics identifies a metric, i.e. occurs in the metric column of a MULTITSFRAME, e.g. \"id\" or \"external_id\".", + "title": "Metric Key", + "type": "string" + }, + "new_data_invalidation_date": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "An invalidation date to be stored together with all new datapoints, i.e. invalidating them in advance. Ignored if null.", + "title": "New Data Invalidation Date" + }, + "only_invalidate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "description": "If true, existing data should only be invalidated and the new data should not be stored. Cannot be true if invalidate_dataset is false.", + "title": "Only Invalidate" + }, + "queried_metrics": { + "description": "The requested metrics. Since not all queried metrics may have data in the queried interval, a metric may be missing in the data.", + "items": { + "type": "string" + }, + "title": "Queried Metrics", + "type": "array" + }, + "query_timestamp": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The point in time whose state of the data was delivered in UTC isoformat: Data stored, changed or invalidated later is not included. A source supporting it delivers the same data again when queried for this point in time, e.g. for reproducing an execution.", + "title": "Query Timestamp" + }, + "ref_data_frequency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Frequency of the timeseries as Pandas date offset alias or timedelta string, e.g. \"15min\" or \"1D\": the data_frequency of all metrics without their own, with the same meaning as there. Together with ref_data_frequency_offset and ref_data_frequency_timezone.", + "title": "Ref Data Frequency" + }, + "ref_data_frequency_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Offset of the timestamps against the starts of the intervals of ref_data_frequency (see ref_data_frequency_timezone) as timedelta string, e.g. \"4min\": not negative and smaller than ref_data_frequency. 0 if missing or null. Requires ref_data_frequency. Applies to all metrics without their own data_frequency.", + "title": "Ref Data Frequency Offset" + }, + "ref_data_frequency_timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time zone the intervals of ref_data_frequency are aligned to, as IANA time zone name like \"Europe/Berlin\" or \"UTC\" (preferred, since it respects daylight saving time) or as fixed UTC offset like \"+02:00\". Without it, the intervals start at the multiples of ref_data_frequency since 1970-01-01T00:00:00Z. With it, for a ref_data_frequency shorter than a day, the intervals restart at each local midnight: They start at local midnight plus multiples of ref_data_frequency, and the last interval of a day ends at the next local midnight, so it may be shorter, e.g. on the day of a daylight saving time change. For a ref_data_frequency of a day or longer, the intervals follow the calendar of the time zone, e.g. days from local midnight to local midnight. The offset shifts the intervals in both cases. Requires ref_data_frequency. Applies to all metrics without their own data_frequency.", + "title": "Ref Data Frequency Timezone" + }, + "ref_dataset_discrete": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "description": "Whether the ref dataset should be considered as discrete, i.e. isolated datapoints instead of an interval. If true, the ref interval is not required.", + "title": "Ref Dataset Discrete" + }, + "ref_interval_end_timestamp": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "End of the queried time interval (ref interval) in UTC isoformat. Required unless ref_dataset_discrete is true.", + "title": "Ref Interval End Timestamp" + }, + "ref_interval_start_timestamp": { + "anyOf": [ + { + "format": "date-time", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?(Z|\\+00:00)$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Start of the queried time interval (ref interval) in UTC isoformat. Required unless ref_dataset_discrete is true.", + "title": "Ref Interval Start Timestamp" + }, + "ref_interval_type": { + "anyOf": [ + { + "$ref": "#/$defs/IntervalType" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of the ref interval. Required unless ref_dataset_discrete is true." + }, + "signal_group": { + "anyOf": [ + { + "$ref": "#/$defs/SignalGroupMetadata" + }, + { + "type": "null" + } + ], + "default": null + }, + "single_metric": { + "description": "The metric_key field value of the single metric under metrics.", + "title": "Single Metric", + "type": "string" + } + }, + "required": [ + "metric_key", + "queried_metrics", + "single_metric" + ], + "title": "SingleMetricDatasetMetadata", + "type": "object" + }, + "SingleTSFrameMetadata": { + "additionalProperties": true, + "description": "Immutable metadata for a SINGLETSFRAME: one metric with one or more value columns.\n\nRead from a pandas DataFrame with read_metadata. See the module documentation for storage,\nadapter exchange and common field rules.", + "examples": [ + { + "dataset_metadata": { + "metric_key": "external_id", + "queried_metrics": [ + "abc.temp" + ], + "ref_interval_end_timestamp": "2026-01-02T00:00:00+00:00", + "ref_interval_start_timestamp": "2026-01-01T00:00:00+00:00", + "ref_interval_type": "closed", + "single_metric": "abc.temp" + }, + "metrics": [ + { + "external_id": "abc.temp", + "id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "name": "ABC temperature", + "value_dimensions": [ + { + "column": "value", + "measurement": "temperature", + "unit": "°C" + }, + { + "column": "state", + "measurement": null, + "name": "measurement state", + "unit": null + } + ] + } + ] + } + ], + "properties": { + "dataset_metadata": { + "$ref": "#/$defs/SingleMetricDatasetMetadata" + }, + "metrics": { + "description": "Metadata of the single metric: exactly one metric, which has the field named by dataset_metadata.metric_key with the value of dataset_metadata.single_metric.", + "items": { + "$ref": "#/$defs/MetricMetadata" + }, + "maxItems": 1, + "minItems": 1, + "title": "Metrics", + "type": "array" + }, + "value_dimensions_shared": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/SharedValueDimensionMetadata" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metadata of value dimensions that have the same meaning for all metrics. Consumers use it as fallback, but it does not replace the value dimensions of the metrics. Each column may occur at most once.", + "title": "Value Dimensions Shared" + } + }, + "required": [ + "dataset_metadata", + "metrics" + ], + "title": "SingleTSFrameMetadata", + "type": "object" + }, + "ValueDataType": { + "description": "Data type of the values of a value dimension", + "enum": [ + "float", + "integer", + "boolean", + "string" + ], + "title": "ValueDataType", + "type": "string" + }, + "ValueDimensionMetadata": { + "additionalProperties": true, + "description": "Metadata for one metric's value column, such as its unit and measured quantity.\n\nEach metric describes its value columns; a SERIES uses \"value\". Non-null fields take\nprecedence over shared metadata for the same column. Unit and measurement are required\nfields, but may be None if unknown.", + "properties": { + "column": { + "description": "The actual column name in the MULTITSFRAME or SINGLETSFRAME, connecting the value dimension to its column. \"value\" for a SERIES.", + "title": "Column", + "type": "string" + }, + "deadband": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "For a metric with recording_mode on_change: the change of the values, in their unit, which has to be exceeded for a value to be recorded. 0 records every change.", + "title": "Deadband" + }, + "decimals": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of decimal places numeric values should be displayed with, in their unit, e.g. 2 for 12.35. Only affects how values are displayed, e.g. in tables, tooltips or reports; the values themselves are not rounded. Up to the consumer if missing or null.", + "title": "Decimals" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A description. Should be considered empty if missing.", + "title": "Description" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A shorter name for labels, captions or plot titles. Falls back to name.", + "title": "Display Name" + }, + "measurement": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The measured quantity, e.g. \"temperature\". Null if unknown.", + "title": "Measurement" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the value dimension, e.g. \"temperature\". May equal column.", + "title": "Name" + }, + "short_display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A short name for legends in plots. Falls back to display_name.", + "title": "Short Display Name" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Unit of the values, e.g. \"°C\" or \"m/s\". \"\" for dimensionless quantities, null if unknown.", + "title": "Unit" + }, + "value_data_type": { + "anyOf": [ + { + "$ref": "#/$defs/ValueDataType" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Data type of the values" + } + }, + "required": [ + "column", + "unit", + "measurement" + ], + "title": "ValueDimensionMetadata", + "type": "object" + } + } +} diff --git a/tests/data/fixtures/helpers.py b/tests/data/fixtures/helpers.py index 104eb23..98cef5c 100644 --- a/tests/data/fixtures/helpers.py +++ b/tests/data/fixtures/helpers.py @@ -2,7 +2,7 @@ import pytest -@pytest.fixture(scope="function") +@pytest.fixture() def series_winter() -> pd.Series: winter = pd.Series( [0, 1, 2, 3], @@ -17,7 +17,7 @@ def series_winter() -> pd.Series: return winter -@pytest.fixture(scope="function") +@pytest.fixture() def series_summer() -> pd.Series: summer = pd.Series( [0, 1, 2, 3], @@ -31,7 +31,7 @@ def series_summer() -> pd.Series: return summer -@pytest.fixture(scope="function") +@pytest.fixture() def dataframe() -> pd.DataFrame: values = [1.0, 1.2, 1.2] timestamps = pd.to_datetime( @@ -49,7 +49,7 @@ def dataframe() -> pd.DataFrame: return ts_df -@pytest.fixture(scope="function") +@pytest.fixture() def multicolumn_frame() -> pd.DataFrame: values = [1.0, 1.2, 1.2] index = pd.to_datetime( diff --git a/tests/data/fixtures/metadata.py b/tests/data/fixtures/metadata.py deleted file mode 100644 index 88b9a90..0000000 --- a/tests/data/fixtures/metadata.py +++ /dev/null @@ -1,74 +0,0 @@ -import json - -import pandas as pd -import pytest - - -@pytest.fixture(scope="function") -def empty_mts_with_old_attr() -> pd.DataFrame: - with open("tests/data/json_templates/old_mts_attrs.json", "r") as file: - metadata_for_mts = json.load(file) - - empty_mts = pd.DataFrame() - empty_mts.attrs = metadata_for_mts - return empty_mts - - -@pytest.fixture(scope="function") -def empty_mts_with_attr() -> pd.DataFrame: - with open("tests/data/json_templates/mts_attrs.json", "r") as file: - metadata_for_mts = json.load(file) - - empty_mts = pd.DataFrame() - empty_mts.attrs = metadata_for_mts - return empty_mts - - -@pytest.fixture(scope="function") -def empty_mts_with_old_attr_real() -> pd.DataFrame: - with open("tests/data/json_templates/mts_attrs_old_real.json", "r") as file: - metadata_for_mts = json.load(file) - - empty_mts = pd.DataFrame() - empty_mts.attrs = metadata_for_mts - return empty_mts - - -@pytest.fixture(scope="function") -def empty_singletsframe_with_attr() -> pd.DataFrame: - with open("tests/data/json_templates/singletsframe_attrs.json", "r") as file: - metadata_for_sts = json.load(file) - - empty_sts = pd.DataFrame() - empty_sts.attrs = metadata_for_sts - return empty_sts - - -@pytest.fixture(scope="function") -def empty_series_with_old_attr_real() -> pd.Series: - with open("tests/data/json_templates/series_attrs_old_real.json", "r") as file: - metadata_for_series = json.load(file) - - empty_series = pd.Series() - empty_series.attrs = metadata_for_series - return empty_series - - -@pytest.fixture(scope="function") -def empty_series_with_old_attr() -> pd.Series: - with open("tests/data/json_templates/old_series_attrs.json", "r") as file: - metadata_for_series = json.load(file) - - empty_series = pd.Series() - empty_series.attrs = metadata_for_series - return empty_series - - -@pytest.fixture(scope="function") -def empty_series_with_attr() -> pd.Series: - with open("tests/data/json_templates/series_attrs.json", "r") as file: - metadata_for_series = json.load(file) - - empty_series = pd.Series() - empty_series.attrs = metadata_for_series - return empty_series diff --git a/tests/data/json_templates/mts_attrs.json b/tests/data/json_templates/mts_attrs.json deleted file mode 100644 index 055166b..0000000 --- a/tests/data/json_templates/mts_attrs.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "dataset_metadata": { - "metric_key": "id", - "ref_interval_start_timestamp": "2023-01-01T00:00:00+00:00", - "ref_interval_end_timestamp": "2023-01-02T00:00:00Z" - }, - "value_dimensions_shared": [ - { - "column": "pressure", - "unit": "bar", - "name": "shared value_dimension pressure name", - "measurement": "pressure" - }, - { - "column": "temp", - "measurement": "temperature" - } - ], - "metrics": [ - { - "id": "first", - "external_id": "external_first", - "unit": "m", - "display_name": "first display name", - "value_dimensions": [ - { - "column": "temp", - "unit": "C", - "measurement": "temperature" - } - ] - }, - { - "id": "second", - "name": "second name", - "external_id": "external_second", - "value_dimensions": [ - { - "column": "temp", - "unit": "C" - } - ] - }, - { - "id": "third", - "external_id": "external_third", - "measurement": "height", - "value_dimensions": [ - { - "column": "pressure", - "display_name": "thirds's pressure" - } - ] - }, - { - "id": "fourth", - "external_id": "external_fourth", - "unit": "l", - "value_dimensions": [ - { - "column": "pressure", - "unit" : "Pa" - } - ] - }, - { - "id": "fifth", - "external_id": "external_fifth", - "unit": "l", - "value_dimensions": [ - { - "column": "value", - "unit" : "m^3" - } - ] - } - ] -} diff --git a/tests/data/json_templates/mts_attrs_old_real.json b/tests/data/json_templates/mts_attrs_old_real.json deleted file mode 100644 index f2e93a0..0000000 --- a/tests/data/json_templates/mts_attrs_old_real.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "dataset_metadata": { - "ref_interval_start_timestamp": "2025-11-05T13:28:00Z", - "ref_interval_end_timestamp": "2025-11-06T13:28:00Z", - "ref_interval_type": "closed", - "ref_metric": "Wasserstand Ruhr Meschede", - "ref_data_frequency": null, - "ref_data_frequency_offset": null, - "invalidation_interval_start": null, - "invalidation_interval_end": null, - "invalidation_interval_type": null, - "invalidate_dataset": null, - "delete_invalidated": null, - "only_invalidate": null, - "ref_dataset_discrete": null, - "invalidation_timestamp": null, - "new_data_invalidation_date": null - }, - "by_metric": { - "metric1": { - "metric": { - "name": "Wasserstand Ruhr Meschede", - "display_name": null, - "short_display_name": null, - "description": null, - "value_data_type": null, - "external_id": "wasserstand.ruhr.meschede2", - "channel_id": "303fc49a-b515-4fbc-b4fd-70594f053f58" - }, - "inherited": { - "Stationsname": "Meschede (Ruhr)", - "Latitude": 51.347759, - "Longitude": 8.280575 - }, - "value_dimensions": { - "value_dim1": { - "name": "Wasserstand Ruhr Meschede v1", - "display_name": null, - "short_display_name": null, - "description": null, - "unit": "m³/s", - "value_data_type": "float" - }, - "value_dim2": { - "name": "Wasserstand Ruhr Meschede v2", - "display_name": null, - "short_display_name": null, - "description": null, - "unit": "m³/s", - "value_data_type": "float" - } - } - }, - "metric2": { - "metric": { - "name": "Wasserstand Ruhr Meschede", - "display_name": null, - "short_display_name": null, - "description": null, - "value_data_type": null, - "external_id": "wasserstand.ruhr.meschede2", - "channel_id": "303fc49a-b515-4fbc-b4fd-70594f053f59" - }, - "inherited": { - "Stationsname": "Meschede (Ruhr)", - "Latitude": 51.347759, - "Longitude": 8.280575 - }, - "value_dimensions": { - "value_dim1": { - "name": "Wasserstand Ruhr Meschede (2)", - "display_name": null, - "short_display_name": null, - "description": null, - "unit": "m³/h", - "value_data_type": "float" - } - } - } - } -} diff --git a/tests/data/json_templates/old_mts_attrs.json b/tests/data/json_templates/old_mts_attrs.json deleted file mode 100644 index 847708f..0000000 --- a/tests/data/json_templates/old_mts_attrs.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "metrics": { - "test_channel": { - "tenantId": "tenant_a", - "id": "0eeccba1-b38d-48ac-bad7-630a743e241b", - "name": "Some name", - "icon": null, - "type": "FLOAT", - "unit": "l/min", - "externalTimeSeriesId": "some_external_id", - "description": null, - "measurement": null, - "visualization": null, - "virtualChannel": true, - "aggregationType": "ORIGINAL", - "aggregationInterval": null, - "schedulerJob": { - "id": "some_id" - }, - "singleTimeseries": true - }, - "some_other_metric": { - "unit": "l", - "measurement": "volume" - } - } -} diff --git a/tests/data/json_templates/old_series_attrs.json b/tests/data/json_templates/old_series_attrs.json deleted file mode 100644 index 4a9d188..0000000 --- a/tests/data/json_templates/old_series_attrs.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "dataset_metadata": { - "ref_interval_start_timestamp": "2026-02-06T12:45:00Z", - "ref_interval_end_timestamp": "2026-02-06T12:45:00Z", - "ref_interval_type": "closed", - "ref_metric": "Muster Channel", - "ref_data_frequency": null, - "ref_data_frequency_offset": null, - "invalidation_interval_start": null, - "invalidation_interval_end": null, - "invalidation_interval_type": null, - "invalidate_dataset": null, - "delete_invalidated": null, - "only_invalidate": null, - "ref_dataset_discrete": null, - "invalidation_timestamp": null, - "new_data_invalidation_date": null - }, - "single_metric_metadata": { - "structured_metadata": { - "metric": { - "name": "Muster Channel", - "display_name": null, - "short_display_name": "muster", - "description": "*neue Beschreibung", - "unit": "C°", - "value_data_type": null, - "external_id": "raspi_demo.Heizung4.Temperature", - "channel_id": "67d5fc88-af8f-4901-92d1-cc61090a2023" - }, - "inherited": {}, - "value_dimensions": { - "value": { - "display_name": null, - "short_display_name": null, - "description": null, - "unit": "C°", - "value_data_type": "float" - } - } - } - } - } diff --git a/tests/data/json_templates/series_attrs.json b/tests/data/json_templates/series_attrs.json deleted file mode 100644 index 52590ed..0000000 --- a/tests/data/json_templates/series_attrs.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "dataset_metadata": { - "metric_key": "id", - "single_metric": "series", - "ref_interval_start_timestamp": "2023-01-01T00:00:00+00:00", - "ref_interval_end_timestamp": "2023-01-02T00:00:00Z" - }, - "metrics": [ - { - "id": "series", - "external_id": "external_first", - "unit": "m", - "name": "first name" - } - ] -} diff --git a/tests/data/json_templates/series_attrs_old_real.json b/tests/data/json_templates/series_attrs_old_real.json deleted file mode 100644 index 2246548..0000000 --- a/tests/data/json_templates/series_attrs_old_real.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "dataset_metadata": { - "ref_interval_start_timestamp": "2025-11-05T13:28:00Z", - "ref_interval_end_timestamp": "2025-11-06T13:28:00Z", - "ref_interval_type": "closed", - "ref_metric": "Wasserstand", - "ref_data_frequency": null, - "ref_data_frequency_offset": null, - "invalidation_interval_start": null, - "invalidation_interval_end": null, - "invalidation_interval_type": null, - "invalidate_dataset": null, - "delete_invalidated": null, - "only_invalidate": null, - "ref_dataset_discrete": null, - "invalidation_timestamp": null, - "new_data_invalidation_date": null - }, - "single_metric_metadata": { - "structured_metadata": { - "metric": { - "name": "Wasserstand", - "display_name": null, - "short_display_name": "W", - "description": "neu berechneter Wasserstand", - "unit": "m³/s", - "value_data_type": null, - "external_id": "virtual-channel.b4a6c64d-5e72-44a4-a551-e3f51f8e19b0", - "channel_id": "09922b06-2c62-4e18-9c1e-857949674ebb" - }, - "inherited": { - "Stationsname": "Mühlenstrang - Brücke Ruhr", - "Latitude": 51.436477, - "Longitude": 7.583455, - "Referenzniveau": 3000 - }, - "value_dimensions": { - "value": { - "name": "Wasserstand value", - "display_name": null, - "short_display_name": "W value", - "description": null, - "unit": "m³/s value", - "value_data_type": "float" - } - } - } - } -} diff --git a/tests/data/json_templates/singletsframe_attrs.json b/tests/data/json_templates/singletsframe_attrs.json deleted file mode 100644 index 1192ff2..0000000 --- a/tests/data/json_templates/singletsframe_attrs.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "dataset_metadata": { - "single_metric": "abc.temp", - "metric_key": "external_id" - }, - "metrics": [ - { - "external_id": "abc.temp", - "name": "ABC temperature", - "value_dimensions": [ - { - "column": "value", - "name": "temperature", - "unit": "°C" - }, - { - "column": "state", - "name": "measurement state" - } - ] - } - ], - "value_dimensions_shared": [ - { - "column": "state", - "unit": "UNKNOWN" - } - ] -} \ No newline at end of file diff --git a/tests/metadata/test_attached.py b/tests/metadata/test_attached.py new file mode 100644 index 0000000..807af33 --- /dev/null +++ b/tests/metadata/test_attached.py @@ -0,0 +1,159 @@ +import copy +import json +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from hdhelpers.metadata import ( + META_KEY, + MetadataConventionError, + attach_metadata, + attrs_to_json, + check_metadata_conventions, + read_metadata, + set_metadata, +) +from hdhelpers.metadata.attached import infer_data_type +from hdhelpers.metadata.conventions import ( + MULTITSFRAME_METADATA_EXAMPLE, + SINGLETSFRAME_METADATA_EXAMPLE, + MultiTSFrameMetadata, + SingleTSFrameMetadata, +) + +OTHER_ATTRS = {"hetida-designer_filters": {"from": "2026-01-01"}} + + +def multitsframe(attrs: dict[str, Any] | None = None) -> pd.DataFrame: + df = pd.DataFrame( + { + "timestamp": pd.date_range("2026-01-01T06:00", periods=4, freq="h", tz="UTC"), + "metric": ["plant_a.pressure", "plant_a.temperature"] * 2, + "value": np.arange(4.0), + } + ) + df.attrs = copy.deepcopy( + MULTITSFRAME_METADATA_EXAMPLE | OTHER_ATTRS if attrs is None else attrs + ) + return df + + +def singletsframe() -> pd.DataFrame: + df = pd.DataFrame( + { + "timestamp": pd.date_range("2026-01-01T06:00", periods=2, freq="h", tz="UTC"), + "value": [1.0, 2.0], + } + ) + df.attrs = copy.deepcopy(SINGLETSFRAME_METADATA_EXAMPLE) + return df + + +def test_infer_data_type() -> None: + assert infer_data_type(pd.Series([1.0])) == "SERIES" + assert infer_data_type(multitsframe()) == "MULTITSFRAME" + assert infer_data_type(singletsframe()) == "SINGLETSFRAME" + + +class TestAttach: + def test_model_replaces_the_convention_keys(self) -> None: + df = multitsframe() + + meta = attach_metadata(df) + + assert isinstance(meta, MultiTSFrameMetadata) + assert df.attrs == OTHER_ATTRS | {META_KEY: meta} + + def test_attached_model_stays(self) -> None: + df = multitsframe() + meta = attach_metadata(df) + + assert attach_metadata(df) is meta + + @pytest.mark.parametrize( + "attrs", + [ + {}, + OTHER_ATTRS, + {"metrics": []}, + MULTITSFRAME_METADATA_EXAMPLE | {"dataset_metadata": {"metric_key": "id"}}, + ], + ) + def test_attrs_without_valid_metadata_stay(self, attrs: dict[str, Any]) -> None: + df = multitsframe(attrs) + + assert attach_metadata(df) is None + assert df.attrs == attrs + + +class TestRead: + def test_attached_model(self) -> None: + df = multitsframe() + meta = attach_metadata(df) + + assert read_metadata(df) is meta + assert read_metadata(df, "MULTITSFRAME") is meta + assert read_metadata(df[df["value"] > 1]) is meta + + def test_metadata_of_another_data_type(self) -> None: + df = singletsframe() + meta = attach_metadata(df) + series = df["value"] + + assert read_metadata(series) is meta + assert read_metadata(series, "SERIES") is None + with pytest.raises(MetadataConventionError, match="SINGLETSFRAME metadata"): + read_metadata(series, "SERIES", required=True) + + def test_plain_attrs_are_parsed_without_attaching(self) -> None: + df = singletsframe() + + meta = read_metadata(df) + + assert isinstance(meta, SingleTSFrameMetadata) + assert df.attrs == SINGLETSFRAME_METADATA_EXAMPLE + + def test_no_metadata(self) -> None: + df = multitsframe(OTHER_ATTRS) + + assert read_metadata(df) is None + with pytest.raises(MetadataConventionError, match="No metadata"): + read_metadata(df, required=True) + + def test_invalid_metadata(self) -> None: + attrs = copy.deepcopy(MULTITSFRAME_METADATA_EXAMPLE) + attrs["metrics"][0]["default_timezone"] = "Mars/Base" + df = multitsframe(attrs) + + assert read_metadata(df) is None + with pytest.raises(MetadataConventionError) as raised: + read_metadata(df, required=True) + assert [issue.location for issue in raised.value.issues] == ["metrics[0].default_timezone"] + + +def test_set_metadata_replaces_attached_metadata_and_convention_keys() -> None: + df = multitsframe() + meta = MultiTSFrameMetadata.model_validate(MULTITSFRAME_METADATA_EXAMPLE) + + set_metadata(df, meta) + + assert df.attrs == OTHER_ATTRS | {META_KEY: meta} + + +def test_attrs_to_json_reproduces_the_attrs() -> None: + df = multitsframe() + attach_metadata(df) + + assert attrs_to_json(df.attrs) == MULTITSFRAME_METADATA_EXAMPLE | OTHER_ATTRS + assert json.loads(json.dumps(attrs_to_json(df.attrs))) == attrs_to_json(df.attrs) + assert attrs_to_json(OTHER_ATTRS) == OTHER_ATTRS + + +def test_check_metadata_conventions_with_attached_metadata() -> None: + df = multitsframe() + issues = check_metadata_conventions(df) + attach_metadata(df) + + assert check_metadata_conventions(df) == issues diff --git a/tests/metadata/test_conventions.py b/tests/metadata/test_conventions.py new file mode 100644 index 0000000..ee4c00f --- /dev/null +++ b/tests/metadata/test_conventions.py @@ -0,0 +1,1039 @@ +from copy import deepcopy +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from hdhelpers.metadata.conventions import ( + MULTITSFRAME_METADATA_EXAMPLE, + SERIES_METADATA_EXAMPLE, + SINGLETSFRAME_METADATA_EXAMPLE, + MetadataConventionIssue, + MetadataIssueSeverity, + check_metadata_conventions, +) + +SIGNAL_ID = "7ab8c419-8745-4714-b588-57c0c772cc95" + + +def value_dimension(column: str = "value", **fields: Any) -> dict[str, Any]: + return {"column": column, "unit": None, "measurement": None} | fields + + +def metric(metric_id: str, **fields: Any) -> dict[str, Any]: + return { + "id": metric_id, + "name": f"name of {metric_id}", + "value_dimensions": [value_dimension()], + } | fields + + +def dataset_metadata(**fields: Any) -> dict[str, Any]: + return { + "metric_key": "id", + "queried_metrics": [], + "ref_interval_start_timestamp": "2026-10-18T00:00:00Z", + "ref_interval_end_timestamp": "2026-10-19T00:00:00Z", + "ref_interval_type": "closed", + } | fields + + +def series_metadata(**metric_fields: Any) -> dict[str, Any]: + return { + "dataset_metadata": dataset_metadata(single_metric="a", queried_metrics=["a"]), + "metrics": [metric("a", **metric_fields)], + } + + +def multitsframe_metadata(*metric_ids: str) -> dict[str, Any]: + return { + "dataset_metadata": dataset_metadata(queried_metrics=list(metric_ids)), + "metrics": [metric(metric_id) for metric_id in metric_ids], + } + + +def with_dataset_metadata(metadata: dict[str, Any], **fields: Any) -> dict[str, Any]: + changed = deepcopy(metadata) + changed["dataset_metadata"] |= fields + return changed + + +def remove(metadata: dict[str, Any], *path: str | int) -> dict[str, Any]: + removed = deepcopy(metadata) + obj: Any = removed + for part in path[:-1]: + obj = obj[part] + del obj[path[-1]] + return removed + + +# a comment as the hetida platform sends it: Jackson serializes Instants as UTC isoformat strings +# with Z and includes null fields +platform_comment: dict[str, Any] = { + "id": "0b5f0f0e-6d1c-4f7e-9a51-1f3c2a9e8d11", + "start_date": "2026-10-18T06:00:00Z", + "end_date": "2026-10-18T07:30:00Z", + "created_at": "2026-10-18T08:12:45.123Z", + "edited_at": None, + "content": "Pump maintenance", + "username": "jdoe", + "tags": ["maintenance"], + "replies": [ + { + "id": "5e1c7d2a-8b3f-4c6e-9d0a-2f4b6c8e0a13", + "created_at": "2026-10-18T09:00:00Z", + "edited_at": "2026-10-18T09:05:00.500Z", + "content": "Done, pump runs again", + "username": "asmith", + } + ], +} + +# the metadata the hetida platform sends for a timeseries of a SERIES read +platform_series_metadata: dict[str, Any] = { + "dataset_metadata": { + "metric_key": "id", + "single_metric": SIGNAL_ID, + "queried_metrics": [SIGNAL_ID], + "signal_group": None, + "ref_interval_start_timestamp": "2026-10-18T00:00:00Z", + "ref_interval_end_timestamp": "2026-10-19T00:00:00Z", + "ref_interval_type": "closed", + "ref_data_frequency": None, + "ref_data_frequency_offset": None, + "invalidation_interval_start": None, + "invalidation_interval_end": None, + "invalidation_interval_type": None, + "invalidate_dataset": None, + "delete_invalidated": None, + "only_invalidate": None, + "ref_dataset_discrete": None, + "invalidation_timestamp": None, + "new_data_invalidation_date": None, + }, + "metrics": [ + { + "id": SIGNAL_ID, + "external_id": "water.north.level", + "name": "Water-lvl-north", + "display_name": None, + "description": None, + # property values of the platform, possibly as strings + "inherited": {"signal_role": "level", "latitude": "51.43"}, + "comments": [ + platform_comment, + # a comment on a single point in time without tags and replies + platform_comment + | { + "start_date": "2026-10-18T12:00:00Z", + "end_date": "2026-10-18T12:00:00Z", + "tags": [], + "replies": [], + }, + ], + "value_dimensions": [ + { + "column": "value", + "name": "Water-lvl-north", + "display_name": None, + "short_display_name": None, + "description": None, + "unit": "m", + "measurement": "level", + "value_data_type": "float", + } + ], + "default_aggregation_method": "AVG", + } + ], +} + +# the metadata the hetida platform sends for a MULTITSFRAME read of a signal group +platform_multitsframe_metadata: dict[str, Any] = { + "dataset_metadata": platform_series_metadata["dataset_metadata"] + | { + "single_metric": None, + "queried_metrics": [SIGNAL_ID, "pump"], + "signal_group": {"id": "3c2e1d4f", "name": "Pumps north", "description": None}, + }, + "metrics": [ + platform_series_metadata["metrics"][0] | {"display_name": "Water level"}, + platform_series_metadata["metrics"][0] + | { + "id": "pump", + "external_id": None, + "comments": [], + "value_dimensions": [value_dimension()], + "default_aggregation_method": None, + }, + ], +} + + +def multitsframe(attrs: dict[str, Any], metrics: list[str], **columns: list) -> pd.DataFrame: + frame = pd.DataFrame( + { + "timestamp": pd.date_range("2026-10-18T06:00:00Z", periods=len(metrics), freq="1h"), + "metric": metrics, + "value": np.arange(len(metrics), dtype=float), + } + | columns + ) + frame.attrs = deepcopy(attrs) + return frame + + +def series(attrs: dict[str, Any]) -> pd.Series: + ser = pd.Series([1.0, 2.0], index=pd.date_range("2026-10-18T06:00:00Z", periods=2, freq="1h")) + ser.attrs = deepcopy(attrs) + return ser + + +def singletsframe(attrs: dict[str, Any]) -> pd.DataFrame: + frame = pd.DataFrame( + { + # in the ref interval of SINGLETSFRAME_METADATA_EXAMPLE + "timestamp": pd.date_range("2026-01-01T06:00:00Z", periods=2, freq="1h"), + "value": [21.3, 21.5], + "state": [0, 1], + } + ) + frame.attrs = deepcopy(attrs) + return frame + + +def issues_as_tuples( + issues: list[MetadataConventionIssue], +) -> list[tuple[MetadataIssueSeverity, str]]: + return [(issue.severity, issue.location) for issue in issues] + + +def errors_of(issues: list[MetadataConventionIssue]) -> list[str]: + return [issue.location for issue in issues if issue.severity == MetadataIssueSeverity.ERROR] + + +@pytest.mark.parametrize( + ("obj", "data_type"), + [ + (SERIES_METADATA_EXAMPLE, "SERIES"), + (SINGLETSFRAME_METADATA_EXAMPLE, "SINGLETSFRAME"), + (MULTITSFRAME_METADATA_EXAMPLE, "MULTITSFRAME"), + (platform_series_metadata, "SERIES"), + (platform_multitsframe_metadata, "MULTITSFRAME"), + (multitsframe_metadata(), "MULTITSFRAME"), + (series(platform_series_metadata), None), + (series(series_metadata()), None), + (multitsframe(platform_multitsframe_metadata, [SIGNAL_ID, "pump"]), None), + (multitsframe(multitsframe_metadata("a", "b"), ["a", "b", "a"]), None), + (singletsframe(SINGLETSFRAME_METADATA_EXAMPLE), None), + ], +) +def test_conforming_metadata(obj: Any, data_type: Any) -> None: + assert check_metadata_conventions(obj, data_type) == [] + + +@pytest.mark.parametrize( + ("data_type", "metadata", "location"), + [ + ("SERIES", {}, "dataset_metadata"), + ("SERIES", remove(series_metadata(), "metrics"), "metrics"), + ( + "SERIES", + remove(series_metadata(), "dataset_metadata", "metric_key"), + "dataset_metadata.metric_key", + ), + ( + "SERIES", + remove(series_metadata(), "dataset_metadata", "queried_metrics"), + "dataset_metadata.queried_metrics", + ), + ( + "SERIES", + remove(series_metadata(), "dataset_metadata", "single_metric"), + "dataset_metadata.single_metric", + ), + ("SERIES", remove(series_metadata(), "metrics", 0, "id"), "metrics[0].id"), + ("SERIES", remove(series_metadata(), "metrics", 0, "name"), "metrics[0].name"), + ( + "SERIES", + remove(series_metadata(), "metrics", 0, "value_dimensions"), + "metrics[0].value_dimensions", + ), + ( + "SERIES", + remove(series_metadata(), "metrics", 0, "value_dimensions", 0, "column"), + "metrics[0].value_dimensions[0].column", + ), + ( + "SERIES", + remove(series_metadata(), "metrics", 0, "value_dimensions", 0, "unit"), + "metrics[0].value_dimensions[0].unit", + ), + ( + "SERIES", + remove(series_metadata(), "metrics", 0, "value_dimensions", 0, "measurement"), + "metrics[0].value_dimensions[0].measurement", + ), + ("SINGLETSFRAME", remove(series_metadata(), "metrics", 0, "name"), "metrics[0].name"), + ( + "MULTITSFRAME", + remove(multitsframe_metadata("a"), "dataset_metadata", "queried_metrics"), + "dataset_metadata.queried_metrics", + ), + ], +) +def test_required_fields(data_type: Any, metadata: dict[str, Any], location: str) -> None: + issues = check_metadata_conventions(metadata, data_type) + + assert location in errors_of(issues) + assert next(issue for issue in issues if issue.location == location).message == ( + "Field required" + ) + + +@pytest.mark.parametrize("data_type", ["SERIES", "SINGLETSFRAME"]) +@pytest.mark.parametrize("metric_count", [0, 2]) +def test_single_metric_types_have_exactly_one_metric(data_type: Any, metric_count: int) -> None: + metadata = series_metadata() + metadata["metrics"] = [metric(f"m{index}") for index in range(metric_count)] + + assert "metrics" in errors_of(check_metadata_conventions(metadata, data_type)) + + +def test_value_dimensions_must_not_be_empty() -> None: + assert "metrics[0].value_dimensions" in errors_of( + check_metadata_conventions(series_metadata(value_dimensions=[]), "SERIES") + ) + + +def test_ref_interval_is_required_unless_discrete() -> None: + metadata = with_dataset_metadata( + series_metadata(), + ref_interval_start_timestamp=None, + ref_interval_end_timestamp=None, + ref_interval_type=None, + ) + + assert errors_of(check_metadata_conventions(metadata, "SERIES")) == [ + "dataset_metadata.ref_interval_start_timestamp", + "dataset_metadata.ref_interval_end_timestamp", + "dataset_metadata.ref_interval_type", + ] + + metadata["dataset_metadata"]["ref_dataset_discrete"] = True + assert check_metadata_conventions(metadata, "SERIES") == [] + + +@pytest.mark.parametrize( + ("metadata", "location", "message_part"), + [ + ([1, 2], "", "object"), + ( + with_dataset_metadata(series_metadata(), ref_interval_type="half_open"), + "dataset_metadata.ref_interval_type", + "closed", + ), + ( + with_dataset_metadata( + series_metadata(), ref_interval_start_timestamp="2026-10-18T00:00:00+02:00" + ), + "dataset_metadata.ref_interval_start_timestamp", + "ending with Z or +00:00, got '2026-10-18T00:00:00+02:00'.", + ), + ( + with_dataset_metadata(series_metadata(), only_invalidate="true"), + "dataset_metadata.only_invalidate", + "boolean", + ), + (series_metadata(id=42), "metrics[0].id", "string"), + (series_metadata(name=None), "metrics[0].name", "string"), + ( + series_metadata(value_dimensions=[value_dimension(unit=3)]), + "metrics[0].value_dimensions[0].unit", + "string", + ), + ( + series_metadata(default_aggregation_method="mean"), + "metrics[0].default_aggregation_method", + "MEAN", + ), + ( + series_metadata(comments=[{"created_at": "2026-10-18T08:12:45"}]), + "metrics[0].comments[0].created_at", + "UTC isoformat timestamp", + ), + ( + series_metadata(comments=[platform_comment | {"tags": "maintenance"}]), + "metrics[0].comments[0].tags", + "array", + ), + ( + series_metadata() | {"metrics": {"a": {"unit": "m"}}}, + "metrics", + "older metadata convention", + ), + ], +) +def test_structure_errors(metadata: Any, location: str, message_part: str) -> None: + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.ERROR, location)] + assert message_part in issues[0].message + + +@pytest.mark.parametrize( + "method", ["MEAN", "AVG", "MEDIAN", "SUM", "MIN", "MAX", "FIRST", "LAST", "COUNT", None] +) +def test_aggregation_methods(method: Any) -> None: + metadata = series_metadata(default_aggregation_method=method) + + assert check_metadata_conventions(metadata, "SERIES") == [] + + +def test_inherited_properties_are_arbitrary() -> None: + metadata = series_metadata(inherited={"latitude": "51.43", "longitude": 7.58, "unit": None}) + + assert check_metadata_conventions(metadata, "SERIES") == [] + + +def test_recording_and_location_information() -> None: + metadata = with_dataset_metadata( + series_metadata( + path=["Waterworks North", "Gauge North"], + origin="OPC_UA", + recording_mode="periodic", + data_frequency="15min", + data_frequency_offset="0s", + recording_aggregation_method="AVG", + ), + query_timestamp="2026-10-19T00:05:00Z", + default_aggregation_interval="1h", + ) + + assert check_metadata_conventions(metadata, "SERIES") == [] + + +@pytest.mark.parametrize( + "metadata", + [ + # the frequency of the dataset defines the aggregated intervals + with_dataset_metadata( + series_metadata(recording_mode="periodic", recording_aggregation_method="MAX"), + ref_data_frequency="1h", + ), + series_metadata( + recording_mode="on_change", + data_frequency="1D", + value_dimensions=[value_dimension(deadband=0)], + ), + series_metadata(recording_mode="irregular"), + series_metadata(data_frequency="P1D"), + # calendar based frequencies and offsets of fixed duration + series_metadata(recording_mode="periodic", data_frequency="MS"), + series_metadata(data_frequency="1D", data_frequency_offset="23h"), + series_metadata(data_frequency="h", data_frequency_offset="59min"), + with_dataset_metadata( + series_metadata(), ref_data_frequency="1h", ref_data_frequency_offset="0s" + ), + with_dataset_metadata(series_metadata(), default_aggregation_interval="1MS"), + # intervals restarting at local midnight, e.g. compressed by the hetida platform + series_metadata( + recording_mode="periodic", + data_frequency="2h", + data_frequency_timezone="Europe/Berlin", + recording_aggregation_method="MEAN", + ), + series_metadata( + data_frequency="1D", data_frequency_offset="6h", data_frequency_timezone="+05:30" + ), + with_dataset_metadata( + series_metadata(recording_mode="periodic"), + ref_data_frequency="4h", + ref_data_frequency_timezone="America/New_York", + ), + ], +) +def test_consistent_recording_information(metadata: dict[str, Any]) -> None: + assert check_metadata_conventions(metadata, "SERIES") == [] + + +@pytest.mark.parametrize( + ("metadata", "location", "message_part"), + [ + (series_metadata(path="Plant A/Unit"), "metrics[0].path", "array"), + (series_metadata(path=["Plant A", 3]), "metrics[0].path[1]", "string"), + (series_metadata(recording_mode="continuous"), "metrics[0].recording_mode", "periodic"), + ( + series_metadata(recording_aggregation_method="mean"), + "metrics[0].recording_aggregation_method", + "MEAN", + ), + ( + series_metadata(value_dimensions=[value_dimension(deadband=-0.1)]), + "metrics[0].value_dimensions[0].deadband", + "greater than or equal to 0", + ), + ( + with_dataset_metadata(series_metadata(), query_timestamp="2026-10-19T02:05:00+02:00"), + "dataset_metadata.query_timestamp", + "UTC isoformat timestamp", + ), + ( + with_dataset_metadata(series_metadata(), query_timestamp="2026-10-18T24:00:00Z"), + "dataset_metadata.query_timestamp", + "UTC isoformat timestamp", + ), + ( + with_dataset_metadata(series_metadata(), default_aggregation_interval=60), + "dataset_metadata.default_aggregation_interval", + "string", + ), + ], +) +def test_recording_and_location_structure_errors( + metadata: Any, location: str, message_part: str +) -> None: + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.ERROR, location)] + assert message_part in issues[0].message + + +@pytest.mark.parametrize( + ("metadata", "location", "message_part"), + [ + ( + with_dataset_metadata(series_metadata(), ref_data_frequency="every 5 minutes"), + "dataset_metadata.ref_data_frequency", + "not a Pandas date offset alias", + ), + ( + with_dataset_metadata( + series_metadata(), ref_data_frequency="1h", ref_data_frequency_offset="" + ), + "dataset_metadata.ref_data_frequency_offset", + "not a Pandas date offset alias", + ), + ( + with_dataset_metadata(series_metadata(), ref_data_frequency_offset="5min"), + "dataset_metadata.ref_data_frequency_offset", + "Requires ref_data_frequency", + ), + # the offset of the dataset only applies together with its frequency + ( + with_dataset_metadata( + series_metadata(data_frequency_offset="5min"), ref_data_frequency="1h" + ), + "metrics[0].data_frequency_offset", + "Requires data_frequency", + ), + ( + series_metadata(data_frequency="15min", data_frequency_offset="15min"), + "metrics[0].data_frequency_offset", + 'not smaller than data_frequency "15min"', + ), + ( + series_metadata(data_frequency="1D", data_frequency_offset="-1h"), + "metrics[0].data_frequency_offset", + "not a non-negative timedelta", + ), + ( + series_metadata(data_frequency="1D", data_frequency_offset="MS"), + "metrics[0].data_frequency_offset", + "not a non-negative timedelta", + ), + ( + series_metadata(data_frequency="1D", data_frequency_offset="1D"), + "metrics[0].data_frequency_offset", + 'not smaller than data_frequency "1D"', + ), + ( + series_metadata(data_frequency="1"), + "metrics[0].data_frequency", + "not a Pandas date offset alias", + ), + (series_metadata(data_frequency="0s"), "metrics[0].data_frequency", "not positive"), + ( + series_metadata(data_frequency_timezone="Europe/Berlin"), + "metrics[0].data_frequency_timezone", + "Requires data_frequency", + ), + # the time zone of the dataset only applies together with its frequency + ( + with_dataset_metadata( + series_metadata(data_frequency_timezone="Europe/Berlin"), ref_data_frequency="1h" + ), + "metrics[0].data_frequency_timezone", + "Requires data_frequency", + ), + ( + with_dataset_metadata(series_metadata(), ref_data_frequency_timezone="Europe/Berlin"), + "dataset_metadata.ref_data_frequency_timezone", + "Requires ref_data_frequency", + ), + ( + series_metadata(data_frequency="2h", data_frequency_timezone="Europe/Berlinn"), + "metrics[0].data_frequency_timezone", + "IANA time zone name", + ), + ( + with_dataset_metadata( + series_metadata(), ref_data_frequency="2h", ref_data_frequency_timezone="CEST" + ), + "dataset_metadata.ref_data_frequency_timezone", + "IANA time zone name", + ), + (series_metadata(data_frequency="-15min"), "metrics[0].data_frequency", "not positive"), + ( + with_dataset_metadata(series_metadata(), default_aggregation_interval="-1MS"), + "dataset_metadata.default_aggregation_interval", + "not positive", + ), + ( + with_dataset_metadata(series_metadata(), default_aggregation_interval="1month"), + "dataset_metadata.default_aggregation_interval", + "not a Pandas date offset alias", + ), + ( + series_metadata(data_frequency="hourly"), + "metrics[0].data_frequency", + "not a Pandas date offset alias", + ), + ( + series_metadata(data_frequency="1h", data_frequency_offset="4 mins later"), + "metrics[0].data_frequency_offset", + "not a Pandas date offset alias", + ), + ( + series_metadata( + recording_mode="on_change", recording_aggregation_method="MEAN", data_frequency="1h" + ), + "metrics[0].recording_aggregation_method", + "Only meaningful for recording_mode periodic", + ), + ( + series_metadata(recording_aggregation_method="MEAN", data_frequency="1h"), + "metrics[0].recording_aggregation_method", + "Only meaningful for recording_mode periodic", + ), + ( + series_metadata(recording_mode="periodic", recording_aggregation_method="MEAN"), + "metrics[0].recording_mode", + "periodic requires data_frequency", + ), + ( + series_metadata(recording_mode="periodic"), + "metrics[0].recording_mode", + "periodic requires data_frequency", + ), + ], +) +def test_recording_consistency_errors( + metadata: dict[str, Any], location: str, message_part: str +) -> None: + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.ERROR, location)] + assert message_part in issues[0].message + + +@pytest.mark.parametrize("timezone", ["Europe/Berlin", "UTC", "Etc/GMT-2", "+02:00", "-05:30"]) +def test_default_timezones(timezone: str) -> None: + metadata = with_dataset_metadata( + series_metadata(default_timezone=timezone), default_timezone=timezone + ) + + assert check_metadata_conventions(metadata, "SERIES") == [] + + +@pytest.mark.parametrize( + "timezone", + [ + "Europe/Berln", + "europe/berlin", + "Europe", + "UTC+02:00", + "+2:00", + "+15:00", + "", + # files of the system's time zone database, which are no time zone names + "posixrules", + "right/UTC", + "localtime", + ], +) +@pytest.mark.parametrize("level", ["dataset_metadata", "metrics[0]"]) +def test_invalid_default_timezones(timezone: str, level: str) -> None: + metadata = ( + with_dataset_metadata(series_metadata(), default_timezone=timezone) + if level == "dataset_metadata" + else series_metadata(default_timezone=timezone) + ) + + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.ERROR, f"{level}.default_timezone")] + assert "IANA time zone name" in issues[0].message + + +def test_default_timezone_must_be_a_string() -> None: + issues = check_metadata_conventions(series_metadata(default_timezone=1), "SERIES") + + assert issues_as_tuples(issues) == [ + (MetadataIssueSeverity.ERROR, "metrics[0].default_timezone") + ] + + +@pytest.mark.parametrize("decimals", [0, 3, None]) +def test_decimals(decimals: Any) -> None: + metadata = series_metadata(value_dimensions=[value_dimension(decimals=decimals)]) | { + "value_dimensions_shared": [{"column": "value", "decimals": decimals}] + } + + assert check_metadata_conventions(metadata, "SERIES") == [] + + +@pytest.mark.parametrize( + ("decimals", "message_part"), + [(-1, "greater than or equal to 0"), (1.5, "integer"), ("2", "integer"), (True, "integer")], +) +def test_invalid_decimals(decimals: Any, message_part: str) -> None: + metadata = series_metadata(value_dimensions=[value_dimension(decimals=decimals)]) + + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [ + (MetadataIssueSeverity.ERROR, "metrics[0].value_dimensions[0].decimals") + ] + assert message_part in issues[0].message + + +@pytest.mark.parametrize("recording_mode", [None, "periodic", "irregular"]) +def test_deadband_without_on_change_recording_warns(recording_mode: Any) -> None: + metadata = series_metadata( + recording_mode=recording_mode, + data_frequency="1h", + value_dimensions=[value_dimension(deadband=0.5)], + ) + + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [ + (MetadataIssueSeverity.WARNING, "metrics[0].value_dimensions[0].deadband") + ] + + +@pytest.mark.parametrize( + "metadata", + [ + with_dataset_metadata( + series_metadata(), ref_interval_start_timestamp=pd.Timestamp("2026-01-01", tz="UTC") + ), + series_metadata(inherited={"elevation": float("nan")}), + ], +) +def test_metadata_not_json_serializable(metadata: dict[str, Any]) -> None: + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.ERROR, "")] + assert "JSON serializable" in issues[0].message + + +@pytest.mark.parametrize( + ("data_type", "metadata", "location"), + [ + ( + "MULTITSFRAME", + with_dataset_metadata(multitsframe_metadata("a"), metric_key="external_id"), + "metrics[0]", + ), + ("MULTITSFRAME", multitsframe_metadata("a", "b", "a"), "metrics"), + ( + "SERIES", + series_metadata(value_dimensions=[value_dimension(), value_dimension()]), + "metrics[0].value_dimensions", + ), + ( + "MULTITSFRAME", + multitsframe_metadata("a") + | {"value_dimensions_shared": [{"column": "value"}, {"column": "value"}]}, + "value_dimensions_shared", + ), + ( + "MULTITSFRAME", + with_dataset_metadata(multitsframe_metadata("a", "b"), single_metric="a"), + "dataset_metadata.single_metric", + ), + ( + "SERIES", + with_dataset_metadata(series_metadata(), single_metric="b"), + "dataset_metadata.single_metric", + ), + ( + "SERIES", + with_dataset_metadata( + series_metadata(), only_invalidate=True, invalidate_dataset=False + ), + "dataset_metadata.only_invalidate", + ), + ( + "SERIES", + with_dataset_metadata( + series_metadata(), invalidation_interval_end="2026-01-01T00:00:00Z" + ), + "dataset_metadata.invalidation_interval_start", + ), + ( + "SERIES", + with_dataset_metadata( + series_metadata(), ref_interval_start_timestamp="2026-10-20T00:00:00.000000000Z" + ), + "dataset_metadata.ref_interval_start_timestamp", + ), + ( + "SERIES", + series_metadata(comments=[platform_comment | {"end_date": None}]), + "metrics[0].comments[0].end_date", + ), + ( + "SERIES", + series_metadata(comments=[platform_comment | {"start_date": "2026-10-18T08:00:00Z"}]), + "metrics[0].comments[0].start_date", + ), + # a SERIES has the value dimension "value", which must be described + ( + "SERIES", + series_metadata(value_dimensions=[value_dimension("temperature")]), + "metrics[0].value_dimensions", + ), + ( + "SERIES", + with_dataset_metadata(series_metadata(), queried_metrics=["b"]), + "dataset_metadata.single_metric", + ), + ], +) +def test_consistency_errors(data_type: Any, metadata: dict[str, Any], location: str) -> None: + assert errors_of(check_metadata_conventions(metadata, data_type)) == [location] + + +@pytest.mark.parametrize( + ("metadata", "location"), + [ + ( + with_dataset_metadata( + series_metadata(), ref_interval_start_timestamp="2026-02-30T00:00:00Z" + ), + "dataset_metadata.ref_interval_start_timestamp", + ), + ( + with_dataset_metadata(series_metadata(), query_timestamp="2026-04-31T00:00:00Z"), + "dataset_metadata.query_timestamp", + ), + ( + series_metadata(comments=[platform_comment | {"end_date": "2026-02-29T00:00:00Z"}]), + "metrics[0].comments[0].end_date", + ), + ], +) +def test_timestamps_must_be_actual_points_in_time(metadata: dict[str, Any], location: str) -> None: + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.ERROR, location)] + assert "not an actual point in time" in issues[0].message + + +@pytest.mark.parametrize("value_data_type", ["float", "integer", "boolean", "string", None]) +def test_value_data_types(value_data_type: Any) -> None: + metadata = series_metadata(value_dimensions=[value_dimension(value_data_type=value_data_type)]) + + assert check_metadata_conventions(metadata, "SERIES") == [] + + +def test_unknown_value_data_type() -> None: + metadata = series_metadata(value_dimensions=[value_dimension(value_data_type="double")]) + + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [ + (MetadataIssueSeverity.ERROR, "metrics[0].value_dimensions[0].value_data_type") + ] + assert "'float', 'integer', 'boolean' or 'string'" in issues[0].message + + +@pytest.mark.parametrize("with_data", [True, False]) +def test_queried_metrics_without_metadata_warn(with_data: bool) -> None: + metadata = with_dataset_metadata( + multitsframe_metadata("a"), queried_metrics=["a", "without data", "b"] + ) + obj = multitsframe(metadata, ["a", "b"]) if with_data else metadata + + issues = check_metadata_conventions(obj, "MULTITSFRAME") + + # with data, "b" is reported as metric of the data missing under metrics instead + assert issues_as_tuples(issues) == ( + [(MetadataIssueSeverity.ERROR, "metrics"), (MetadataIssueSeverity.WARNING, "metrics")] + if with_data + else [(MetadataIssueSeverity.WARNING, "metrics")] + ) + assert issues[-1].message.endswith('"without data"' if with_data else '"b", "without data"') + + +@pytest.mark.parametrize( + ("ref_interval", "in_ref_interval"), + [ + (("2026-10-18T06:00:00Z", "2026-10-18T07:00:00Z", "closed"), True), + (("2026-10-18T06:00:00Z", "2026-10-18T07:00:00Z", "right_open"), False), + (("2026-10-18T06:00:00Z", "2026-10-18T08:00:00Z", "right_open"), True), + (("2026-10-18T06:00:00Z", "2026-10-18T07:00:00Z", "left_open"), False), + (("2026-10-18T06:30:00Z", "2026-10-18T07:00:00Z", "closed"), False), + (("2026-10-18T06:00:00Z", "2026-10-18T06:30:00Z", "closed"), False), + ], +) +def test_data_outside_of_ref_interval_warns( + ref_interval: tuple[str, str, str], in_ref_interval: bool +) -> None: + start, end, interval_type = ref_interval + metadata = with_dataset_metadata( + series_metadata(), + ref_interval_start_timestamp=start, + ref_interval_end_timestamp=end, + ref_interval_type=interval_type, + ) + + # the series has values at 06:00 and 07:00 + issues = check_metadata_conventions(series(metadata)) + + assert issues_as_tuples(issues) == ( + [] if in_ref_interval else [(MetadataIssueSeverity.WARNING, "dataset_metadata")] + ) + + +def test_discrete_dataset_is_not_compared_with_ref_interval() -> None: + metadata = with_dataset_metadata(series_metadata(), ref_dataset_discrete=True) + for field in ("ref_interval_start_timestamp", "ref_interval_end_timestamp"): + metadata["dataset_metadata"][field] = "2026-01-01T00:00:00Z" + + assert check_metadata_conventions(series(metadata)) == [] + + +def test_consistency_is_only_checked_for_valid_structure() -> None: + metadata = with_dataset_metadata(multitsframe_metadata("a", "a"), ref_interval_type="half_open") + + assert issues_as_tuples(check_metadata_conventions(metadata, "MULTITSFRAME")) == [ + (MetadataIssueSeverity.ERROR, "dataset_metadata.ref_interval_type") + ] + + +def test_metadata_without_data_requires_data_type() -> None: + with pytest.raises(ValueError, match="data type is required"): + check_metadata_conventions(series_metadata()) + + +def test_multitsframe_inconsistent_with_metadata() -> None: + metadata = multitsframe_metadata("a", "b") + metadata["dataset_metadata"]["queried_metrics"] = ["a"] + metadata["metrics"][0]["value_dimensions"].append(value_dimension("quality")) + + issues = check_metadata_conventions(multitsframe(metadata, ["a", "b", "c"], state=[0, 1, 0])) + + assert issues_as_tuples(issues) == [ + (MetadataIssueSeverity.ERROR, "metrics"), + (MetadataIssueSeverity.ERROR, "dataset_metadata.queried_metrics"), + (MetadataIssueSeverity.ERROR, "metrics[0].value_dimensions"), + (MetadataIssueSeverity.ERROR, "metrics[1].value_dimensions"), + (MetadataIssueSeverity.WARNING, "metrics[0].value_dimensions[1].column"), + ] + assert '"c"' in issues[0].message + assert '"b", "c"' in issues[1].message + assert '"state"' in issues[2].message + assert '"quality"' in issues[4].message + + +def test_multitsframe_metric_listing_is_shortened() -> None: + metric_ids = [f"m{i:02}" for i in range(15)] + metadata = multitsframe_metadata("a") + metadata["dataset_metadata"]["queried_metrics"] = ["a", *metric_ids] + + issues = check_metadata_conventions(multitsframe(metadata, ["a", *metric_ids])) + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.ERROR, "metrics")] + assert '"m09" and 5 more' in issues[0].message + + +def test_singletsframe_inconsistent_with_metadata() -> None: + metadata = deepcopy(SINGLETSFRAME_METADATA_EXAMPLE) + del metadata["metrics"][0]["value_dimensions"][1] + metadata["value_dimensions_shared"] = [{"column": "quality"}] + + assert issues_as_tuples(check_metadata_conventions(singletsframe(metadata))) == [ + (MetadataIssueSeverity.ERROR, "metrics[0].value_dimensions"), + (MetadataIssueSeverity.WARNING, "value_dimensions_shared[0].column"), + ] + + +@pytest.mark.parametrize( + ("metadata", "location", "message_part"), + [ + ( + series_metadata(displayName="A"), + "metrics[0].displayName", + 'Did you mean "display_name"?', + ), + ( + series_metadata(value_dimensions=[value_dimension(measurment="level")]), + "metrics[0].value_dimensions[0].measurment", + 'Did you mean "measurement"?', + ), + (series_metadata(unit="m"), "metrics[0].unit", "under value_dimensions instead"), + ( + with_dataset_metadata(series_metadata(), ref_metrics=["a"]), + "dataset_metadata.ref_metrics", + "Additional fields are allowed", + ), + ( + series_metadata(comments=[platform_comment | {"author": "jdoe"}]), + "metrics[0].comments[0].author", + "Additional fields are allowed", + ), + (series_metadata() | {"anomalies": []}, "anomalies", "Additional fields are allowed"), + ], +) +def test_unknown_fields(metadata: dict[str, Any], location: str, message_part: str) -> None: + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.WARNING, location)] + assert message_part in issues[0].message + + +def test_custom_metric_key_field_is_not_unknown() -> None: + metadata = with_dataset_metadata( + series_metadata(externalTimeSeriesId="a"), metric_key="externalTimeSeriesId" + ) + + assert check_metadata_conventions(metadata, "SERIES") == [] + + +def test_unknown_fields_explain_missing_required_fields() -> None: + metadata = series_metadata() + metadata["metrics"][0]["valueDimensions"] = metadata["metrics"][0].pop("value_dimensions") + + issues = check_metadata_conventions(metadata, "SERIES") + + assert issues_as_tuples(issues) == [ + (MetadataIssueSeverity.ERROR, "metrics[0].value_dimensions"), + (MetadataIssueSeverity.WARNING, "metrics[0].valueDimensions"), + ] + assert 'Did you mean "value_dimensions"?' in issues[1].message + + +def test_deprecated_channel_id() -> None: + metadata = multitsframe_metadata("a", "b") + metadata["metrics"][0]["channel_id"] = "abc" + metadata["metrics"][1] |= {"signal_id": "def", "channel_id": None} + + issues = check_metadata_conventions(metadata, "MULTITSFRAME") + + assert issues_as_tuples(issues) == [(MetadataIssueSeverity.WARNING, "metrics[0].channel_id")] + assert '"signal_id"' in issues[0].message diff --git a/tests/metadata/test_conventions_immutable.py b/tests/metadata/test_conventions_immutable.py new file mode 100644 index 0000000..0472ba0 --- /dev/null +++ b/tests/metadata/test_conventions_immutable.py @@ -0,0 +1,188 @@ +import copy +import pickle +from collections.abc import Mapping +from typing import Any + +import numpy as np +import pandas as pd +import pytest +from pydantic import ValidationError + +from hdhelpers.metadata.conventions import ( + METADATA_MODELS, + MULTITSFRAME_METADATA_EXAMPLE, + SERIES_METADATA_EXAMPLE, + SINGLETSFRAME_METADATA_EXAMPLE, + FrozenDict, + MultiTSFrameMetadata, +) + +EXAMPLES = { + "SERIES": SERIES_METADATA_EXAMPLE, + "SINGLETSFRAME": SINGLETSFRAME_METADATA_EXAMPLE, + "MULTITSFRAME": MULTITSFRAME_METADATA_EXAMPLE, +} + + +def multitsframe_metadata(**metric_fields: Any) -> MultiTSFrameMetadata: + attrs = copy.deepcopy(MULTITSFRAME_METADATA_EXAMPLE) + attrs["metrics"][0] |= metric_fields + return MultiTSFrameMetadata.model_validate(attrs) + + +@pytest.mark.parametrize("data_type", EXAMPLES) +def test_dump_reproduces_the_input(data_type: str) -> None: + meta = METADATA_MODELS[data_type].model_validate(EXAMPLES[data_type]) + + assert meta.model_dump(mode="json") == EXAMPLES[data_type] + + +def test_dump_leaves_out_optional_fields_with_none_or_default() -> None: + attrs = copy.deepcopy(MULTITSFRAME_METADATA_EXAMPLE) + attrs["dataset_metadata"] |= {"ref_dataset_discrete": False, "default_timezone": None} + attrs["metrics"][0]["display_name"] = None + attrs["metrics"][0]["value_dimensions"][0]["unit"] = None + + dumped = MultiTSFrameMetadata.model_validate(attrs).model_dump(mode="json") + + assert "ref_dataset_discrete" not in dumped["dataset_metadata"] + assert "default_timezone" not in dumped["dataset_metadata"] + assert "display_name" not in dumped["metrics"][0] + # required, but nullable + assert dumped["metrics"][0]["value_dimensions"][0]["unit"] is None + + +def test_dump_keeps_extra_fields_with_none() -> None: + meta = multitsframe_metadata(anomaly_score=None) + + assert meta.model_dump(mode="json")["metrics"][0]["anomaly_score"] is None + + +def test_lists_become_tuples() -> None: + meta = multitsframe_metadata(path=["site", "area"]) + + assert isinstance(meta.metrics, tuple) + assert isinstance(meta.metrics[0].value_dimensions, tuple) + assert meta.metrics[0].path == ("site", "area") + + +def test_fields_cannot_be_changed() -> None: + meta = multitsframe_metadata() + + with pytest.raises(ValidationError, match="frozen"): + meta.metrics[0].name = "other" # type: ignore[misc] + + +def test_extra_values_are_frozen() -> None: + meta = multitsframe_metadata( + inherited={"site": "Site A", "asset": {"type": "pump", "tags": ["a", "b"]}}, + anomaly_scores=[{"score": 0.5}], + ) + inherited = meta.metrics[0].inherited + assert inherited is not None + asset = inherited.asset # type: ignore[attr-defined] + + assert isinstance(asset, FrozenDict) + assert isinstance(asset, Mapping) + assert asset["tags"] == ("a", "b") + assert meta.metrics[0].anomaly_scores == (FrozenDict(score=0.5),) # type: ignore[attr-defined] + with pytest.raises(TypeError, match="immutable"): + asset["type"] = "valve" + with pytest.raises(TypeError, match="immutable"): + asset.update(type="valve") + + +@pytest.mark.parametrize("value", [True, 1, 1.5, "text", None]) +def test_scalar_extra_values_are_kept(value: Any) -> None: + meta = multitsframe_metadata(extra_value=value) + + assert meta.metrics[0].extra_value == value # type: ignore[attr-defined] + assert type(meta.metrics[0].extra_value) is type(value) # type: ignore[attr-defined] + + +@pytest.mark.parametrize( + ("field", "value"), [("decimals", 1.0), ("decimals", "1"), ("deadband", "0.5")] +) +def test_numbers_are_not_coerced(field: str, value: Any) -> None: + attrs = copy.deepcopy(MULTITSFRAME_METADATA_EXAMPLE) + attrs["metrics"][0]["value_dimensions"][0][field] = value + + with pytest.raises(ValidationError): + MultiTSFrameMetadata.model_validate(attrs) + + +def test_booleans_are_not_coerced() -> None: + attrs = copy.deepcopy(MULTITSFRAME_METADATA_EXAMPLE) + attrs["dataset_metadata"]["only_invalidate"] = "true" + + with pytest.raises(ValidationError): + MultiTSFrameMetadata.model_validate(attrs) + + +def test_copies_are_the_original() -> None: + meta = multitsframe_metadata(inherited={"asset": {"type": "pump"}}) + + assert copy.deepcopy(meta) is meta + assert copy.deepcopy(meta.metrics[0]) is meta.metrics[0] + assert copy.deepcopy(meta.metrics[0].inherited.asset) is meta.metrics[0].inherited.asset # type: ignore[union-attr] + + +def test_model_copy_never_changes_the_original() -> None: + meta = multitsframe_metadata() + + changed = meta.model_copy(update={"metrics": meta.metrics[:1]}, deep=True) + + assert len(meta.metrics) == 2 + assert len(changed.metrics) == 1 + assert changed.metrics[0] is meta.metrics[0] + + +def test_pickle_round_trip() -> None: + meta = multitsframe_metadata(inherited={"asset": {"tags": ["a"]}}) + + unpickled = pickle.loads(pickle.dumps(meta)) # noqa: S301 + + assert unpickled == meta + assert isinstance(unpickled.metrics[0].inherited.asset, FrozenDict) + + +def multitsframe(meta: MultiTSFrameMetadata) -> pd.DataFrame: + df = pd.DataFrame( + { + "timestamp": pd.date_range("2026-01-01", periods=4, freq="h", tz="UTC"), + "metric": ["plant_a.pressure", "plant_a.temperature"] * 2, + "value": np.arange(4.0), + } + ) + df.attrs = {"hd_metadata": meta} + return df + + +def test_pandas_operations_share_the_model() -> None: + meta = multitsframe_metadata() + df = multitsframe(meta) + + derived = [ + df[df["value"] > 0], + df.sort_values("value"), + df.reset_index(drop=True), + df["value"], + df.copy(deep=False), + df.copy(), + pd.concat([df, df.copy()]), + ] + + assert all(obj.attrs["hd_metadata"] is meta for obj in derived) + assert all(obj.attrs is not df.attrs for obj in derived) + + +def test_reading_does_not_change_the_equality() -> None: + meta = multitsframe_metadata() + other = multitsframe_metadata() + + meta.unit("plant_a.pressure") + + assert meta == other + assert meta != meta.with_metric("plant_a.pressure", name="changed") + # pandas keeps attrs only if they are equal + assert pd.concat([multitsframe(meta), multitsframe(other)]).attrs["hd_metadata"] is meta diff --git a/tests/metadata/test_conventions_rules.py b/tests/metadata/test_conventions_rules.py new file mode 100644 index 0000000..dccea28 --- /dev/null +++ b/tests/metadata/test_conventions_rules.py @@ -0,0 +1,240 @@ +"""The models reject everything check_metadata_conventions reports as error""" + +import copy +import datetime +from collections.abc import Callable +from typing import Any + +import pandas as pd +import pytest +from pydantic import ValidationError + +from hdhelpers.metadata.conventions import ( + METADATA_MODELS, + MULTITSFRAME_METADATA_EXAMPLE, + SERIES_METADATA_EXAMPLE, + SINGLETSFRAME_METADATA_EXAMPLE, + MetadataIssueSeverity, + MetricMetadata, + MultiTSFrameDatasetMetadata, + MultiTSFrameMetadata, + ValueDimensionMetadata, + check_metadata_conventions, +) + +EXAMPLES = { + "SERIES": SERIES_METADATA_EXAMPLE, + "SINGLETSFRAME": SINGLETSFRAME_METADATA_EXAMPLE, + "MULTITSFRAME": MULTITSFRAME_METADATA_EXAMPLE, +} + + +def changed(data_type: str, change: Callable[[dict[str, Any]], Any]) -> dict[str, Any]: + attrs = copy.deepcopy(EXAMPLES[data_type]) + change(attrs) + return attrs + + +def first_metric(attrs: dict[str, Any]) -> dict[str, Any]: + return attrs["metrics"][0] + + +def unhashable_metric_ids(attrs: dict[str, Any]) -> None: + attrs["dataset_metadata"]["metric_key"] = "custom_key" + for metric in attrs["metrics"]: + metric["custom_key"] = [metric["external_id"]] + + +# (data type, change of its example, location of the error) +RULE_VIOLATIONS = [ + ("MULTITSFRAME", unhashable_metric_ids, "metrics[0]"), + ( + "MULTITSFRAME", + lambda a: first_metric(a).pop("external_id"), + "metrics[0]", + ), + ( + "MULTITSFRAME", + lambda a: a["metrics"][1].update(external_id=first_metric(a)["external_id"]), + "metrics", + ), + ( + "MULTITSFRAME", + lambda a: first_metric(a)["value_dimensions"].append( + first_metric(a)["value_dimensions"][0] + ), + "metrics[0].value_dimensions", + ), + ("MULTITSFRAME", lambda a: first_metric(a).update(data_frequency="often"), None), + ("MULTITSFRAME", lambda a: first_metric(a).update(data_frequency="-1h"), None), + ("MULTITSFRAME", lambda a: first_metric(a).update(data_frequency_offset="5min"), None), + ( + "MULTITSFRAME", + lambda a: first_metric(a).update(data_frequency="1h", data_frequency_offset="2h"), + None, + ), + ("MULTITSFRAME", lambda a: first_metric(a).update(default_timezone="Mars/Base"), None), + ("MULTITSFRAME", lambda a: a["dataset_metadata"].update(default_timezone="CEST"), None), + ( + "MULTITSFRAME", + lambda a: first_metric(a).update(recording_mode="periodic"), + "metrics[0].recording_mode", + ), + ( + "MULTITSFRAME", + lambda a: first_metric(a).update( + comments=[{"start_date": "2026-01-02T00:00:00Z", "end_date": "2026-01-01T00:00:00Z"}] + ), + None, + ), + ( + "MULTITSFRAME", + lambda a: a["dataset_metadata"].update(only_invalidate=True, invalidate_dataset=False), + "dataset_metadata.only_invalidate", + ), + ( + "MULTITSFRAME", + lambda a: a["dataset_metadata"].pop("ref_interval_start_timestamp"), + None, + ), + ( + "MULTITSFRAME", + lambda a: a["dataset_metadata"].update( + ref_interval_start_timestamp="2026-01-03T00:00:00+00:00" + ), + None, + ), + ( + "MULTITSFRAME", + lambda a: a["dataset_metadata"].update(default_aggregation_interval="sometimes"), + None, + ), + ( + "SINGLETSFRAME", + lambda a: a["dataset_metadata"].update(single_metric="another metric"), + "dataset_metadata.single_metric", + ), + ( + "SERIES", + lambda a: first_metric(a)["value_dimensions"][0].update(column="other"), + "metrics[0].value_dimensions", + ), +] + + +@pytest.mark.parametrize(("data_type", "change", "location"), RULE_VIOLATIONS) +def test_models_reject_rule_violations( + data_type: str, change: Callable[[dict[str, Any]], Any], location: str | None +) -> None: + attrs = changed(data_type, change) + errors = [ + issue + for issue in check_metadata_conventions(attrs, data_type) # type: ignore[arg-type] + if issue.severity == MetadataIssueSeverity.ERROR + ] + assert len(errors) > 0 + if location is not None: + assert location in [error.location for error in errors] + + with pytest.raises(ValidationError, match="Violates the metadata conventions") as raised: + METADATA_MODELS[data_type].model_validate(attrs) + assert errors[0].location in str(raised.value) + + +def test_warnings_do_not_make_metadata_invalid() -> None: + attrs = changed( + "MULTITSFRAME", + lambda a: first_metric(a).update(channel_id="deprecated", unknown_field=1), + ) + issues = check_metadata_conventions(attrs, "MULTITSFRAME") + assert len(issues) > 0 + assert all(issue.severity == MetadataIssueSeverity.WARNING for issue in issues) + + MultiTSFrameMetadata.model_validate(attrs) + + +def nested_models(default_timezone: str) -> MultiTSFrameMetadata: + return MultiTSFrameMetadata( + dataset_metadata=MultiTSFrameDatasetMetadata.model_validate( + MULTITSFRAME_METADATA_EXAMPLE["dataset_metadata"] + ), + metrics=( + MetricMetadata( + id="m1", + external_id="plant_a.pressure", + name="Pressure", + default_timezone=default_timezone, + value_dimensions=( + ValueDimensionMetadata(column="value", unit="bar", measurement=None), + ), + ), + ), + ) + + +def test_rules_apply_to_nested_models() -> None: + assert nested_models("UTC").metrics[0].default_timezone == "UTC" + with pytest.raises(ValidationError, match=r"metrics\[0\].default_timezone"): + nested_models("Mars/Base") + + +@pytest.mark.parametrize( + "value", + [ + datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), + datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone(datetime.timedelta(0))), + pd.Timestamp("2026-01-01", tz="UTC"), + ], +) +def test_timestamps_from_utc_datetimes(value: datetime.datetime) -> None: + attrs = changed( + "MULTITSFRAME", lambda a: a["dataset_metadata"].update(ref_interval_start_timestamp=value) + ) + + meta = MultiTSFrameMetadata.model_validate(attrs) + + assert meta.dataset_metadata.ref_interval_start_timestamp == "2026-01-01T00:00:00+00:00" + + +@pytest.mark.parametrize( + ("value", "message_part"), + [ + (datetime.datetime(2026, 1, 1), "naive"), # noqa: DTZ001 + (pd.Timestamp("2026-01-01T01:00", tz="Europe/Berlin"), "not in UTC"), + ], +) +def test_timestamps_from_other_datetimes_are_rejected(value: Any, message_part: str) -> None: + attrs = changed( + "MULTITSFRAME", lambda a: a["dataset_metadata"].update(ref_interval_start_timestamp=value) + ) + + with pytest.raises(ValidationError, match=message_part): + MultiTSFrameMetadata.model_validate(attrs) + + +@pytest.mark.parametrize( + "extra_value", + [ + datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), + float("nan"), + float("inf"), + [1, float("nan")], + {1: "integer key"}, + {"nested": {"value": object()}}, + ], +) +def test_extra_values_must_be_json(extra_value: Any) -> None: + attrs = changed("MULTITSFRAME", lambda a: first_metric(a).update(extra=extra_value)) + + with pytest.raises(ValidationError): + MultiTSFrameMetadata.model_validate(attrs) + + +def test_deadband_must_be_finite() -> None: + attrs = changed( + "MULTITSFRAME", + lambda a: first_metric(a)["value_dimensions"][0].update(deadband=float("inf")), + ) + + with pytest.raises(ValidationError, match="deadband"): + MultiTSFrameMetadata.model_validate(attrs) diff --git a/tests/metadata/test_conventions_schema.py b/tests/metadata/test_conventions_schema.py new file mode 100644 index 0000000..a3602c0 --- /dev/null +++ b/tests/metadata/test_conventions_schema.py @@ -0,0 +1,36 @@ +"""Snapshot of the JSON Schemas of the metadata conventions + +The hetida designer publishes these schemas in its openapi.json, so a change of the snapshot is a +change of the contract. To accept an intended change, run the tests with +UPDATE_SCHEMA_SNAPSHOT=1 and review the diff of the snapshot file. +""" + +import json +import os +from pathlib import Path +from typing import Any + +from pydantic.json_schema import models_json_schema + +from hdhelpers.metadata.conventions import METADATA_MODELS + +SNAPSHOT = Path(__file__).parent.parent / "data" / "convention_schemas.json" + + +def convention_schemas() -> dict[str, Any]: + _, schemas = models_json_schema( + [(model, "validation") for model in METADATA_MODELS.values()], + ref_template="#/$defs/{model}", + ) + return schemas + + +def test_json_schemas_match_snapshot() -> None: + schemas = convention_schemas() + if os.environ.get("UPDATE_SCHEMA_SNAPSHOT"): + SNAPSHOT.write_text( + json.dumps(schemas, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + assert schemas == json.loads(SNAPSHOT.read_text(encoding="utf-8")) diff --git a/tests/metadata/test_metadata_without_metadata.py b/tests/metadata/test_copy_without_metadata.py similarity index 81% rename from tests/metadata/test_metadata_without_metadata.py rename to tests/metadata/test_copy_without_metadata.py index 3a2c344..d67cc99 100644 --- a/tests/metadata/test_metadata_without_metadata.py +++ b/tests/metadata/test_copy_without_metadata.py @@ -1,7 +1,7 @@ import pandas as pd import pytest -from hdhelpers.metadata import without_metadata +from hdhelpers.metadata import copy_without_metadata class NotCopyable: @@ -18,11 +18,11 @@ def __deepcopy__(self, memo): pd.Series([1.0, 2.0], index=pd.date_range("2026-01-01", periods=2, tz="UTC"), name="a"), ], ) -def test_without_metadata(data): +def test_copy_without_metadata(data): metadata = {"metrics": [{"id": "a"}], "not copyable": NotCopyable()} data.attrs = metadata - copy = without_metadata(data) + copy = copy_without_metadata(data) assert copy.attrs == {} assert data.attrs == metadata @@ -40,12 +40,12 @@ def test_changing_the_copy_does_not_change_the_data(): data = pd.DataFrame({"value": [1.0, 2.0]}) data.attrs = {"metrics": []} - copy = without_metadata(data) + copy = copy_without_metadata(data) copy.loc[0, "value"] = -1.0 assert data.loc[0, "value"] == 1.0 -def test_without_metadata_requires_series_or_dataframe(): +def test_copy_without_metadata_requires_series_or_dataframe(): with pytest.raises(TypeError): - without_metadata([1.0, 2.0]) + copy_without_metadata([1.0, 2.0]) diff --git a/tests/metadata/test_helpers.py b/tests/metadata/test_helpers.py new file mode 100644 index 0000000..3821559 --- /dev/null +++ b/tests/metadata/test_helpers.py @@ -0,0 +1,485 @@ +"""The get_* functions, thin wrappers over the reading methods of the models""" + +import datetime +from copy import deepcopy +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from hdhelpers.metadata import ( + Recording, + attach_metadata, + get_default_aggregation_methods, + get_default_timezones, + get_display_names, + get_measurements, + get_metric_info, + get_names, + get_queried_interval, + get_recordings, + get_series_default_aggregation_method, + get_series_default_timezone, + get_series_display_name, + get_series_info, + get_series_measurement, + get_series_name, + get_series_recording, + get_series_short_display_name, + get_series_unit, + get_short_display_names, + get_singlets_default_aggregation_method, + get_singlets_default_timezone, + get_singlets_display_names, + get_singlets_info, + get_singlets_metric_info, + get_singlets_recording, + get_singlets_short_display_names, + get_singlets_units, + get_units, +) + +SIGNAL_ID = "7ab8c419-8745-4714-b588-57c0c772cc95" +REF_INTERVAL = { + "ref_interval_start_timestamp": "2026-10-18T00:00:00Z", + "ref_interval_end_timestamp": "2026-10-19T00:00:00Z", + "ref_interval_type": "closed", +} + + +def value_dimension(**fields: Any) -> dict[str, Any]: + """Value dimension "value" as the hetida platform sends it, with nulls for missing fields""" + return { + "column": "value", + "name": None, + "display_name": None, + "short_display_name": None, + "description": None, + "unit": None, + "measurement": None, + "value_data_type": "float", + } | fields + + +def metric(**fields: Any) -> dict[str, Any]: + """Metric as the hetida platform sends it, with nulls for missing fields""" + return { + "id": SIGNAL_ID, + "external_id": None, + "name": "signal", + "display_name": None, + "description": None, + "inherited": {}, + "comments": [], + "value_dimensions": [value_dimension()], + "default_aggregation_method": None, + } | fields + + +def dataset_metadata(**fields: Any) -> dict[str, Any]: + return {"metric_key": "id", "queried_metrics": []} | REF_INTERVAL | fields + + +# what the hetida platform sends for a timeseries of a SERIES read +platform_series_attrs = { + "dataset_metadata": dataset_metadata(single_metric=SIGNAL_ID, queried_metrics=[SIGNAL_ID]), + "metrics": [ + metric( + external_id="water.north.level", + name="Water-lvl-north", + value_dimensions=[ + value_dimension(name="Water-lvl-north", unit="m", measurement="level") + ], + default_aggregation_method="AVG", + ) + ], +} + +# what the hetida platform sends for a MULTITSFRAME read of a signal group: display_name of +# a metric is the display name configured for the member of the group +platform_multitsframe_attrs = { + "dataset_metadata": dataset_metadata(queried_metrics=["level", "flow"]), + "metrics": [ + metric( + id="level", + name="Water-lvl-north", + value_dimensions=[value_dimension(name="Water-lvl-north", unit="m")], + ), + metric( + id="flow", + name="Pump-flow", + display_name="Flow pump 1", + value_dimensions=[value_dimension(name="Pump-flow")], + ), + ], +} + +singletsframe_attrs = { + "dataset_metadata": dataset_metadata( + metric_key="external_id", single_metric="abc.temp", queried_metrics=["abc.temp"] + ), + "metrics": [ + metric( + external_id="abc.temp", + name="ABC temperature", + display_name="ABC temp.", + value_dimensions=[ + value_dimension(name="temperature", unit="°C"), + value_dimension(column="state", name="measurement state"), + ], + ) + ], +} + + +def series(attrs: dict[str, Any]) -> pd.Series: + ser = pd.Series([1.0], index=pd.to_datetime(["2026-10-18T06:00:00Z"])) + ser.attrs = deepcopy(attrs) + return ser + + +def frame(attrs: dict[str, Any]) -> pd.DataFrame: + df = pd.DataFrame() + df.attrs = deepcopy(attrs) + return df + + +def test_series_from_platform() -> None: + platform_series = series(platform_series_attrs) + + assert get_series_unit(platform_series) == "m" + assert get_series_measurement(platform_series) == "level" + assert get_series_name(platform_series) == "Water-lvl-north" + assert get_series_display_name(platform_series) == "Water-lvl-north" + assert get_series_short_display_name(platform_series) == "Water-lvl-north" + assert get_series_info(platform_series, "value_data_type") == "float" + + +def test_multitsframe_from_platform() -> None: + multitsframe = frame(platform_multitsframe_attrs) + + assert get_units(multitsframe)["level"]["value"] == "m" + assert get_units(multitsframe)["flow"]["value"] is None + assert get_names(multitsframe)["flow"]["value"] == "Pump-flow" + assert get_measurements(multitsframe)["level"]["value"] is None + # a null display name falls back to the name + assert get_display_names(multitsframe)["level"]["value"] == "Water-lvl-north" + assert get_short_display_names(multitsframe)["level"]["value"] == "Water-lvl-north" + # the display name of the metric wins over the name of its value dimension + assert get_display_names(multitsframe)["flow"]["value"] == "Flow pump 1" + assert get_short_display_names(multitsframe)["flow"]["value"] == "Flow pump 1" + assert get_metric_info(multitsframe, "name")["flow"] == "Pump-flow" + + +def test_each_field_is_looked_up_on_all_levels_before_the_next_field() -> None: + attrs = { + "dataset_metadata": dataset_metadata(), + "metrics": [ + metric( + id="a", + name="metric name", + value_dimensions=[ + value_dimension(name="value name"), + value_dimension(column="state", name="state name"), + ], + ), + metric(id="b", name="metric name", display_name="metric display name"), + metric( + id="c", + display_name="metric display name", + value_dimensions=[value_dimension(display_name="value display name")], + ), + ], + "value_dimensions_shared": [ + {"column": "state", "display_name": "shared display name"}, + ], + } + + display_names = get_display_names(frame(attrs)) + + assert display_names["a"]["value"] == "value name" + assert display_names["a"]["state"] == "shared display name" + assert display_names["b"]["value"] == "metric display name" + assert display_names["c"]["value"] == "value display name" + + +def test_singletsframe() -> None: + singletsframe = frame(singletsframe_attrs) + + assert get_singlets_units(singletsframe)["value"] == "°C" + # only the value dimension "value" falls back to the metric + assert get_singlets_display_names(singletsframe)["value"] == "ABC temp." + assert get_singlets_display_names(singletsframe)["state"] == "measurement state" + assert get_singlets_short_display_names(singletsframe)["state"] == "measurement state" + assert get_singlets_info(singletsframe, "name")["state"] == "measurement state" + assert get_singlets_info(singletsframe, "name")["unknown column"] is None + assert get_singlets_metric_info(singletsframe, "name") == "ABC temperature" + + +def test_series_display_name_of_metric_wins_over_value_dimension_name() -> None: + attrs = deepcopy(platform_series_attrs) + attrs["metrics"][0]["display_name"] = "Level north" + attrs["metrics"][0]["short_display_name"] = "north" + + assert get_series_display_name(series(attrs)) == "Level north" + assert get_series_short_display_name(series(attrs)) == "north" + + +def test_series_display_name_does_not_fall_back_to_short_display_name() -> None: + attrs = deepcopy(platform_series_attrs) + attrs["metrics"][0]["value_dimensions"][0]["name"] = None + attrs["metrics"][0]["short_display_name"] = "north" + + assert get_series_display_name(series(attrs)) == "Water-lvl-north" + assert get_series_short_display_name(series(attrs)) == "north" + + +def test_metadata_not_following_the_conventions_gives_no_information() -> None: + attrs = deepcopy(platform_series_attrs) + # the single metric is another one than the metric present + attrs["dataset_metadata"]["single_metric"] = "another signal" + + assert get_series_unit(series(attrs)) is None + assert get_singlets_units(frame(attrs))["value"] is None + assert get_singlets_metric_info(frame(attrs), "name") is None + assert get_units(frame(attrs))[SIGNAL_ID]["value"] is None + + +def test_metric_ids_which_are_no_strings_give_no_information() -> None: + attrs = deepcopy(platform_series_attrs) + attrs["dataset_metadata"]["metric_key"] = "custom_key" + attrs["metrics"][0]["custom_key"] = [SIGNAL_ID] + + assert get_series_unit(series(attrs)) is None + assert get_units(frame(attrs))[SIGNAL_ID]["value"] is None + + +@pytest.mark.parametrize( + "attrs", [{}, {"dataset_metadata": {"metric_key": "id"}}, {"metrics": None}] +) +def test_without_metadata(attrs: dict[str, Any]) -> None: + """Components should not require metadata, so missing metadata must not raise""" + assert get_metric_info(frame(attrs), "name")["any metric"] is None + assert get_series_unit(series(attrs)) is None + assert get_singlets_units(frame(attrs))["value"] is None + assert get_default_timezones(frame(attrs))["any metric"] is None + assert get_recordings(frame(attrs))["any metric"] == Recording() + assert get_series_default_aggregation_method(series(attrs)) == "MEAN" + assert get_queried_interval(series(attrs)) == (None, None) + + +def multitsframe_with_data(attrs: dict[str, Any]) -> pd.DataFrame: + df = pd.DataFrame( + { + "timestamp": pd.date_range("2026-10-18T06:00", periods=2, freq="h", tz="UTC"), + "metric": ["level", "flow"], + "value": np.arange(2.0), + } + ) + df.attrs = deepcopy(attrs) + return df + + +def test_attached_metadata_of_another_data_type() -> None: + """E.g. a Series taken from a MultiTSFrame, whose metadata it carries""" + attrs = deepcopy(platform_multitsframe_attrs) + attrs["dataset_metadata"]["default_timezone"] = "UTC" + attrs["metrics"][0]["default_timezone"] = "Asia/Tokyo" + multitsframe = multitsframe_with_data(attrs) + attach_metadata(multitsframe) + values = multitsframe["value"] + + # several metrics, none of which is the single one + assert get_series_name(values) is None + assert get_series_default_timezone(values) == "UTC" + + one_metric = multitsframe[multitsframe["metric"] == "level"] + assert get_units(one_metric)["level"]["value"] == "m" + + +def test_attached_singletsframe_metadata_on_a_series() -> None: + singletsframe = pd.DataFrame( + { + "timestamp": pd.date_range("2026-10-18T06:00", periods=2, freq="h", tz="UTC"), + "value": [1.0, 2.0], + "state": ["ok", "ok"], + } + ) + singletsframe.attrs = deepcopy(singletsframe_attrs) + attach_metadata(singletsframe) + + assert get_series_unit(singletsframe["value"]) == "°C" + assert get_series_display_name(singletsframe["value"]) == "ABC temp." + + +@pytest.mark.parametrize("include_inherited", [False, True]) +def test_short_display_names_of_metrics_resolved_by_different_fields( + include_inherited: bool, +) -> None: + """Each metric gets its information from the first field providing it, independently""" + attrs = { + "dataset_metadata": dataset_metadata(), + "metrics": [ + metric( + id="a", + short_display_name="a short", + value_dimensions=[value_dimension(), value_dimension(column="state")], + ), + metric(id="b", display_name="b display", name="b name"), + metric(id="c", name="c name"), + ], + "value_dimensions_shared": [{"column": "state", "name": "shared state"}], + } + + short_display_names = get_short_display_names(frame(attrs), include_inherited=include_inherited) + + assert short_display_names["a"]["value"] == "a short" + assert short_display_names["a"]["state"] == "shared state" + assert short_display_names["b"]["value"] == "b display" + assert short_display_names["c"]["value"] == "c name" + assert short_display_names["c"]["state"] == "shared state" + + +def test_inherited_properties() -> None: + attrs = deepcopy(platform_multitsframe_attrs) + attrs["metrics"][1]["inherited"] = {"unit": "m³/h", "site": "north"} + + assert get_units(frame(attrs))["flow"]["value"] is None + assert get_units(frame(attrs), include_inherited=True)["flow"]["value"] == "m³/h" + assert get_metric_info(frame(attrs), "site")["flow"] == "north" + assert get_metric_info(frame(attrs), "site", include_inherited=False)["flow"] is None + + +def test_default_timezones_fall_back_to_the_dataset_metadata() -> None: + attrs = { + "dataset_metadata": dataset_metadata( + metric_key="external_id", default_timezone="Europe/Berlin" + ), + "metrics": [ + metric(external_id="new_york", default_timezone="America/New_York"), + metric(id="2", external_id="fixed_offset", default_timezone="+02:00"), + metric(id="3", external_id="without"), + ], + } + + timezones = get_default_timezones(frame(attrs)) + + assert timezones == { + "new_york": "America/New_York", + "fixed_offset": "+02:00", + "without": "Europe/Berlin", + } + assert timezones["metric without metadata"] == "Europe/Berlin" + + +def test_default_timezone_of_single_metric() -> None: + attrs = deepcopy(platform_series_attrs) + attrs["dataset_metadata"]["default_timezone"] = "Europe/Berlin" + + assert get_series_default_timezone(series(attrs)) == "Europe/Berlin" + attrs["metrics"][0]["default_timezone"] = "America/New_York" + assert get_series_default_timezone(series(attrs)) == "America/New_York" + assert get_singlets_default_timezone(frame(attrs)) == "America/New_York" + + +def test_recordings_fall_back_to_the_frequency_of_the_dataset_as_a_whole() -> None: + attrs = { + "dataset_metadata": dataset_metadata( + metric_key="external_id", + ref_data_frequency="1h", + ref_data_frequency_offset="30min", + ref_data_frequency_timezone="Europe/Berlin", + ), + "metrics": [ + metric( + id="1", + external_id="own_frequency", + recording_mode="periodic", + data_frequency="15min", + recording_aggregation_method="MEAN", + ), + metric(id="2", external_id="dataset_frequency", recording_mode="on_change"), + ], + } + dataset_frequency = { + "data_frequency": "1h", + "data_frequency_offset": "30min", + "data_frequency_timezone": "Europe/Berlin", + } + + recordings = get_recordings(frame(attrs)) + + assert recordings == { + "own_frequency": Recording( + recording_mode="periodic", data_frequency="15min", recording_aggregation_method="MEAN" + ), + "dataset_frequency": Recording(recording_mode="on_change", **dataset_frequency), + } + assert recordings["metric without metadata"] == Recording(**dataset_frequency) + + +def test_single_metric_recording() -> None: + attrs = deepcopy(platform_series_attrs) + attrs["metrics"][0] |= {"recording_mode": "periodic", "data_frequency": "15min"} + + expected = Recording(recording_mode="periodic", data_frequency="15min") + assert get_series_recording(series(attrs)) == expected + assert get_singlets_recording(frame(attrs)) == expected + + +def test_default_aggregation_methods() -> None: + attrs = deepcopy(platform_multitsframe_attrs) + attrs["metrics"][0]["default_aggregation_method"] = "SUM" + attrs["metrics"][1]["value_dimensions"][0]["value_data_type"] = "boolean" + + methods = get_default_aggregation_methods(frame(attrs)) + + assert methods == {"level": "SUM", "flow": "LAST"} + assert methods["metric without metadata"] == "MEAN" + assert get_series_default_aggregation_method(series(platform_series_attrs)) == "MEAN" + assert get_singlets_default_aggregation_method(frame(platform_series_attrs)) == "MEAN" + + +def test_queried_interval() -> None: + assert get_queried_interval(series(platform_series_attrs)) == ( + datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC), + datetime.datetime(2026, 10, 19, tzinfo=datetime.UTC), + ) + + +def test_many_metrics() -> None: + attrs = { + "dataset_metadata": dataset_metadata(), + "metrics": [ + metric( + id=f"m{i}", + name=f"Metric {i}", + inherited={"site": "north"}, + value_dimensions=[value_dimension(name=f"Metric {i}", unit="m")], + ) + for i in range(1000) + ], + } + multitsframe = frame(attrs) + + assert get_short_display_names(multitsframe)["m999"]["value"] == "Metric 999" + assert get_units(multitsframe)["m0"]["value"] == "m" + assert get_metric_info(multitsframe, "unit")["m500"] == "m" + assert get_metric_info(multitsframe, "site")["m1"] == "north" + + +@pytest.mark.parametrize( + ("function", "data"), + [ + (get_units, pd.Series(dtype=float)), + (get_default_timezones, pd.Series(dtype=float)), + (get_singlets_units, pd.Series(dtype=float)), + (get_series_unit, pd.DataFrame()), + (get_series_recording, pd.DataFrame()), + (get_queried_interval, {}), + ], +) +def test_wrong_input_types_raise(function: Any, data: Any) -> None: + with pytest.raises(TypeError): + function(data) diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py deleted file mode 100644 index 4e9db3d..0000000 --- a/tests/metadata/test_metadata.py +++ /dev/null @@ -1,625 +0,0 @@ -import datetime - -import pandas as pd -import pytest - -from hdhelpers.metadata import ( - # get_display_names, - # get_measurements, - # get_metric_info, - get_queried_interval, - get_series_display_name, - # get_series_measurement, - get_series_name, - get_series_short_display_name, - get_series_unit, - # get_units, - get_singlets_display_names, - get_singlets_info, - get_singlets_measurements, - get_singlets_metric_info, - get_singlets_names, - get_singlets_short_display_names, - get_singlets_units, -) - - -def test_get_queried_interval(empty_series_with_old_attr_real): - start, end = get_queried_interval(empty_series_with_old_attr_real) - assert start == datetime.datetime(2025, 11, 5, 13, 28, tzinfo=datetime.UTC) - assert end == datetime.datetime(2025, 11, 6, 13, 28, tzinfo=datetime.UTC) - - -def test_doctest_get_queried_interval(): - attr = { - "dataset_metadata": { - "ref_interval_start_timestamp": "2025-11-04T13:28:00Z", - "ref_interval_end_timestamp": "2025-11-07T13:28:00Z", - } - } - series = pd.Series() - series.attrs = attr - start, end = get_queried_interval(series) - - assert start == datetime.datetime(2025, 11, 4, 13, 28, tzinfo=datetime.UTC) - assert end == datetime.datetime(2025, 11, 7, 13, 28, tzinfo=datetime.UTC) - - -def test_get_queried_interval_not_given(): - start, end = get_queried_interval(pd.Series()) - - assert start is None - assert end is None - - -def test_get_series_unit(empty_series_with_old_attr_real): - unit = get_series_unit(empty_series_with_old_attr_real) - assert unit == "m³/s" - - -@pytest.mark.parametrize( - ("attr", "output"), - [ - pytest.param( - {"single_metric_metadata": {"structured_metadata": {"value_dimensions": {}}}}, - None, - id="value not given", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": {"value_dimensions": {"value": {"unit": None}}} - } - }, - None, - id="unit not given", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": {"value_dimensions": {"value": {"unit": "m/s"}}} - } - }, - "m/s", - id="given", - ), - ], -) -def test_doctest_get_series_unit(attr, output): - series = pd.Series() - series.attrs = attr - assert get_series_unit(series) == output - - -def test_get_series_unit_no_attr(): - assert get_series_unit(pd.Series()) is None - - -def test_get_series_name(empty_series_with_old_attr_real): - name = get_series_name(empty_series_with_old_attr_real) - assert name == "Wasserstand" - - -@pytest.mark.parametrize( - ("attr", "output"), - [ - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "value_dimensions": {"value": {"name": "value_name_of_series"}} - } - } - }, - "value_name_of_series", - id="value name", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": {"metric": {"name": "name_of_series"}} - } - }, - "name_of_series", - id="name_of_series", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "metric": {"name": "name_of_series"}, - "value_dimensions": {"value": {"name": "value_name_of_series"}}, - } - } - }, - "name_of_series", - id="metric before value", - ), - ], -) -def test_doctest_get_series_name(attr, output): - series = pd.Series() - series.attrs = attr - assert get_series_name(series) == output - - -def test_get_series_name_no_attr(): - assert get_series_name(pd.Series()) is None - - -def test_get_display_name_series_name(empty_series_with_old_attr_real): - display_name = get_series_display_name(empty_series_with_old_attr_real) - assert display_name == "Wasserstand" - - -@pytest.mark.parametrize( - ("attr", "output"), - [ - pytest.param( - {"by_metric": {"series": {"metric": {"display_name": "metric_display_name"}}}}, - "metric_display_name", - id="metric_display_name", - ), - pytest.param( - { - "by_metric": { - "series": { - "value_dimensions": {"value": {"display_name": "value_display_name"}} - } - } - }, - "value_display_name", - id="value_display_name", - ), - pytest.param( - { - "by_metric": { - "series": { - "metric": {"display_name": "metric_display_name"}, - "value_dimensions": {"value": {"display_name": "value_display_name"}}, - } - } - }, - "metric_display_name", - id="metric before value", - ), - pytest.param( - { - "by_metric": { - "series": { - "metric": {"name": "metric_name"}, - "value_dimensions": {"value": {"display_name": "value_display_name"}}, - } - } - }, - "metric_name", - id="metric name before value display name", - ), - pytest.param( - { - "by_metric": { - "series": { - "metric": {"name": "metric_name"}, - "value_dimensions": {"value": {"name": "value_name"}}, - } - } - }, - "metric_name", - id="metric name before value name", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": {"metric": {"display_name": "metric_display_name"}} - } - }, - "metric_display_name", - id="metric_display_name platform", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "value_dimensions": {"value": {"display_name": "value_display_name"}} - } - } - }, - "value_display_name", - id="value_display_name platform", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "metric": {"display_name": "metric_display_name"}, - "value_dimensions": {"value": {"display_name": "value_display_name"}}, - } - } - }, - "metric_display_name", - id="metric before value platform", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "metric": {"name": "metric_display_name"}, - "value_dimensions": {"value": {"display_name": "value_display_name"}}, - } - } - }, - "metric_display_name", - id="metric before value name platform", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "metric": {"name": "metric_name"}, - "value_dimensions": {"value": {"name": "value_name"}}, - } - } - }, - "metric_name", - id="metric name before value name platform", - ), - ], -) -def test_doctest_get_series_display_name(attr, output): - series = pd.Series() - series.attrs = attr - assert get_series_display_name(series) == output - - -def test_get_series_display_name_no_attr(): - assert get_series_display_name(pd.Series()) is None - - -def test_get_short_display_name_series_name(empty_series_with_old_attr_real): - display_name = get_series_short_display_name(empty_series_with_old_attr_real) - assert display_name == "W" - - -@pytest.mark.parametrize( - ("attr", "output"), - [ - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "metric": {"short_display_name": "short_display_name_of_series"} - } - } - }, - "short_display_name_of_series", - id="metric_short_display_name", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "value_dimensions": { - "value": {"short_display_name": "value_short_display_name"} - } - } - } - }, - "value_short_display_name", - id="value_short_display_name", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "metric": {"short_display_name": "metric_short_display_name"}, - "value_dimensions": { - "value": {"short_display_name": "value_short_display_name"} - }, - } - } - }, - "metric_short_display_name", - id="metric before value", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "metric": {"name": "metric_name"}, - "value_dimensions": {"value": {"display_name": "value_display_name"}}, - } - } - }, - "metric_name", - id="metric display name before value name", - ), - pytest.param( - { - "single_metric_metadata": { - "structured_metadata": { - "metric": {"name": "metric_name"}, - "value_dimensions": {"value": {"name": "value_name"}}, - } - } - }, - "metric_name", - id="metric name before value name", - ), - ], -) -def test_doctest_get_series_short_display_name(attr, output): - series = pd.Series() - series.attrs = attr - assert get_series_short_display_name(series) == output - - -def test_get_names_for_mts_with_metric_info_fallback_option(): - from hdhelpers.metadata import get_display_names, get_names, get_short_display_names - - attr = { - "by_metric": { - "metric1": {"metric": {"name": "name_of_metric1"}}, - "metric2": {"metric": {"name": None}}, - } - } - - attr = { - "by_metric": { - "metric1": {"metric": {"short_display_name": "short_display_name_of_metric1"}}, - "metric2": {"metric": {"name": "name_of_metric2"}}, - "metric3": {}, - } - } - - attr = { - "dataset_metadata": {"metric_key": "column"}, - "metrics": [ - { - "external_id": "column_name", - "value_dimensions": [{"column": "temp", "measurement": "temperature"}], - } - ], - } - - dataframe = pd.DataFrame() - dataframe.attrs = attr - result_name = get_names(dataframe) - result_display_name = get_display_names(dataframe) - result_short_display_name = get_short_display_names(dataframe) - - assert result_name == result_display_name == result_short_display_name - - -def test_get_singlets_info_is_keyed_by_value_dimension_only(empty_singletsframe_with_attr): - """A SingleTSFrame has one metric, so no metric level in the result""" - units = get_singlets_units(empty_singletsframe_with_attr) - - assert units["value"] == "°C" - assert units["state"] == "UNKNOWN" # falls back to value_dimensions_shared - assert units["not-given"] is None - - -def test_get_singlets_names(empty_singletsframe_with_attr): - names = get_singlets_names(empty_singletsframe_with_attr) - - assert names["value"] == "temperature" - assert names["state"] == "measurement state" - assert names["not-given"] is None - - -def test_get_singlets_display_names_fall_back_to_name(empty_singletsframe_with_attr): - empty_singletsframe_with_attr.attrs["metrics"][0]["value_dimensions"][0]["display_name"] = ( - "temp" - ) - - display_names = get_singlets_display_names(empty_singletsframe_with_attr) - - assert display_names["value"] == "temp" - assert display_names["state"] == "measurement state" - - -def test_get_singlets_short_display_names_fall_back(empty_singletsframe_with_attr): - empty_singletsframe_with_attr.attrs["metrics"][0]["value_dimensions"][0][ - "short_display_name" - ] = "T" - empty_singletsframe_with_attr.attrs["metrics"][0]["value_dimensions"][1]["display_name"] = ( - "state" - ) - - short_display_names = get_singlets_short_display_names(empty_singletsframe_with_attr) - - assert short_display_names["value"] == "T" - assert short_display_names["state"] == "state" - - -def test_get_singlets_measurements(empty_singletsframe_with_attr): - empty_singletsframe_with_attr.attrs["metrics"][0]["value_dimensions"][0]["measurement"] = ( - "temperature" - ) - - measurements = get_singlets_measurements(empty_singletsframe_with_attr) - - assert measurements["value"] == "temperature" - assert measurements["state"] is None - - -def test_get_singlets_info_with_arbitrary_spec(empty_singletsframe_with_attr): - assert get_singlets_info(empty_singletsframe_with_attr, "unit")["value"] == "°C" - assert get_singlets_info(empty_singletsframe_with_attr, "not-given")["value"] is None - - -def test_get_singlets_metric_info(empty_singletsframe_with_attr): - assert get_singlets_metric_info(empty_singletsframe_with_attr, "name") == "ABC temperature" - assert get_singlets_metric_info(empty_singletsframe_with_attr, "external_id") == "abc.temp" - assert get_singlets_metric_info(empty_singletsframe_with_attr, "not-given") is None - - -def test_get_singlets_metric_key_defaults_to_id(): - """metric_key is optional per convention and defaults to "id" """ - empty_singletsframe_with_attr = pd.DataFrame() - empty_singletsframe_with_attr.attrs = { - "dataset_metadata": {"single_metric": "abc.temp"}, - "metrics": [{"id": "abc.temp", "value_dimensions": [{"column": "value", "unit": "m"}]}], - } - - assert get_singlets_units(empty_singletsframe_with_attr)["value"] == "m" - assert get_singlets_metric_info(empty_singletsframe_with_attr, "id") == "abc.temp" - - -def test_get_singlets_falls_back_to_only_metric_without_single_metric( - empty_singletsframe_with_attr, -): - """If single_metric is missing but there is exactly one metric, use that one""" - del empty_singletsframe_with_attr.attrs["dataset_metadata"]["single_metric"] - - assert get_singlets_units(empty_singletsframe_with_attr)["value"] == "°C" - assert get_singlets_metric_info(empty_singletsframe_with_attr, "name") == "ABC temperature" - - -def test_get_singlets_without_metadata(): - """Components should not require metadata, so no metadata must not raise""" - empty_singletsframe_with_attr = pd.DataFrame() - - assert get_singlets_units(empty_singletsframe_with_attr)["value"] is None - assert get_singlets_names(empty_singletsframe_with_attr)["value"] is None - assert get_singlets_display_names(empty_singletsframe_with_attr)["value"] is None - assert get_singlets_short_display_names(empty_singletsframe_with_attr)["value"] is None - assert get_singlets_measurements(empty_singletsframe_with_attr)["value"] is None - assert get_singlets_metric_info(empty_singletsframe_with_attr, "name") is None - - -@pytest.mark.parametrize( - "func", - [ - get_singlets_units, - get_singlets_names, - get_singlets_display_names, - get_singlets_short_display_names, - get_singlets_measurements, - ], -) -def test_get_singlets_requires_a_dataframe(func): - with pytest.raises(TypeError): - func(pd.Series()) - - -@pytest.fixture -def attrs_with_inherited() -> dict: - return { - "dataset_metadata": {"metric_key": "id", "single_metric": "sensor"}, - "value_dimensions_shared": [{"column": "value", "unit": "shared unit"}], - "metrics": [ - { - "id": "sensor", - "name": "sensor name", - "Latitude": None, - "Longitude": 7.58, - "inherited": { - "Latitude": 51.43, - "Longitude": 8.28, - "display_name": "plant display name", - "unit": "inherited unit", - }, - "value_dimensions": [{"column": "value"}, {"column": "state"}], - }, - {"id": "other", "value_dimensions": [{"column": "value"}]}, - ], - } - - -def test_get_metric_info_falls_back_to_inherited(attrs_with_inherited): - from hdhelpers.metadata import get_metric_info - - dataframe = pd.DataFrame() - dataframe.attrs = attrs_with_inherited - - assert get_metric_info(dataframe, "Latitude")["sensor"] == 51.43 # own None -> inherited - assert get_metric_info(dataframe, "Longitude")["sensor"] == 7.58 # own beats inherited - assert get_metric_info(dataframe, "Latitude")["other"] is None - assert get_metric_info(dataframe, "Latitude", include_inherited=False)["sensor"] is None - assert get_singlets_metric_info(dataframe, "Latitude") == 51.43 - assert get_singlets_metric_info(dataframe, "Latitude", include_inherited=False) is None - - -def test_get_value_dimension_info_falls_back_to_inherited_for_value_only( - attrs_with_inherited, -): - from hdhelpers.metadata import get_units - - dataframe = pd.DataFrame() - dataframe.attrs = attrs_with_inherited - - # like the metric metadata itself, inherited metadata only applies to the "value" column - assert get_singlets_info(dataframe, "Latitude")["value"] == 51.43 - assert get_singlets_info(dataframe, "Latitude")["state"] is None - assert get_singlets_info(dataframe, "Latitude", include_inherited=False)["value"] is None - - # inherited metadata is the last resort, after the dataset wide value_dimensions_shared - assert get_units(dataframe, include_inherited=True)["sensor"]["value"] == "shared unit" - assert get_units(dataframe, include_inherited=True)["other"]["value"] == "shared unit" - assert get_singlets_units(dataframe, include_inherited=True)["value"] == "shared unit" - - del dataframe.attrs["value_dimensions_shared"] - assert get_units(dataframe, include_inherited=True)["sensor"]["value"] == "inherited unit" - assert get_singlets_units(dataframe, include_inherited=True)["value"] == "inherited unit" - - # the functions for dedicated information do not fall back to inherited by default - assert get_units(dataframe)["sensor"]["value"] is None - assert get_singlets_units(dataframe)["value"] is None - - -def test_get_series_info_falls_back_to_inherited(attrs_with_inherited): - from hdhelpers.metadata import get_series_info - - series = pd.Series() - series.attrs = attrs_with_inherited - - assert get_series_info(series, "Latitude") == 51.43 - assert get_series_info(series, "Longitude") == 7.58 - assert get_series_info(series, "Latitude", include_inherited=False) is None - assert get_series_unit(series) == "shared unit" - assert get_series_unit(series, include_inherited=True) == "shared unit" - - del series.attrs["value_dimensions_shared"] - assert get_series_unit(series) is None - assert get_series_unit(series, include_inherited=True) == "inherited unit" - - -def test_own_metadata_beats_inherited_metadata_in_fallback_chains(attrs_with_inherited): - """The whole spec is applied to the metric itself before trying its inherited metadata""" - series = pd.Series() - series.attrs = attrs_with_inherited - - # own "name" beats inherited "display_name" - assert get_series_display_name(series, include_inherited=True) == "sensor name" - assert get_series_short_display_name(series, include_inherited=True) == "sensor name" - - del series.attrs["metrics"][0]["name"] - assert get_series_display_name(series, include_inherited=True) == "plant display name" - assert get_series_display_name(series) is None - - -def test_get_metric_info_prefers_value_dimension_and_shared_over_inherited( - attrs_with_inherited, -): - """Order of get_metric_info: metric, value dimension "value", shared, inherited""" - from hdhelpers.metadata import get_metric_info, get_units - - dataframe = pd.DataFrame() - dataframe.attrs = attrs_with_inherited - - assert get_metric_info(dataframe, "unit")["sensor"] == "shared unit" - - dataframe.attrs["metrics"][0]["value_dimensions"][0]["unit"] = "m" - assert get_metric_info(dataframe, "unit")["sensor"] == "m" - assert get_units(dataframe)["sensor"]["value"] == "m" - - dataframe.attrs["metrics"][0]["unit"] = "kg" - assert get_metric_info(dataframe, "unit")["sensor"] == "kg" - - del dataframe.attrs["value_dimensions_shared"] - del dataframe.attrs["metrics"][0]["unit"] - dataframe.attrs["metrics"][0]["value_dimensions"][0]["unit"] = None - assert get_metric_info(dataframe, "unit")["sensor"] == "inherited unit" - assert get_metric_info(dataframe, "unit", include_inherited=False)["sensor"] is None - assert get_singlets_metric_info(dataframe, "unit") == "inherited unit" diff --git a/tests/metadata/test_metadata_current_convention.py b/tests/metadata/test_metadata_current_convention.py deleted file mode 100644 index f425d86..0000000 --- a/tests/metadata/test_metadata_current_convention.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Extraction of metadata following the current metadata convention in a single pass""" - -import pandas as pd -import pytest -from glom import Coalesce, Path - -import hdhelpers.metadata._current_convention as _current_convention -from hdhelpers.metadata import ( - get_display_names, - get_metric_info, - get_series_info, - get_short_display_names, - get_singlets_info, - get_singlets_metric_info, - get_singlets_units, - get_units, -) - - -def frame(attrs): - df = pd.DataFrame() - df.attrs = attrs - return df - - -def many_metrics_attrs(number): - return { - "dataset_metadata": {"metric_key": "id"}, - "metrics": [ - { - "id": f"m{i}", - "name": f"Metric {i}", - "display_name": None, - "inherited": {"site": "north"}, - "value_dimensions": [{"column": "value", "name": f"Metric {i}", "unit": "m"}], - } - for i in range(number) - ], - } - - -def test_entries_not_adhering_to_the_conventions_are_skipped(): - attrs = { - "metrics": [ - "no object", - {"name": "without metric key"}, - {"id": None, "name": "null metric key"}, - {"id": 42, "name": "metric key no string"}, - { - "id": "a", - "value_dimensions": [ - {"column": "value", "unit": "m"}, - {"unit": "without column"}, - {"column": None, "unit": "null column"}, - {"column": ["no", "string"], "unit": "column no string"}, - "no object", - ], - }, - {"id": "b", "unit": "m³/h", "value_dimensions": None}, - ], - "value_dimensions_shared": [ - {"column": "state", "unit": "shared unit"}, - {"unit": "without column"}, - "no object", - ], - } - - units = get_units(frame(attrs)) - - assert set(units) == {"a", "b"} - assert units["a"] == {"state": "shared unit", "value": "m"} - assert units["b"] == {"state": "shared unit", "value": "m³/h"} - assert set(get_metric_info(frame(attrs), "unit")) == {"a", "b"} - - -@pytest.mark.parametrize("shared", [None, "no list", {"column": "value", "unit": "m"}]) -def test_value_dimensions_shared_which_is_no_list_counts_as_empty(shared): - attrs = {"metrics": [{"id": "a"}], "value_dimensions_shared": shared} - - assert get_units(frame(attrs)) == {"a": {"value": None}} - - -@pytest.mark.parametrize("metric_key", [None, 42]) -def test_metric_key_which_is_no_string_defaults_to_id(metric_key): - attrs = { - "dataset_metadata": {"metric_key": metric_key, "single_metric": "a"}, - "metrics": [{"id": "a", "external_id": "ext", "unit": "m"}], - } - - assert get_metric_info(frame(attrs), "external_id") == {"a": "ext"} - assert get_singlets_metric_info(frame(attrs), "unit") == "m" - assert get_singlets_units(frame(attrs))["value"] == "m" - - -@pytest.mark.parametrize( - ("spec", "expected"), - [ - ("name", "n"), - ("missing", None), - ("items", None), # glom does not fall back to attributes of dicts - (Coalesce("name", default=None), "n"), - (Coalesce("display_name", "name", default=None), None), # null display_name is present - ("sensor.manufacturer", "Acme"), - ("sensor.missing", None), - ("display_name.missing", None), # path through null, left to glom - ("comments.0.content", "c"), # list index, left to glom - ("operator.name", None), # string paths are split at dots - (Path("operator.name"), "Ruhrverband"), - (("comments", ["content"]), ["c"]), - ], -) -def test_getter_applies_spec_like_glom(spec, expected): - obj = { - "name": "n", - "display_name": None, - "sensor": {"manufacturer": "Acme"}, - "operator.name": "Ruhrverband", - "comments": [{"content": "c"}], - } - - assert _current_convention.getter(spec)(obj) == expected - - -def test_spec_is_only_applied_to_levels_needed(): - attrs = { - "metrics": [ - {"id": "a", "name": "Sensor", "value_dimensions": [{"column": "value", "name": None}]} - ], - "value_dimensions_shared": [{"column": "value", "name": None}], - } - - assert get_metric_info(frame(attrs), ("name", str.upper))["a"] == "SENSOR" - - -def test_unused_inherited_properties_are_not_evaluated(): - attrs = { - "dataset_metadata": {"single_metric": "a"}, - "metrics": [ - { - "id": "a", - "name": "Sensor", - "inherited": {"name": None}, - "value_dimensions": [{"column": "value", "name": "Value"}], - } - ], - } - series = pd.Series([1.0]) - series.attrs = attrs - - assert get_series_info(series, ("name", str.upper)) == "VALUE" - assert get_singlets_info(frame(attrs), ("name", str.upper))["value"] == "VALUE" - - -def test_no_glom_evaluation_per_metric_for_field_names(monkeypatch): - """glom costs a lot of time per call, which must not add up for frames with many metrics""" - calls = [] - original_glom = _current_convention.glom - monkeypatch.setattr( - _current_convention, - "glom", - lambda *args, **kwargs: calls.append(1) or original_glom(*args, **kwargs), - ) - multitsframe = frame(many_metrics_attrs(100)) - - get_units(multitsframe) - get_display_names(multitsframe, include_inherited=True) - get_short_display_names(multitsframe, include_inherited=True) - get_metric_info(multitsframe, "unit") - - assert calls == [] - - -def test_many_metrics(): - multitsframe = frame(many_metrics_attrs(1000)) - - assert get_short_display_names(multitsframe)["m999"]["value"] == "Metric 999" - assert get_units(multitsframe)["m0"]["value"] == "m" - assert get_metric_info(multitsframe, "unit")["m500"] == "m" - assert get_metric_info(multitsframe, "site")["m1"] == "north" diff --git a/tests/metadata/test_metadata_default_timezone.py b/tests/metadata/test_metadata_default_timezone.py deleted file mode 100644 index dc3504c..0000000 --- a/tests/metadata/test_metadata_default_timezone.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Default time zone of the metrics, falling back to the one of the dataset metadata""" - -import pandas as pd -import pytest - -from hdhelpers.helpers import modify_timezone -from hdhelpers.metadata import ( - get_default_timezones, - get_series_default_timezone, - get_singlets_default_timezone, -) - - -def frame(attrs): - df = pd.DataFrame() - df.attrs = attrs - return df - - -def series(attrs, values=(1.0,), timestamps=("2026-03-29T12:00:00Z",)): - ser = pd.Series(list(values), index=pd.to_datetime(list(timestamps))) - ser.attrs = attrs - return ser - - -multitsframe_attrs = { - "dataset_metadata": {"metric_key": "external_id", "default_timezone": "Europe/Berlin"}, - "metrics": [ - {"external_id": "new_york", "default_timezone": "America/New_York"}, - {"external_id": "fixed_offset", "default_timezone": "+02:00"}, - {"external_id": "without"}, - {"external_id": "null", "default_timezone": None}, - {"external_id": "no_string", "default_timezone": 2}, - {"external_id": "empty", "default_timezone": ""}, - ], -} - - -def test_default_timezones_of_metrics_fall_back_to_dataset_metadata(): - timezones = get_default_timezones(frame(multitsframe_attrs)) - - assert timezones == { - "new_york": "America/New_York", - "fixed_offset": "+02:00", - "without": "Europe/Berlin", - "null": "Europe/Berlin", - "no_string": "Europe/Berlin", - "empty": "Europe/Berlin", - } - # the default time zone of the dataset metadata applies to all metrics without their own - assert timezones["metric without metadata"] == "Europe/Berlin" - - -def test_default_timezones_without_dataset_default_timezone(): - attrs = {"metrics": [{"id": "a", "default_timezone": "UTC"}, {"id": "b"}]} - - timezones = get_default_timezones(frame(attrs)) - - assert timezones == {"a": "UTC", "b": None} - assert timezones["metric without metadata"] is None - - -def test_default_timezone_does_not_fall_back_to_other_levels(): - """The conventions specify only the dataset metadata as fallback""" - attrs = { - "dataset_metadata": {"single_metric": "a"}, - "metrics": [ - { - "id": "a", - "inherited": {"default_timezone": "Asia/Tokyo"}, - "value_dimensions": [{"column": "value", "default_timezone": "Asia/Tokyo"}], - } - ], - "value_dimensions_shared": [{"column": "value", "default_timezone": "Asia/Tokyo"}], - } - - assert get_default_timezones(frame(attrs))["a"] is None - assert get_series_default_timezone(series(attrs)) is None - assert get_singlets_default_timezone(frame(attrs)) is None - - -@pytest.mark.parametrize( - ("attrs", "expected"), - [ - ( - { - "dataset_metadata": {"single_metric": "a", "default_timezone": "Europe/Berlin"}, - "metrics": [{"id": "a", "default_timezone": "America/New_York"}], - }, - "America/New_York", - ), - ( - { - "dataset_metadata": {"single_metric": "a", "default_timezone": "Europe/Berlin"}, - "metrics": [{"id": "a", "default_timezone": None}], - }, - "Europe/Berlin", - ), - # without single_metric the only metric is used - ({"metrics": [{"id": "a", "default_timezone": "UTC"}]}, "UTC"), - # but not if it is another one than the single metric - ( - { - "dataset_metadata": {"single_metric": "a", "default_timezone": "Europe/Berlin"}, - "metrics": [{"id": "b", "default_timezone": "America/New_York"}], - }, - "Europe/Berlin", - ), - ( - { - "dataset_metadata": {"single_metric": "a"}, - "metrics": [{"id": "b", "default_timezone": "America/New_York"}], - }, - None, - ), - # a single_metric which is no string counts as missing - ( - { - "dataset_metadata": {"single_metric": ["a"]}, - "metrics": [{"id": "a", "default_timezone": "UTC"}], - }, - "UTC", - ), - # without metric metadata the dataset metadata still applies - ({"dataset_metadata": {"default_timezone": "Europe/Berlin"}}, "Europe/Berlin"), - ( - {"dataset_metadata": {"default_timezone": "Europe/Berlin"}, "metrics": []}, - "Europe/Berlin", - ), - ({}, None), - # older metadata structures do not provide default time zones of the metrics - ({"by_metric": {"series": {"metric": {"default_timezone": "UTC"}}}}, None), - ], -) -def test_default_timezone_of_single_metric(attrs, expected): - assert get_series_default_timezone(series(attrs)) == expected - assert get_singlets_default_timezone(frame(attrs)) == expected - - -def test_wrong_types_raise(): - with pytest.raises(TypeError): - get_default_timezones(series({})) - with pytest.raises(TypeError): - get_series_default_timezone(frame({})) - with pytest.raises(TypeError): - get_singlets_default_timezone(series({})) - - -def test_aggregating_days_in_default_timezone(): - """As in the first steps: days start at midnight of the default time zone, which may - differ from UTC days and changes its UTC offset with daylight saving time""" - attrs = { - "dataset_metadata": {"single_metric": "a"}, - "metrics": [{"id": "a", "default_timezone": "Europe/Berlin"}], - } - # 2026-03-29 is the day daylight saving time starts in Germany, it has 23 hours - hourly = series( - attrs, - values=[1.0] * 26, - timestamps=pd.date_range("2026-03-28T23:00:00Z", periods=26, freq="1h"), - ) - - timezone = get_series_default_timezone(hourly) or "UTC" - daily_sums = modify_timezone(hourly, to_timezone=timezone).resample("1D").sum() - - assert daily_sums.to_dict() == { - pd.Timestamp("2026-03-29", tz="Europe/Berlin"): 23.0, - pd.Timestamp("2026-03-30", tz="Europe/Berlin"): 3.0, - } diff --git a/tests/metadata/test_metadata_fallbacks.py b/tests/metadata/test_metadata_fallbacks.py deleted file mode 100644 index 3455339..0000000 --- a/tests/metadata/test_metadata_fallbacks.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Fallbacks for metadata following the current metadata convention - -Metadata providers like the hetida platform send fields without value explicitly as null. -These count as missing: The accessors fall back to the next level or field as if the field -was not provided at all. -""" - -from copy import deepcopy - -import pandas as pd -import pytest - -from hdhelpers.metadata import ( - get_display_names, - get_measurements, - get_metric_info, - get_names, - get_series_default_timezone, - get_series_display_name, - get_series_info, - get_series_measurement, - get_series_name, - get_series_short_display_name, - get_series_unit, - get_short_display_names, - get_singlets_display_names, - get_singlets_metric_info, - get_singlets_short_display_names, - get_singlets_units, - get_units, -) - -SIGNAL_ID = "7ab8c419-8745-4714-b588-57c0c772cc95" - - -def value_dimension(**fields): - """Value dimension "value" as the hetida platform sends it, with nulls for missing fields""" - return { - "column": "value", - "name": None, - "display_name": None, - "short_display_name": None, - "description": None, - "unit": None, - "measurement": None, - "value_data_type": "float", - } | fields - - -def metric(**fields): - """Metric as the hetida platform sends it, with nulls for missing fields""" - return { - "id": SIGNAL_ID, - "external_id": None, - "name": None, - "display_name": None, - "description": None, - "inherited": {}, - "comments": [], - "value_dimensions": [value_dimension()], - "default_aggregation_method": None, - } | fields - - -# what the hetida platform sends for a timeseries of a SERIES read -platform_series_attrs = { - "dataset_metadata": { - "metric_key": "id", - "single_metric": SIGNAL_ID, - "queried_metrics": [SIGNAL_ID], - "ref_interval_start_timestamp": "2026-10-18T00:00:00Z", - "ref_interval_end_timestamp": "2026-10-19T00:00:00Z", - "ref_interval_type": "closed", - }, - "metrics": [ - metric( - external_id="water.north.level", - name="Water-lvl-north", - value_dimensions=[ - value_dimension(name="Water-lvl-north", unit="m", measurement="level") - ], - default_aggregation_method="AVG", - ) - ], -} - -# what the hetida platform sends for a MULTITSFRAME read of a signal group: display_name of -# a metric is the display name configured for the member of the group -platform_multitsframe_attrs = { - "dataset_metadata": {"metric_key": "id", "queried_metrics": ["level", "flow"]}, - "metrics": [ - metric( - id="level", - name="Water-lvl-north", - value_dimensions=[value_dimension(name="Water-lvl-north", unit="m")], - ), - metric( - id="flow", - name="Pump-flow", - display_name="Flow pump 1", - value_dimensions=[value_dimension(name="Pump-flow")], - ), - ], -} - - -def series(attrs): - ser = pd.Series([1.0], index=pd.to_datetime(["2026-10-18T06:00:00Z"])) - ser.attrs = deepcopy(attrs) - return ser - - -def frame(attrs): - df = pd.DataFrame() - df.attrs = deepcopy(attrs) - return df - - -def test_series_from_platform(): - platform_series = series(platform_series_attrs) - - assert get_series_unit(platform_series) == "m" - assert get_series_measurement(platform_series) == "level" - assert get_series_name(platform_series) == "Water-lvl-north" - assert get_series_display_name(platform_series) == "Water-lvl-north" - assert get_series_short_display_name(platform_series) == "Water-lvl-north" - - -def test_multitsframe_from_platform(): - multitsframe = frame(platform_multitsframe_attrs) - - assert get_units(multitsframe)["level"]["value"] == "m" - assert get_units(multitsframe)["flow"]["value"] is None - assert get_names(multitsframe)["flow"]["value"] == "Pump-flow" - assert get_measurements(multitsframe)["level"]["value"] is None - # a null display name falls back to the name - assert get_display_names(multitsframe)["level"]["value"] == "Water-lvl-north" - assert get_short_display_names(multitsframe)["level"]["value"] == "Water-lvl-north" - # the display name of the metric wins over the name of its value dimension - assert get_display_names(multitsframe)["flow"]["value"] == "Flow pump 1" - assert get_short_display_names(multitsframe)["flow"]["value"] == "Flow pump 1" - - -def test_each_field_is_looked_up_on_all_levels_before_the_next_field(): - attrs = { - "metrics": [ - metric( - id="a", - name="metric name", - value_dimensions=[ - value_dimension(name="value name"), - value_dimension(column="state", name="state name"), - ], - ), - metric(id="b", name="metric name", display_name="metric display name"), - metric( - id="c", - display_name="metric display name", - value_dimensions=[value_dimension(display_name="value display name")], - ), - ], - "value_dimensions_shared": [{"column": "state", "display_name": "shared display name"}], - } - - display_names = get_display_names(frame(attrs)) - - assert display_names["a"]["value"] == "value name" - assert display_names["a"]["state"] == "shared display name" - assert display_names["b"]["value"] == "metric display name" - assert display_names["c"]["value"] == "value display name" - - -def test_singletsframe_display_names(): - attrs = { - "dataset_metadata": {"single_metric": "abc.temp", "metric_key": "external_id"}, - "metrics": [ - metric( - external_id="abc.temp", - name="ABC temperature", - display_name="ABC temp.", - value_dimensions=[ - value_dimension(name="temperature", unit="°C"), - value_dimension(column="state", name="measurement state"), - ], - ) - ], - } - singletsframe = frame(attrs) - - assert get_singlets_units(singletsframe)["value"] == "°C" - # only the value dimension "value" falls back to the metric - assert get_singlets_display_names(singletsframe)["value"] == "ABC temp." - assert get_singlets_display_names(singletsframe)["state"] == "measurement state" - assert get_singlets_short_display_names(singletsframe)["state"] == "measurement state" - - -def test_series_display_name_of_metric_wins_over_value_dimension_name(): - attrs = deepcopy(platform_series_attrs) - attrs["metrics"][0]["display_name"] = "Level north" - attrs["metrics"][0]["short_display_name"] = "north" - - assert get_series_display_name(series(attrs)) == "Level north" - assert get_series_short_display_name(series(attrs)) == "north" - - -def test_series_without_single_metric_uses_its_only_metric(): - attrs = deepcopy(platform_series_attrs) - del attrs["dataset_metadata"]["single_metric"] - - assert get_series_unit(series(attrs)) == "m" - assert get_series_display_name(series(attrs)) == "Water-lvl-north" - - -def test_single_metric_is_not_replaced_by_another_only_metric(): - """If the metadata lacks the single metric, the only metric present is another one""" - attrs = deepcopy(platform_series_attrs) - attrs["dataset_metadata"]["single_metric"] = "another signal" - - assert get_series_unit(series(attrs)) is None - assert get_series_display_name(series(attrs)) is None - assert get_singlets_units(frame(attrs))["value"] is None - assert get_singlets_display_names(frame(attrs))["value"] is None - assert get_singlets_metric_info(frame(attrs), "name") is None - - -@pytest.mark.parametrize("single_metric", [None, 1, [SIGNAL_ID]]) -def test_single_metric_which_is_no_string_counts_as_missing(single_metric): - attrs = deepcopy(platform_series_attrs) - attrs["dataset_metadata"]["single_metric"] = single_metric - - assert get_series_unit(series(attrs)) == "m" - assert get_singlets_units(frame(attrs))["value"] == "m" - assert get_singlets_metric_info(frame(attrs), "name") == "Water-lvl-north" - - -def test_series_with_metadata_of_several_metrics_has_no_metric(): - """E.g. a Series taken from a MultiTSFrame, whose metadata it carries - - A metric named "series" is only the metric of a Series in older metadata structures. - """ - attrs = { - "dataset_metadata": {"default_timezone": "UTC"}, - "metrics": [ - metric(id="series", name="Series", default_timezone="Asia/Tokyo"), - metric(id="other", name="Other"), - ], - } - - assert get_series_name(series(attrs)) is None - assert get_series_default_timezone(series(attrs)) == "UTC" - - -def test_only_the_single_metric_is_resolved(): - """The spec is not applied to other metrics, so failing on their null names does not matter""" - attrs = deepcopy(platform_series_attrs) - attrs["metrics"].append(metric(id="other")) - - assert get_series_info(series(attrs), ("name", str.upper)) == "WATER-LVL-NORTH" - assert get_singlets_metric_info(frame(attrs), ("name", str.upper)) == "WATER-LVL-NORTH" - - -def test_series_display_name_does_not_fall_back_to_short_display_name(): - """Like the display names of MultiTSFrames and SingleTSFrames""" - attrs = deepcopy(platform_series_attrs) - attrs["metrics"][0]["name"] = None - attrs["metrics"][0]["value_dimensions"][0]["name"] = None - attrs["metrics"][0]["short_display_name"] = "north" - - assert get_series_display_name(series(attrs)) is None - assert get_singlets_display_names(frame(attrs))["value"] is None - assert get_series_short_display_name(series(attrs)) == "north" - - -@pytest.mark.parametrize( - "attrs", - [{}, {"dataset_metadata": {"metric_key": "id"}}, {"metrics": None}], -) -def test_metric_info_without_metric_metadata(attrs): - """Components should not require metadata, so missing metric metadata must not raise""" - assert get_metric_info(frame(attrs), "name")["any metric"] is None - - -def test_metric_info_from_platform(): - multitsframe = frame(platform_multitsframe_attrs) - multitsframe.attrs["metrics"][0]["default_aggregation_method"] = "SUM" - - aggregation_methods = get_metric_info(multitsframe, "default_aggregation_method") - - assert aggregation_methods["level"] == "SUM" - assert aggregation_methods["flow"] is None - - -def test_metric_info_falls_back_only_for_metrics_lacking_it(): - """A spec transforming the information is not applied to fallback levels needlessly - - The null name of the value dimension of metric "a" would let str.upper fail. - """ - metric_b = metric(id="b", value_dimensions=[value_dimension(name="value name")]) - del metric_b["name"] - multitsframe = frame({"metrics": [metric(id="a", name="Sensor"), metric_b]}) - - names = get_metric_info(multitsframe, ("name", str.upper)) - - assert names["a"] == "SENSOR" - assert names["b"] == "VALUE NAME" - assert get_singlets_metric_info(frame(platform_series_attrs), ("name", str.upper)) == ( - "WATER-LVL-NORTH" - ) - - -def test_metric_info_raises_errors_of_the_spec(): - """Errors of the spec must not be mistaken for missing metric metadata""" - with pytest.raises(TypeError): - get_metric_info(frame(platform_multitsframe_attrs), ("display_name", str.upper)) - - -@pytest.mark.parametrize("include_inherited", [False, True]) -def test_short_display_names_of_metrics_resolved_by_different_fields(include_inherited): - """Each metric gets its information from the first field providing it, independently""" - attrs = { - "metrics": [ - metric( - id="a", - short_display_name="a short", - value_dimensions=[value_dimension(), value_dimension(column="state")], - ), - metric(id="b", display_name="b display", name="b name"), - metric(id="c", name="c name"), - metric(id="d", inherited={"display_name": "d inherited"}), - ], - "value_dimensions_shared": [{"column": "state", "name": "shared state"}], - } - - short_display_names = get_short_display_names(frame(attrs), include_inherited=include_inherited) - - assert short_display_names["a"]["value"] == "a short" - assert short_display_names["a"]["state"] == "shared state" - assert short_display_names["b"]["value"] == "b display" - assert short_display_names["c"]["value"] == "c name" - assert short_display_names["c"]["state"] == "shared state" - assert short_display_names["d"]["value"] == ("d inherited" if include_inherited else None) diff --git a/tests/metadata/test_metadata_migration.py b/tests/metadata/test_metadata_migration.py deleted file mode 100644 index 0363b50..0000000 --- a/tests/metadata/test_metadata_migration.py +++ /dev/null @@ -1,195 +0,0 @@ -import pandas as pd - -from hdhelpers.metadata import ( - get_display_names, - get_measurements, - get_metric_info, - get_series_display_name, - get_series_measurement, - get_series_name, - get_series_short_display_name, - get_series_unit, - get_units, -) - - -def test_get_units_mts_old_format_by_metric(empty_mts_with_old_attr_real): - ##### NEW ##### - units_by_metric_by_value_dimension = get_units(empty_mts_with_old_attr_real) - - # get values - assert units_by_metric_by_value_dimension["metric1"]["value_dim1"] == "m³/s" - assert units_by_metric_by_value_dimension["metric1"]["value_dim2"] == "m³/s" - assert units_by_metric_by_value_dimension["metric2"]["value_dim1"] == "m³/h" - - # ensure that default dict is used in case of missing entry - assert units_by_metric_by_value_dimension["SOME"]["SOME"] is None # is a default dict - - -def test_get_units_mts_old_format(empty_mts_with_old_attr): - units_by_metric_by_value_dimension = get_units(empty_mts_with_old_attr) - - # get values - assert units_by_metric_by_value_dimension["test_channel"]["value"] == "l/min" - assert units_by_metric_by_value_dimension["some_other_metric"]["value"] == "l" - - # ensure that default dict is used in case of missing entry - assert units_by_metric_by_value_dimension["SOME"]["SOME"] is None # is a default dict - - -def test_get_units_mts_new_format_1(empty_mts_with_attr): - measurements_by_metric_by_value_dimension = get_measurements(empty_mts_with_attr) - - assert measurements_by_metric_by_value_dimension["first"]["temp"] == "temperature" - assert measurements_by_metric_by_value_dimension["first"]["value"] is None - assert measurements_by_metric_by_value_dimension["first"]["pressure"] == "pressure" - assert measurements_by_metric_by_value_dimension["first"]["NOT OCCURING"] is None - assert measurements_by_metric_by_value_dimension["NOT OCCURING"]["value"] is None - - assert measurements_by_metric_by_value_dimension["second"]["temp"] == "temperature" - - assert measurements_by_metric_by_value_dimension["third"]["value"] == "height" - assert measurements_by_metric_by_value_dimension["third"]["pressure"] == "pressure" - - -def test_get_units_mts_new_format_2(empty_mts_with_attr): - units_by_metric_by_value_dimension = get_units(empty_mts_with_attr) - - assert units_by_metric_by_value_dimension["first"]["temp"] == "C" - assert units_by_metric_by_value_dimension["first"]["value"] == "m" - assert units_by_metric_by_value_dimension["first"]["NOT OCCURING"] is None - assert units_by_metric_by_value_dimension["NOT OCCURING"]["value"] is None - - assert units_by_metric_by_value_dimension["second"]["temp"] == "C" - assert units_by_metric_by_value_dimension["second"]["value"] is None - - assert units_by_metric_by_value_dimension["third"]["pressure"] == "bar" - assert units_by_metric_by_value_dimension["third"]["value"] is None - - assert units_by_metric_by_value_dimension["fourth"]["pressure"] == "Pa" - assert units_by_metric_by_value_dimension["fourth"]["value"] == "l" - - assert units_by_metric_by_value_dimension["fifth"]["pressure"] == "bar" - assert units_by_metric_by_value_dimension["fifth"]["value"] == "m^3" - - -def test_get_units_mts_new_format_3(empty_mts_with_attr): - empty_mts_with_attr.attrs["dataset_metadata"]["metric_key"] = "external_id" - units_by_metric_by_value_dimension = get_units(empty_mts_with_attr) - - assert units_by_metric_by_value_dimension["external_first"]["temp"] == "C" - assert units_by_metric_by_value_dimension["external_first"]["value"] == "m" - assert units_by_metric_by_value_dimension["external_first"]["NOT OCCURING"] is None - assert units_by_metric_by_value_dimension["NOT OCCURING"]["value"] is None - - assert units_by_metric_by_value_dimension["external_second"]["temp"] == "C" - assert units_by_metric_by_value_dimension["external_second"]["value"] is None - - assert units_by_metric_by_value_dimension["external_third"]["pressure"] == "bar" - assert units_by_metric_by_value_dimension["external_third"]["value"] is None - - assert units_by_metric_by_value_dimension["external_fourth"]["pressure"] == "Pa" - assert units_by_metric_by_value_dimension["external_fourth"]["value"] == "l" - - assert units_by_metric_by_value_dimension["external_fifth"]["pressure"] == "bar" - assert units_by_metric_by_value_dimension["external_fifth"]["value"] == "m^3" - - -def test_get_multitsframe_display_names_from_metadata_with_value_dimensions(empty_mts_with_attr): - display_names_by_metric_by_value_dimension = get_display_names(empty_mts_with_attr) - - assert display_names_by_metric_by_value_dimension["first"]["temp"] is None - assert display_names_by_metric_by_value_dimension["first"]["value"] == "first display name" - assert ( - display_names_by_metric_by_value_dimension["first"]["pressure"] - == "shared value_dimension pressure name" - ) - - assert display_names_by_metric_by_value_dimension["first"]["NOT OCCURING"] is None - assert display_names_by_metric_by_value_dimension["NOT OCCURING"]["value"] is None - - assert display_names_by_metric_by_value_dimension["second"]["value"] == "second name" - assert display_names_by_metric_by_value_dimension["second"]["temp"] is None - - assert display_names_by_metric_by_value_dimension["third"]["pressure"] == "thirds's pressure" - assert display_names_by_metric_by_value_dimension["third"]["temp"] is None - - -def test_get_metric_info(empty_mts_with_attr): - external_ids_by_metric = get_metric_info(empty_mts_with_attr, "external_id") - - assert external_ids_by_metric["UNKNOWN"] is None - assert external_ids_by_metric["first"] == "external_first" - assert external_ids_by_metric["second"] == "external_second" - - -def test_series_unit(empty_series_with_attr): - assert get_series_unit(empty_series_with_attr) == "m" - - assert get_series_name(empty_series_with_attr) == "first name" - assert get_series_display_name(empty_series_with_attr) == "first name" - assert get_series_short_display_name(empty_series_with_attr) == "first name" - - assert get_series_measurement(empty_series_with_attr) is None - - -def test_series_unit_old(empty_series_with_old_attr): - assert get_series_unit(empty_series_with_old_attr) == "C°" - - assert get_series_name(empty_series_with_old_attr) == "Muster Channel" - assert get_series_display_name(empty_series_with_old_attr) == "Muster Channel" - assert get_series_short_display_name(empty_series_with_old_attr) == "muster" - - assert get_series_measurement(empty_series_with_old_attr) is None - - -def test_metric_key_defaults_to_id(): - """ "metric_key" is optional per the conventions and defaults to "id" - - It therefore must not act as the discriminator between the metadata conventions - - see test_by_metric_convention_stays_reachable_without_metric_key. - """ - mts = pd.DataFrame() - mts.attrs = { - "dataset_metadata": {}, - "metrics": [{"id": "metric1", "value_dimensions": [{"column": "value", "unit": "m³/s"}]}], - } - - assert get_units(mts)["metric1"]["value"] == "m³/s" - assert get_metric_info(mts, "id")["metric1"] == "metric1" - - # ... also with no "dataset_metadata" at all - mts.attrs = { - "metrics": [{"id": "metric1", "value_dimensions": [{"column": "value", "unit": "m³/s"}]}] - } - - assert get_units(mts)["metric1"]["value"] == "m³/s" - - -def test_by_metric_convention_stays_reachable_without_metric_key(): - """The older "by_metric" convention must stay reachable - - "metrics" being a list is what discriminates the current convention from the older ones, - so metadata without a "metrics" list still resolves via "by_metric". - """ - mts = pd.DataFrame() - mts.attrs = { - "dataset_metadata": {}, - "by_metric": {"metric1": {"value_dimensions": {"value": {"unit": "m³/s"}}}}, - } - - assert get_units(mts)["metric1"]["value"] == "m³/s" - - -def test_metrics_list_takes_precedence_over_by_metric(): - """If both are present the current convention wins""" - mts = pd.DataFrame() - mts.attrs = { - "dataset_metadata": {}, - "metrics": [ - {"id": "metric1", "value_dimensions": [{"column": "value", "unit": "FROM_NEW"}]} - ], - "by_metric": {"metric1": {"value_dimensions": {"value": {"unit": "FROM_OLD"}}}}, - } - - assert get_units(mts)["metric1"]["value"] == "FROM_NEW" diff --git a/tests/metadata/test_metadata_recording.py b/tests/metadata/test_metadata_recording.py deleted file mode 100644 index 527bac6..0000000 --- a/tests/metadata/test_metadata_recording.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Recording of the metrics and their default aggregation methods""" - -import pandas as pd -import pytest - -from hdhelpers.metadata import ( - Recording, - get_default_aggregation_methods, - get_recordings, - get_series_default_aggregation_method, - get_series_recording, - get_singlets_default_aggregation_method, - get_singlets_recording, -) - - -def frame(attrs): - df = pd.DataFrame() - df.attrs = attrs - return df - - -def series(attrs): - ser = pd.Series(dtype=float) - ser.attrs = attrs - return ser - - -def value_data_type(data_type): - return [{"column": "value", "unit": None, "measurement": None, "value_data_type": data_type}] - - -multitsframe_attrs = { - "dataset_metadata": { - "metric_key": "external_id", - "ref_data_frequency": "1h", - "ref_data_frequency_offset": "30min", - "ref_data_frequency_timezone": "Europe/Berlin", - }, - "metrics": [ - { - "external_id": "own_frequency", - "recording_mode": "periodic", - "data_frequency": "15min", - "recording_aggregation_method": "MEAN", - }, - # the offset and time zone of the metric only apply together with its frequency - { - "external_id": "without_frequency", - "recording_mode": "on_change", - "data_frequency_offset": "5min", - "data_frequency_timezone": "UTC", - }, - {"external_id": "null_frequency", "data_frequency": None}, - {"external_id": "no_string", "data_frequency": 15, "recording_mode": 1}, - ], -} - -dataset_frequency = { - "data_frequency": "1h", - "data_frequency_offset": "30min", - "data_frequency_timezone": "Europe/Berlin", -} - - -def test_recordings_fall_back_to_the_frequency_of_the_dataset_as_a_whole(): - recordings = get_recordings(frame(multitsframe_attrs)) - - assert recordings == { - "own_frequency": Recording( - recording_mode="periodic", - data_frequency="15min", - recording_aggregation_method="MEAN", - ), - "without_frequency": Recording(recording_mode="on_change", **dataset_frequency), - "null_frequency": Recording(**dataset_frequency), - "no_string": Recording(**dataset_frequency), - } - assert recordings["metric without metadata"] == Recording(**dataset_frequency) - - -@pytest.mark.parametrize( - "attrs", - [ - {}, - {"metrics": "no list"}, - # an older metadata structure, whose metrics are not considered - {"dataset_metadata": {}, "metrics": {"a": {"data_frequency": "1h"}}}, - ], -) -def test_recordings_without_metric_metadata(attrs): - recordings = get_recordings(frame(attrs)) - - assert recordings == {} - assert recordings["a"] == Recording() - - -def test_series_recording(): - attrs = { - "dataset_metadata": {"single_metric": "b", "ref_data_frequency": "1D"}, - "metrics": [{"id": "b", "recording_mode": "periodic", "data_frequency_offset": "1h"}], - } - - assert get_series_recording(series(attrs)) == Recording( - recording_mode="periodic", data_frequency="1D" - ) - - -def test_singlets_recording_falls_back_to_the_dataset_if_the_metric_is_not_found(): - attrs = { - "dataset_metadata": {"single_metric": "missing", "ref_data_frequency": "5min"}, - "metrics": [{"id": "a", "data_frequency": "1h"}, {"id": "b"}], - } - - assert get_singlets_recording(frame(attrs)) == Recording(data_frequency="5min") - - -def test_recording_functions_check_their_input(): - with pytest.raises(TypeError): - get_recordings(series({})) - with pytest.raises(TypeError): - get_series_recording(frame({})) - with pytest.raises(TypeError): - get_singlets_recording(series({})) - - -def test_default_aggregation_methods(): - attrs = { - "dataset_metadata": {"metric_key": "id"}, - "metrics": [ - {"id": "avg", "default_aggregation_method": "AVG"}, - {"id": "sum", "default_aggregation_method": "SUM"}, - {"id": "float", "value_dimensions": value_data_type("float")}, - {"id": "integer", "value_dimensions": value_data_type("integer")}, - {"id": "boolean", "value_dimensions": value_data_type("boolean")}, - {"id": "string", "value_dimensions": value_data_type("string")}, - # the method of the metric takes precedence over the default of its data type - { - "id": "boolean_max", - "default_aggregation_method": "MAX", - "value_dimensions": value_data_type("boolean"), - }, - {"id": "null", "default_aggregation_method": None}, - {"id": "shared_data_type", "value_dimensions": []}, - ], - "value_dimensions_shared": [{"column": "value", "value_data_type": "string"}], - } - - methods = get_default_aggregation_methods(frame(attrs)) - - assert methods == { - "avg": "MEAN", - "sum": "SUM", - "float": "MEAN", - "integer": "MEAN", - "boolean": "LAST", - "string": "LAST", - "boolean_max": "MAX", - # the value data type of the shared value dimension "value" applies - "null": "LAST", - "shared_data_type": "LAST", - } - assert methods["metric without metadata"] == "MEAN" - - -@pytest.mark.parametrize( - ("attrs", "method"), - [ - ({}, "MEAN"), - ({"metrics": {"a": {"default_aggregation_method": "SUM"}}}, "MEAN"), - ( - { - "dataset_metadata": {"single_metric": "a"}, - "metrics": [{"id": "a", "value_dimensions": value_data_type("boolean")}], - }, - "LAST", - ), - ({"metrics": [{"id": "a", "default_aggregation_method": "AVG"}]}, "MEAN"), - ], -) -def test_single_metric_default_aggregation_method(attrs, method): - assert get_series_default_aggregation_method(series(attrs)) == method - assert get_singlets_default_aggregation_method(frame(attrs)) == method diff --git a/tests/metadata/test_reading.py b/tests/metadata/test_reading.py new file mode 100644 index 0000000..ff96930 --- /dev/null +++ b/tests/metadata/test_reading.py @@ -0,0 +1,447 @@ +"""Lookups with the fallbacks of the conventions and changed versions of the models""" + +import datetime +from typing import Any + +import pytest +from pydantic import ValidationError + +from hdhelpers.metadata._reading import Recording +from hdhelpers.metadata.conventions import ( + MetadataConventionError, + MultiTSFrameMetadata, + SeriesMetadata, + SingleTSFrameMetadata, +) + + +def value_dimension(column: str = "value", **fields: Any) -> dict[str, Any]: + return {"column": column, "unit": None, "measurement": None} | fields + + +def metric(metric_id: str, **fields: Any) -> dict[str, Any]: + return { + "id": metric_id, + "name": f"name of {metric_id}", + "value_dimensions": [value_dimension()], + } | fields + + +def dataset_metadata(**fields: Any) -> dict[str, Any]: + return { + "metric_key": "id", + "queried_metrics": [], + "ref_interval_start_timestamp": "2026-10-18T00:00:00Z", + "ref_interval_end_timestamp": "2026-10-19T00:00:00+00:00", + "ref_interval_type": "closed", + } | fields + + +def multitsframe(*metrics: dict[str, Any], **fields: Any) -> MultiTSFrameMetadata: + return MultiTSFrameMetadata.model_validate( + {"dataset_metadata": dataset_metadata(), "metrics": list(metrics)} | fields + ) + + +def series(metric_fields: dict[str, Any], **dataset_fields: Any) -> SeriesMetadata: + return SeriesMetadata.model_validate( + { + "dataset_metadata": dataset_metadata( + single_metric="s", queried_metrics=["s"], **dataset_fields + ), + "metrics": [metric("s", **metric_fields)], + } + ) + + +class TestMetrics: + def test_metric_ids_in_order(self) -> None: + meta = multitsframe(metric("b"), metric("a")) + + assert meta.metric_ids() == ["b", "a"] + assert meta.metric("a").name == "name of a" + + def test_metric_key_names_the_id(self) -> None: + meta = MultiTSFrameMetadata.model_validate( + { + "dataset_metadata": dataset_metadata(metric_key="tag"), + "metrics": [metric("m1", tag="plant.temperature")], + } + ) + + assert meta.metric_ids() == ["plant.temperature"] + + def test_metric_key_with_dots_names_a_field(self) -> None: + meta = MultiTSFrameMetadata.model_validate( + { + "dataset_metadata": dataset_metadata(metric_key="custom.id"), + "metrics": [ + metric( + "m1", value_dimensions=[value_dimension(unit="m")], **{"custom.id": "a"} + ), + metric( + "m2", value_dimensions=[value_dimension(unit="s")], **{"custom.id": "b"} + ), + ], + } + ) + + assert meta.metric_ids() == ["a", "b"] + assert meta.units() == {"a": {"value": "m"}, "b": {"value": "s"}} + assert meta.with_metric("a", **{"custom.id": "c"}).metric_ids() == ["c", "b"] + with pytest.raises(MetadataConventionError, match="more than once"): + meta.with_metric("a", **{"custom.id": "b"}) + assert meta.narrowed_to("b").as_multitsframe().metric_ids() == ["b"] + assert meta.narrowed_to("b").as_multitsframe("x").metric_ids() == ["x"] + + def test_unknown_metric(self) -> None: + meta = multitsframe(metric("a")) + + with pytest.raises(KeyError): + meta.metric("b") + assert meta.unit("b") is None + assert meta.metric_info("name", "b") is None + + def test_multitsframe_requires_metric_id(self) -> None: + with pytest.raises(TypeError, match="metric_id"): + multitsframe(metric("a")).unit() + + def test_single_metric_by_default(self) -> None: + meta = series({"value_dimensions": [value_dimension(unit="m")]}) + + assert meta.metric().id == "s" + assert meta.unit() == "m" + assert meta.unit("s") == "m" + + def test_singletsframe_columns(self) -> None: + meta = SingleTSFrameMetadata.model_validate( + { + "dataset_metadata": dataset_metadata(single_metric="f", queried_metrics=["f"]), + "metrics": [ + metric( + "f", + value_dimensions=[ + value_dimension("temperature", unit="°C"), + value_dimension("pressure", unit="bar"), + ], + ) + ], + } + ) + + assert meta.unit(column="pressure") == "bar" + assert meta.units() == {"f": {"value": None, "temperature": "°C", "pressure": "bar"}} + + +class TestValueDimensionInformation: + def test_value_dimension_first(self) -> None: + meta = series({"unit": "metric unit", "value_dimensions": [value_dimension(unit="m")]}) + + assert meta.unit() == "m" + + def test_metric_for_value(self) -> None: + meta = series({"unit": "m"}) + + assert meta.unit() == "m" + + def test_metric_not_for_other_columns(self) -> None: + meta = multitsframe( + metric("a", unit="m", value_dimensions=[value_dimension("other")]), + ) + + assert meta.units() == {"a": {"value": "m", "other": None}} + + def test_shared_value_dimensions_last(self) -> None: + meta = multitsframe( + metric("a", value_dimensions=[value_dimension("temperature", unit="K")]), + metric("b", value_dimensions=[value_dimension("temperature")]), + value_dimensions_shared=[ + value_dimension("temperature", unit="°C"), + value_dimension("pressure", unit="bar"), + ], + ) + + assert meta.units() == { + "a": {"temperature": "K", "pressure": "bar", "value": None}, + "b": {"temperature": "°C", "pressure": "bar", "value": None}, + } + assert meta.unit("b", "temperature") == "°C" + + def test_each_field_on_all_levels_before_the_next(self) -> None: + meta = series({"display_name": "metric display name"}) + + # the display name of the metric wins over the name of the value dimension + assert meta.display_name() == "metric display name" + assert meta.short_display_name() == "metric display name" + assert meta.name() == "name of s" + + def test_display_name_falls_back_to_name(self) -> None: + meta = series({"value_dimensions": [value_dimension(name="level")]}) + + assert meta.display_name() == "level" + assert meta.short_display_name() == "level" + + def test_null_counts_as_missing(self) -> None: + meta = series( + {"display_name": None, "value_dimensions": [value_dimension(display_name=None)]} + ) + + assert meta.display_name() == "name of s" + + def test_inherited_properties_only_on_request(self) -> None: + meta = series({"inherited": {"unit": "m", "measurement": "level"}}) + + assert meta.unit() is None + assert meta.unit(include_inherited=True) == "m" + assert meta.measurements(include_inherited=True) == {"s": {"value": "level"}} + + def test_inherited_properties_only_for_value(self) -> None: + meta = multitsframe( + metric("a", inherited={"unit": "m"}, value_dimensions=[value_dimension("other")]) + ) + + assert meta.units(include_inherited=True) == {"a": {"value": "m", "other": None}} + + def test_extra_fields_and_dotted_paths(self) -> None: + meta = series({"value_dimensions": [value_dimension(sensor={"model": "X1"})]}) + + assert meta.value_dimension_info("sensor.model") == "X1" + assert meta.value_dimension_info("sensor.serial") is None + assert meta.value_dimension_info(["sensor.serial", "sensor.model"]) == "X1" + + +class TestMetricInformation: + def test_fallbacks(self) -> None: + meta = multitsframe( + metric("own", description="own"), + metric("value", value_dimensions=[value_dimension(description="value")]), + metric("shared"), + metric("inherited", inherited={"description": "inherited"}), + value_dimensions_shared=[value_dimension(description="shared")], + ) + + assert meta.metric_infos("description") == { + "own": "own", + "value": "value", + "shared": "shared", + "inherited": "shared", + } + + def test_inherited_properties_by_default(self) -> None: + meta = series({"inherited": {"site": "north"}}) + + assert meta.metric_info("site") == "north" + assert meta.metric_info("site", include_inherited=False) is None + + +class TestTimezonesRecordingAggregation: + def test_default_timezone(self) -> None: + meta = MultiTSFrameMetadata.model_validate( + { + "dataset_metadata": dataset_metadata(default_timezone="UTC"), + "metrics": [metric("a", default_timezone="Europe/Berlin"), metric("b")], + } + ) + + assert meta.default_timezones() == {"a": "Europe/Berlin", "b": "UTC"} + assert meta.default_timezone("unknown") == "UTC" + + def test_recording_falls_back_as_a_whole(self) -> None: + meta = MultiTSFrameMetadata.model_validate( + { + "dataset_metadata": dataset_metadata( + ref_data_frequency="1h", + ref_data_frequency_offset="5min", + ref_data_frequency_timezone="Europe/Berlin", + ), + "metrics": [ + metric( + "own", + recording_mode="periodic", + data_frequency="15min", + recording_aggregation_method="MAX", + ), + metric("dataset", recording_mode="on_change"), + ], + } + ) + + assert meta.recordings() == { + "own": Recording("periodic", "15min", None, None, "MAX"), + "dataset": Recording("on_change", "1h", "5min", "Europe/Berlin", None), + } + assert meta.recording("unknown") == Recording(None, "1h", "5min", "Europe/Berlin", None) + assert type(meta.recording("own").recording_mode) is str + + @pytest.mark.parametrize( + ("metric_fields", "shared", "expected"), + [ + ({"default_aggregation_method": "MAX"}, None, "MAX"), + ({"default_aggregation_method": "AVG"}, None, "MEAN"), + ({"value_dimensions": [value_dimension(value_data_type="boolean")]}, None, "LAST"), + ({"value_data_type": "string"}, None, "LAST"), + ({}, [value_dimension(value_data_type="boolean")], "LAST"), + ({}, None, "MEAN"), + ], + ) + def test_default_aggregation_method( + self, metric_fields: dict[str, Any], shared: list[dict] | None, expected: str + ) -> None: + meta = multitsframe(metric("a", **metric_fields), value_dimensions_shared=shared) + + assert meta.default_aggregation_method("a") == expected + assert meta.default_aggregation_methods() == {"a": expected} + assert meta.default_aggregation_method("unknown") == "MEAN" + + def test_queried_interval(self) -> None: + start, end = series({}).queried_interval() + + assert start == datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC) + assert end == datetime.datetime(2026, 10, 19, tzinfo=datetime.UTC) + + def test_no_queried_interval_for_discrete_datasets(self) -> None: + meta = SeriesMetadata.model_validate( + { + "dataset_metadata": { + "metric_key": "id", + "queried_metrics": ["s"], + "single_metric": "s", + "ref_dataset_discrete": True, + }, + "metrics": [metric("s")], + } + ) + + assert meta.queried_interval() == (None, None) + + +class TestChangedVersions: + def test_with_metric_shares_the_other_metrics(self) -> None: + meta = multitsframe(metric("a"), metric("b")) + + changed = meta.with_metric("a", display_name="A") + + assert changed.display_name("a") == "A" + assert meta.display_name("a") == "name of a" + assert changed.metrics[1] is meta.metrics[1] + assert changed.dataset_metadata is meta.dataset_metadata + + def test_with_metric_validates_the_changes(self) -> None: + meta = multitsframe(metric("a"), metric("b")) + + with pytest.raises(ValidationError): + meta.with_metric("a", name=5) + with pytest.raises(MetadataConventionError, match="default_timezone"): + meta.with_metric("a", default_timezone="Mars/Base") + with pytest.raises(MetadataConventionError, match="more than once"): + meta.with_metric("a", id="b") + + def test_with_metric_changing_the_id(self) -> None: + changed = multitsframe(metric("a"), metric("b")).with_metric("a", id="c") + + assert changed.metric_ids() == ["c", "b"] + + def test_with_value_dimension(self) -> None: + meta = series({"value_dimensions": [value_dimension(unit="m")]}) + + changed = meta.with_value_dimension(None, "value", unit="cm") + + assert changed.unit() == "cm" + assert meta.unit() == "m" + with pytest.raises(KeyError): + meta.with_value_dimension(None, "other", unit="cm") + + def test_with_dataset_metadata_checks_all_rules(self) -> None: + meta = multitsframe(metric("a")) + + changed = meta.with_dataset_metadata(default_timezone="Europe/Berlin") + + assert changed.default_timezone("a") == "Europe/Berlin" + assert changed.metrics is meta.metrics + with pytest.raises(MetadataConventionError, match="ref_interval_start_timestamp"): + meta.with_dataset_metadata(ref_interval_start_timestamp="2026-10-20T00:00:00Z") + + +class TestConversions: + def test_narrowed_to_a_metric(self) -> None: + meta = multitsframe( + metric("a"), metric("b"), value_dimensions_shared=[value_dimension(unit="m")] + ) + + narrowed = meta.narrowed_to("b") + + assert isinstance(narrowed, SingleTSFrameMetadata) + assert narrowed.metrics[0] is meta.metrics[1] + assert narrowed.dataset_metadata.single_metric == "b" + assert narrowed.dataset_metadata.queried_metrics == ("b",) + assert narrowed.unit() == "m" + with pytest.raises(KeyError): + meta.narrowed_to("c") + + def singletsframe(self) -> SingleTSFrameMetadata: + return SingleTSFrameMetadata.model_validate( + { + "dataset_metadata": dataset_metadata(single_metric="f", queried_metrics=["f"]), + "metrics": [ + metric( + "f", + value_dimensions=[ + value_dimension("temperature", unit="°C"), + value_dimension("pressure", unit="bar"), + ], + ) + ], + "value_dimensions_shared": [value_dimension("pressure", measurement="pressure")], + } + ) + + def test_singletsframe_as_series(self) -> None: + series_meta = self.singletsframe().as_series("pressure") + + assert isinstance(series_meta, SeriesMetadata) + assert series_meta.units() == {"f": {"value": "bar"}} + assert series_meta.measurement() == "pressure" + with pytest.raises(KeyError): + self.singletsframe().as_series("humidity") + + def test_series_as_singletsframe(self) -> None: + series_meta = self.singletsframe().as_series("pressure") + + singletsframe_meta = series_meta.as_singletsframe("p") + + assert singletsframe_meta.units() == {"f": {"p": "bar", "value": None}} + assert singletsframe_meta.measurement(column="p") == "pressure" + + def test_singletsframe_as_multitsframe(self) -> None: + meta = self.singletsframe().as_multitsframe() + + assert isinstance(meta, MultiTSFrameMetadata) + assert meta.metric_ids() == ["f"] + assert meta.dataset_metadata.queried_metrics == ("f",) + + def test_singletsframe_as_multitsframe_with_another_id(self) -> None: + meta = self.singletsframe().as_multitsframe("plant.f") + + assert meta.metric_ids() == ["plant.f"] + assert meta.dataset_metadata.metric_key == "metric" + assert meta.metric("plant.f").id == "f" + assert meta.dataset_metadata.single_metric == "plant.f" + + def test_conversions_keep_extra_fields(self) -> None: + extra = {"custom_root": {"source": "adapter"}} + meta = SingleTSFrameMetadata.model_validate(self.singletsframe().model_dump() | extra) + + series_meta = meta.as_series("pressure") + multitsframe_meta = meta.as_multitsframe() + + assert series_meta.model_extra == extra + assert series_meta.as_singletsframe().model_extra == extra + assert multitsframe_meta.model_extra == extra + assert multitsframe_meta.narrowed_to("f").model_extra == extra + + +def test_enum_members_are_given_as_values() -> None: + meta = series({"recording_mode": "irregular", "default_aggregation_method": "MAX"}) + + assert type(meta.metric_info("recording_mode")) is str + assert meta.metric_info("default_aggregation_method") == "MAX" diff --git a/tests/test_optional_plotting.py b/tests/test_optional_plotting.py new file mode 100644 index 0000000..bd45700 --- /dev/null +++ b/tests/test_optional_plotting.py @@ -0,0 +1,56 @@ +import os +import subprocess +import sys + +# None in sys.modules makes every import of plotly fail, as if it was not installed +WITHOUT_PLOTLY = "import sys; sys.modules['plotly'] = None\n" + + +def run_without_plotly(code: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", WITHOUT_PLOTLY + code], + capture_output=True, + text=True, + env=os.environ | {"PYTHONPATH": os.pathsep.join(sys.path)}, + check=False, + ) + + +def test_hdhelpers_works_without_plotly() -> None: + result = run_without_plotly( + "import hdhelpers, hdhelpers.metadata\n" + "from hdhelpers import StatusColors, get_locale, modify_timezone\n" + "from hdhelpers.metadata.conventions import check_metadata_conventions\n" + ) + assert result.returncode == 0, result.stderr + + +def test_plotting_names_need_the_plotting_extra() -> None: + result = run_without_plotly("from hdhelpers import set_dt_ticks\n") + + assert result.returncode != 0 + assert "hdhelpers[plotting]" in result.stderr + + +def test_plotting_names_need_the_plotting_extra_on_every_access() -> None: + # twice, since a failed import keeps the modules imported until then, e.g. the colors + result = run_without_plotly( + "import hdhelpers\n" + "for name in sorted(hdhelpers._PLOTTING_NAMES | {'plotting'}) * 2:\n" + " try:\n" + " getattr(hdhelpers, name)\n" + " except ImportError as e:\n" + " assert 'hdhelpers[plotting]' in str(e), e\n" + " else:\n" + " raise SystemExit(f'hdhelpers.{name} is available without plotly')\n" + ) + + assert result.returncode == 0, result.stderr + + +def test_plotting_names_are_available_with_plotly() -> None: + import hdhelpers # noqa: PLC0415 + from hdhelpers.plotting.timestamp_ticks import set_dt_ticks # noqa: PLC0415 + + assert hdhelpers.set_dt_ticks is set_dt_ticks + assert "set_dt_ticks" in dir(hdhelpers) diff --git a/uv.lock b/uv.lock index 36d12ed..7cd25cd 100644 --- a/uv.lock +++ b/uv.lock @@ -8,9 +8,12 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -24,20 +27,75 @@ wheels = [ [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] -name = "attrs" -version = "26.1.0" +name = "ast-serialize" +version = "0.13.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/d5/5dc1ee8a80c3b20a0b63c61b43ba6bc53517faecd1def47bcd752d22268e/ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025", size = 955137, upload-time = "2026-10-17T00:04:17.183Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a8/fbd136b9f70c59e75f59a8e974c84102976f1201828b4f886480ba956936/ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456", size = 897306, upload-time = "2026-10-17T00:02:52.479Z" }, + { url = "https://files.pythonhosted.org/packages/50/3f/2ad51e9823a790e7a70fd781e9595ab3abeace27f3aefcaa1939457efd72/ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4", size = 1236082, upload-time = "2026-10-17T00:02:54.82Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5c/8e959db5cc2237f9d4c560a8e2253c98f9746883fb4774651e4cda458901/ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad", size = 1221242, upload-time = "2026-10-17T00:02:56.395Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/ab1c56c3b7adcac88764bba609502d6b4989e449b2dd60eb478e1d20e369/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9", size = 1282105, upload-time = "2026-10-17T00:02:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f4/41981fcd2ddffe0760e5326be9dcbd2eda9478a8430a637e5123bd8fcb26/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331", size = 1287905, upload-time = "2026-10-17T00:03:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/b783addc5681153707121991b7b47892f7e9c8ba58a0d03b71a3b023ba47/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51", size = 1541806, upload-time = "2026-10-17T00:03:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/969db095038ee15270f65ad20d1129c5e4da605bf7e93b70ae640f1c8a9f/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372", size = 1309946, upload-time = "2026-10-17T00:03:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/06/dd/7c623d2fb0b868948952d1787e629a43b866f7d4caefd5445e20aefacec4/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd", size = 1302773, upload-time = "2026-10-17T00:03:04.378Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c2/4d49469110a19a44fc6269f4fd59679bcf84df068f185f89b73bee4da19b/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82", size = 1299579, upload-time = "2026-10-17T00:03:05.88Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/2d381f1f262dda15affc8ad643fba1619eb07e7d9f8bd904deffef183006/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769", size = 1355969, upload-time = "2026-10-17T00:03:07.321Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/2266794e6c25fa9ce51ce04a9bf4b7e330e3ac9351ba3fa14e487678acdb/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8", size = 1460227, upload-time = "2026-10-17T00:03:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/14deb28e5d8f328f626a0778ea2cf98fe908f2bbcccd804cb6f449ee1782/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96", size = 1562691, upload-time = "2026-10-17T00:03:10.25Z" }, + { url = "https://files.pythonhosted.org/packages/f5/19/7a28c0e5ba54f0d3e054f847a2a549b748cb179ab433321a8dc1b39e398d/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c", size = 1558669, upload-time = "2026-10-17T00:03:11.799Z" }, + { url = "https://files.pythonhosted.org/packages/18/9a/f9e2690eab58217f1d395a964a78efa7eaf813a1e38be8dfd9a8d45d0840/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98", size = 1665071, upload-time = "2026-10-17T00:03:13.284Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/ef5b159a0d7161b404f05e7ec21cf7735ee7edc10c000cd2cbbc7bb6b69a/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe", size = 1473364, upload-time = "2026-10-17T00:03:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/1d/87/6e8ac4a865124a12cc4f915bf1bcfac3bba426cd5f58d3712a873ee61012/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa", size = 1500566, upload-time = "2026-10-17T00:03:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1e/29155f1621433008013ad5c940a03d41a676be0680c41527040472d37583/ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47", size = 1128674, upload-time = "2026-10-17T00:03:17.481Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/2506da65143ea138de5822e9173e0b72320a2f70e23bd723f044033175f1/ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593", size = 1163306, upload-time = "2026-10-17T00:03:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8f/c7cb22638899b0cf15908809c270560e46395e65604bc59e4b623ab9f405/ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6", size = 1133775, upload-time = "2026-10-17T00:03:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e2/b0c140fe50fa39dd249e382f7733af6f816c90021fe7959a0b026f86237f/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5", size = 1236160, upload-time = "2026-10-17T00:03:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/a8a2ca21e3943555b48221f8fba546877a7ad39efaf6505506524967d7c4/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8", size = 1222214, upload-time = "2026-10-17T00:03:23.462Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/2f58f2ba64e4799b39de85c140aef09a03aa6ea53be75dd292a9d9b9d8ae/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede", size = 1282979, upload-time = "2026-10-17T00:03:24.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/ca6087fe84210c1ccd43e3182917bb70649fa1e69745a90c1d59ba08fea8/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5", size = 1288596, upload-time = "2026-10-17T00:03:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/4ef964c763329a0347125e745ec0451ba4fbaceec9efe2ac729b7cd55f17/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49", size = 1545540, upload-time = "2026-10-17T00:03:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/bb/85/aa961dbb75813cfa94de0ce9a16a9510c226b95f604c405421e727f44bb0/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a", size = 1309374, upload-time = "2026-10-17T00:03:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e6/f2b67c327d9f9533e41791804403ccc2dc457821195a2e51f0b587a90b90/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605", size = 1303466, upload-time = "2026-10-17T00:03:30.451Z" }, + { url = "https://files.pythonhosted.org/packages/73/1d/33f18b81d1bb1f5d682943ea92fda8130a6593627f12f6201668d40824fd/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c", size = 1300576, upload-time = "2026-10-17T00:03:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/64914144925f1a4abdd9217e717b122255c38fc5ebe4342f92052f3bacbc/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a", size = 1355960, upload-time = "2026-10-17T00:03:33.676Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/bd29379fab2bce19adc0bf9177002b11b23dfe8d8fecf409998b458b4624/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8", size = 1459779, upload-time = "2026-10-17T00:03:35.09Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/012dbe733de05ffdf8e40ca0f9fa5d417cb16ec93a74463c57c001914198/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611", size = 1563399, upload-time = "2026-10-17T00:03:37.285Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/ee4bcb0358bee38744eb0f18560446572d095fe3bf6b63d6f739d08a839c/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7", size = 1558808, upload-time = "2026-10-17T00:03:38.711Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/ff44c5355251d61ef22934c4430bb31a61de4781d25a992154aac9b8c82a/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd", size = 1666324, upload-time = "2026-10-17T00:03:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bb/6da4bfc8aa3403e6447607131bf3463acaf954d990c6c8b6787c1902ddfe/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0", size = 1474089, upload-time = "2026-10-17T00:03:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/21/97/897e33bef2eefd880e6350e375cc66bc5bcd00a139be480fd655f5b3f095/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd", size = 1501542, upload-time = "2026-10-17T00:03:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/821211ac83870c27e4412381b7c7838336456b40575bf840e4680b2018d9/ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4", size = 1126118, upload-time = "2026-10-17T00:03:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/2b2d5e6c4fa352874a80618e742ffd32d249dee6c119f4233b51afc6b8f4/ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8", size = 1163766, upload-time = "2026-10-17T00:03:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/d610cfcdabaa44fd62a54ca1fc57a7dba120643798b089a14914973562de/ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3", size = 1134043, upload-time = "2026-10-17T00:03:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/0e30f9eddf7d44ccf9d25979030754fd0457af61fa4279acb8c293a04f97/ast_serialize-0.13.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5", size = 897546, upload-time = "2026-10-17T00:03:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/d705be88ff452b9ba0088b554a0cbd308fd01879da092b33fd2ad7d1aa60/ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489", size = 1242416, upload-time = "2026-10-17T00:03:51.521Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/98370845a7f35cb8a25782e78284b48e01bf3128339881d48d613f304f8f/ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a", size = 1229729, upload-time = "2026-10-17T00:03:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/2d7ffed76addd9d3fcf37fb171400942735c8912264c763561a85407f6a8/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8", size = 1292255, upload-time = "2026-10-17T00:03:54.148Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2a/bb9e0e719d89cc950fadfcfcc9cb4bc7640419c4228c852f031ab8abf440/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011", size = 1298501, upload-time = "2026-10-17T00:03:55.559Z" }, + { url = "https://files.pythonhosted.org/packages/7a/32/13ce07dce48226f5bd9cfd8f708ec101a8be05aa430cda4918ce31ce8c82/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117", size = 1553758, upload-time = "2026-10-17T00:03:56.952Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fb/9b51d186db0c20ca28cbaaefd50f03b573c89327b9a2627820cbce22196b/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef", size = 1318258, upload-time = "2026-10-17T00:03:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/da311559dd3dae9af5825cf21660775a929b970f72e3786af3f2cd667944/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226", size = 1313260, upload-time = "2026-10-17T00:04:00.194Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/97a4b9e5b4776d72f47fcd9a09a497bbf68ec8c1ae074307a2c51e410d85/ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e", size = 1309992, upload-time = "2026-10-17T00:04:01.575Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/62f85121a80cd451ef04f550cf0f7ea0ad9ff46cc4e9a5b9ae439b6527b1/ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6", size = 1362715, upload-time = "2026-10-17T00:04:02.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/c5f4a545f72a7681e3295bab6e3600ee0862ec4391bed961fe9f735b67e0/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d", size = 1469931, upload-time = "2026-10-17T00:04:04.181Z" }, + { url = "https://files.pythonhosted.org/packages/38/a9/65aed4f8fa7a001a7cfdc03e68e844517ab041ba6fb614b7d78a1d6ac056/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568", size = 1570945, upload-time = "2026-10-17T00:04:05.778Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/44b2da991cede79bfbf52089f33a5f0168945466b3d9cd38b5dcf08e7130/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b", size = 1569978, upload-time = "2026-10-17T00:04:07.347Z" }, + { url = "https://files.pythonhosted.org/packages/46/e4/109b2a5beca700237767d5add920c2f8e1ba9bd5799b0faf143d0e9cf4ef/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6", size = 1676082, upload-time = "2026-10-17T00:04:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/7df70f6dbb11b5762da5416318f08316a1c51348e0d69ec79ca5f98f836a/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037", size = 1483207, upload-time = "2026-10-17T00:04:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bc/523aa1c08bd69b5654621beb3dc1a9035715a4a63762fb8d219d6fb3eb81/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6", size = 1512140, upload-time = "2026-10-17T00:04:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/83/6607060659998f00afdea0d8375c1e6e2b7d515ab905090d9a5a02f48851/ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a", size = 1131889, upload-time = "2026-10-17T00:04:12.953Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6f/bafd6490fe7b355627fabe95f4417e3d02da93102c8552550eb9a0774e1e/ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7", size = 1171153, upload-time = "2026-10-17T00:04:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, ] [[package]] @@ -49,15 +107,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] -[[package]] -name = "boltons" -version = "25.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" }, -] - [[package]] name = "certifi" version = "2026.10.23" @@ -69,75 +118,133 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, ] [[package]] @@ -151,121 +258,147 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" +version = "7.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, + { url = "https://files.pythonhosted.org/packages/0a/25/989fd3b495c4274ac0cf54cb6664326415e60183a7f8a73dd9bbb0b11e34/coverage-7.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0cfdc938ca3319d873d1f1252e60620a39aee4ebf9dbd7cc58238109ebda9c51", size = 225938, upload-time = "2026-10-22T16:04:18.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/cb/14726426bf3241657facbf0f7df7babd8f7d3fcf8e4609b7929f9dceecbc/coverage-7.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d523da3c2e4bca49417768bcdb1a443c5b359dc44b8b93a3102e118fce912c40", size = 226166, upload-time = "2026-10-22T16:04:19.524Z" }, + { url = "https://files.pythonhosted.org/packages/8e/97/f95ed32cd3cfbe261a3598e694cb770cefc7b2d13ff013418d6f70a44dde/coverage-7.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:adbf3450c0f3f2b2854e282a8758f2b3eddf787eeff24ec9c9bc2cbc38328cc1", size = 258462, upload-time = "2026-10-22T16:04:21.079Z" }, + { url = "https://files.pythonhosted.org/packages/91/ac/eb4c31f741509a9ef48cf6f28385e98e9de0785599282252eee252ff8af7/coverage-7.17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85932227e9dd38e62cd29c17fefdfecfb919eead6af1e3259522d03cf85de983", size = 261789, upload-time = "2026-10-22T16:04:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3e/d9cd006d1a3cc5deab07beb29b86cb4a10dd84d66b330820363fb8d310d6/coverage-7.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f5477aac9c9e4a0a641c55f8f8fddd7f3bd91176c912c50f40ffced96b4034", size = 262322, upload-time = "2026-10-22T16:04:24.94Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3a/4c872c6ae0762b09cdb8e856891bb3f584848cb60b6dda2bacb85fe2f3f0/coverage-7.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b8bb651a4406fb6a983a905309fec1783443c65e1771b41e92740a0f4a1176b", size = 264600, upload-time = "2026-10-22T16:04:26.444Z" }, + { url = "https://files.pythonhosted.org/packages/df/98/1d41a2373fa98b423bcd70adb91d75e38eafb417955426dbb920b10016f3/coverage-7.17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0edeb6594f080c8de1263cccb239cb316ef77aeb959e2e0f38f2953fcc72a49e", size = 258678, upload-time = "2026-10-22T16:04:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/fe04b5e63e76506daa63d90bdfb00a53767fe5df18546b437d6226918d6b/coverage-7.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da94ec0484e84fce5c7bf8d0f54b97767a90d992653a129b35634699d516e0a5", size = 260210, upload-time = "2026-10-22T16:04:29.275Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/5c5a78dc19dd4799be9c6ec022316fb25ababaeacfaf0f52f24c56a57b43/coverage-7.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a25cc5e8c23a590815c6a7aa43624234d25cb23b21b484adb8a0daed59860db5", size = 258375, upload-time = "2026-10-22T16:04:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e7/4c1d8eaff4d756fefd15d58c4953a78ab656782fd0e32c5a3465dba61aa0/coverage-7.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db645c81cf4d6bcb392ea1894aad65d55d1588a8472c834b25ca575da3029a56", size = 262422, upload-time = "2026-10-22T16:04:32.053Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2a/0b17c3e2e7b6ff1a5d92eb58915c0d1026668928f961d14dac73cf1b6096/coverage-7.17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6a80a11b90c4a03df1502304cf24ff953255b6b5d076ef79066ecf88baaff31f", size = 257875, upload-time = "2026-10-22T16:04:33.824Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/eb0cbde0535342df0d17a667163030a3afb67e5eb9e3ad94af9540041584/coverage-7.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4eaf11d6b0f2147f46cfbf06fe7edcca6f095d0120da00ddc2458995ee869fc", size = 260361, upload-time = "2026-10-22T16:04:35.661Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/1be2a6db7eb655d0a1d899217c3f2469f9121cbbeddac35c1c8173b1482e/coverage-7.17.0-cp312-cp312-win32.whl", hash = "sha256:912a29f338798541d795e2e25903c6fe7a99dee70a40fe4706a9a124649b523e", size = 227747, upload-time = "2026-10-22T16:04:37.102Z" }, + { url = "https://files.pythonhosted.org/packages/11/c5/201c894c57de81875d8b8cc527ba222aa350e8cfb183eb3f6df45d06ad8e/coverage-7.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:88a1a9fb0075b83389dd9b403bb3fce976031b3c2aa0e05e3e57143cf639de69", size = 228325, upload-time = "2026-10-22T16:04:38.989Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/196095339b19758cf83f507fb598bc42820ae124745feb3084662729e17a/coverage-7.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:2c680e838ff9e68d491158625ca2c1ddb17d78d791bba96a31f8b839b4990438", size = 228131, upload-time = "2026-10-22T16:04:40.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/e6/1f01afbc9f4fa8ca5e1fa6fdf537a5c58b5765d1b4167ee284cddb9acc72/coverage-7.17.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:84538c040f4e6214f663459d89372ce4e6b2bbdd9d397ef0dca7b3ecd56b8eb2", size = 227080, upload-time = "2026-10-22T16:04:42.325Z" }, + { url = "https://files.pythonhosted.org/packages/92/7a/bdb7e6260c4a211389aeac9b89dc31662ebe122153ad0ecfd156897912df/coverage-7.17.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:6abfb6a4cf1cb1b67bc8fccdde177e2d2b781d0d34449e7078156547025140e5", size = 227152, upload-time = "2026-10-22T16:04:43.737Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/276e7d5b3cfb124871bdb544c27b004a8b4e270e6bf8e7414149c54b5e5b/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ef65e96f0fc1a7c8b7644c7011d47a8228555e7888ac4d6246f715a7f3d7c398", size = 225397, upload-time = "2026-10-22T16:04:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7d/dac073db12f3c131598a6693253aded581e4d7d622a89864f11bfb0ca2f5/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:55939ea4c097fda2edabf80fac5e49bef15597b950a9f29fc56292a895245feb", size = 225705, upload-time = "2026-10-22T16:04:46.603Z" }, + { url = "https://files.pythonhosted.org/packages/00/45/877b0a9248e921080ae91d115c13192d17e308f5aee86bcc7f7bc2a54ed1/coverage-7.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bfb6f868af552c0ceb2d19220b8cbcc2be096ca26163ac30e682a13041433429", size = 225828, upload-time = "2026-10-22T16:04:48.065Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f5/2c48d47abec0e4590b302920334be5ee3854f96bbd22524feabb8d92b220/coverage-7.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8d823d66afd59566030539c49c7a6acaf3083996682be2dda760c63555842094", size = 226088, upload-time = "2026-10-22T16:04:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/ec0442f3f372c7fd742f49b3022edd392f79e37a0048e1fa333f56d01ec3/coverage-7.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50678474e714c684b82c108e63fbb8f1bcf1266d8293a49980f1d08526f093b5", size = 257257, upload-time = "2026-10-22T16:04:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/eb0927ba93809f76eee69fa1e47999825fc7dbbabc3e0e1a2744f2677acb/coverage-7.17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:045c1eeb19fb4eae949850a761102fffb3a5f638b97f977912f95843e54f6669", size = 260165, upload-time = "2026-10-22T16:04:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a2/8d4c6055efb32099b15c9cc33d007b8aabebc4832f372a3274de5ba0afc5/coverage-7.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb12891a773cbc2132349c77c690a045a882fe3f98b4bda50dfabb01893f9bd", size = 261098, upload-time = "2026-10-22T16:04:53.763Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/93412288688a7b03f152a734c8ef086e395108a0a6a746985d3967e220f2/coverage-7.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3067608b4429629f640c1b23d2f69b05d2fd45e00ebb6ee9e8fad220a6c11722", size = 263164, upload-time = "2026-10-22T16:04:55.386Z" }, + { url = "https://files.pythonhosted.org/packages/3c/20/7eb8d805c4f1a212245cef20181dfbf39535357985073d49730ee9cf08d4/coverage-7.17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1762e85cbbe3b5ff9c6dcbcc1b89d5c72320094292b7b9572bf882c85e428b7", size = 257287, upload-time = "2026-10-22T16:04:56.976Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/1df29630b4ed17e135c76390afcc6bd273c3dc5ba2a21a76813a600fe98c/coverage-7.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62bb77987d3f72a0aaa77c567ee7f9806bc2e748c8f46fafbe1fdb90dd148398", size = 259256, upload-time = "2026-10-22T16:04:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/a1/79/2365d1008d28aafe468a450a24c6c63363de794b5140223d67685cd0e205/coverage-7.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6cc31f27240d4e080437e3b8229d92f1d809c9a4f233607385373b73974e183e", size = 257178, upload-time = "2026-10-22T16:05:00.103Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9f/f002952d295d772688dffce0d991e3822795b1e3aad158073712a5a5e86e/coverage-7.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61cde5222f1933d476647c5a25ed1a8678b7703098cb5a15f4a0aa39967d8c28", size = 261275, upload-time = "2026-10-22T16:05:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/f9e15fbb426be97cd679ee5d359151e046f103bcd76b5092c318c26ed596/coverage-7.17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11f5f7fb2d97a0fe865f5e7bad4eb0619b957637f676bc088d13844adf26ba95", size = 257029, upload-time = "2026-10-22T16:05:02.8Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4a/3c398f38694387334ff4c9f71afc8e15b4fa7db7bc0bea4d5b6032eb0df0/coverage-7.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e09612b1b82c9df1d2b6c85dee88d5aad3e73d77a8ec0b16ac6a7322cbdf5d9", size = 259109, upload-time = "2026-10-22T16:05:04.241Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a9/908a3cf1a2335aec1c788682a06c2f742a62a4f987033f080de1c5fd1997/coverage-7.17.0-cp313-cp313-win32.whl", hash = "sha256:bd3e9eb493631ff1ca34bf5efac25b5c66724f837bf6182c4fc771c55946423d", size = 227691, upload-time = "2026-10-22T16:05:05.636Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/8cdd7bfd8a63b3e6b133fc395ecfc7b1cb6f561926e7e2ad050de85b83e7/coverage-7.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1fc9a7cea899c51d8a53e66c2f082a3d9e461ece32418d3ef0a0b90c32bfd3d", size = 228418, upload-time = "2026-10-22T16:05:07.057Z" }, + { url = "https://files.pythonhosted.org/packages/09/cf/0b3fc57f735cd3831ca13d0be5312a5477d5934066683c5d34018cab7541/coverage-7.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:e65a1e61a68493fbfbe44d8bec71b3231d81b9917f62d3dafe0e105072147e83", size = 228142, upload-time = "2026-10-22T16:05:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/722462fe79912ebebc101497bd16c42413cde2dbae771017f1ec0acb84c2/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-22T16:05:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/826b9c09f2ebfea803bccb391d935688a25309bff8e819fc446bc629c108/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/6d/19/c8514d7f5e0659a96ff2bc7377119dca6d420e2761c5fc69ae37618734f8/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12.761Z" }, + { url = "https://files.pythonhosted.org/packages/81/74/ef9433ce599f07d8633b4ce6e6d6921505c4ef3f7f9006f8eb66e2c278cf/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95", size = 225787, upload-time = "2026-10-22T16:05:14.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/26e58981940e8b3944c6a9941bec5c7f50840e7009a046712e3f3bcd9e6a/coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85", size = 226019, upload-time = "2026-10-22T16:05:15.798Z" }, + { url = "https://files.pythonhosted.org/packages/e9/63/fe175310815cc8357b83b773c9b4c20943c269f217dd825a37c4b4bfe324/coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e", size = 226169, upload-time = "2026-10-22T16:05:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b0/ac84761f5b4bd226696b0ec4606903d2ef7208abda0aec06c14a4218def4/coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7", size = 258092, upload-time = "2026-10-22T16:05:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/e9/58/d8bdd65a661913bc12eaaa3e59742b2cb8b94f2e7f78e62184f0a58ca493/coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f", size = 261096, upload-time = "2026-10-22T16:05:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/5d82e2f0a75ea149e0a6412e137e1d610538772fdfd7199737db7fe234da/coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33", size = 262080, upload-time = "2026-10-22T16:05:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/e3ebfd295d1dd47c7bc84a17d73f150465a37ab388dd68fe82b495dc79a0/coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e", size = 264558, upload-time = "2026-10-22T16:05:22.906Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/e2adff76d6544bbc6df7366d74967923c2de37ba889a006bfb875bf5857b/coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274", size = 257855, upload-time = "2026-10-22T16:05:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/ecd1e9cddf1c36b5d03c438568b1f53e63db84bb3656539d71b454484c7d/coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2", size = 259933, upload-time = "2026-10-22T16:05:25.79Z" }, + { url = "https://files.pythonhosted.org/packages/40/b6/e17a19dccdbcad494c64691f71c1dba3e55c30625bc29ffbb88ac5a0aa8b/coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76", size = 257972, upload-time = "2026-10-22T16:05:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0d/37dac534709a3fa43a937c8b0604839bb44b8670b83d143a4b78fec20d1e/coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048", size = 262271, upload-time = "2026-10-22T16:05:28.619Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/7c05186925b4d64a46c271dfd944e3f0c1ad89db9095c31a6df157a75df2/coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c", size = 257604, upload-time = "2026-10-22T16:05:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/95/65/c241cdee66a0ff86c68f6d37328e735d10e3d30cd6ff66f356039eb688d2/coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e", size = 259714, upload-time = "2026-10-22T16:05:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/db/09/e93233fe2405574ce0a31ed201c0cba3a860e085db5d2828bf12b7dc0790/coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657", size = 227914, upload-time = "2026-10-22T16:05:32.78Z" }, + { url = "https://files.pythonhosted.org/packages/d7/77/04979eb217edb94b94afabb4bcc179f60c746f68d3fd0b12688ba9195730/coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709", size = 228543, upload-time = "2026-10-22T16:05:34.118Z" }, + { url = "https://files.pythonhosted.org/packages/a0/38/155f2f2c6769f8c43eca8a5e581443faa0908828650090b97103b4a41376/coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23", size = 228396, upload-time = "2026-10-22T16:05:35.951Z" }, + { url = "https://files.pythonhosted.org/packages/52/55/d6751572d9445485c4c5a51ec6ae11caa5c11b7ef2448cdccc884f7f54d9/coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c", size = 226486, upload-time = "2026-10-22T16:05:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a8/82d37760e537bfdb9fdee285fa1e1b45bc153293d23e2331c7befedd1990/coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4", size = 226861, upload-time = "2026-10-22T16:05:38.777Z" }, + { url = "https://files.pythonhosted.org/packages/24/96/70ec00a9db6d7bdd3f914eba12057360769f5cb78a98456e71fe1937deb5/coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8", size = 266849, upload-time = "2026-10-22T16:05:40.285Z" }, + { url = "https://files.pythonhosted.org/packages/77/d8/d99be3bfbb208512f039c80b31e404138a86678fc929a17acd5b7135a6ae/coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e", size = 269402, upload-time = "2026-10-22T16:05:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/6cbe8b5a5060edbd21ce8109c4e900f8b7656e8b72ede6dc4d82dc0b8bfb/coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764", size = 272025, upload-time = "2026-10-22T16:05:42.921Z" }, + { url = "https://files.pythonhosted.org/packages/bd/05/d3633add01e5518dfdacfdbd1be41cb5c581412dd8bccd663edf6ef7fec8/coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88", size = 273515, upload-time = "2026-10-22T16:05:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/57/d9/b83387780271bf9ea9f96a3aeb01c1c302c4494a927923c4fa70cd170a3f/coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf", size = 266725, upload-time = "2026-10-22T16:05:45.713Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/8e80b29764ae3f4d70d44952eee3f3927eb36897490fe799e400adb7f5da/coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c", size = 269312, upload-time = "2026-10-22T16:05:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/e702f588fe8d83ca844f133270ad0a49bf650c8cf260551ff6f11a6b6bbf/coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b", size = 266740, upload-time = "2026-10-22T16:05:48.343Z" }, + { url = "https://files.pythonhosted.org/packages/8a/11/ee13ad14b714d75ef07c490b6e43969574a8b8b8c6d67bc741fcbacbf9c3/coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2", size = 271036, upload-time = "2026-10-22T16:05:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/e2/bc/854e8912c11c54aaf416e74602000589c6efbbe1f251dc90202fdabddcb6/coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c", size = 266158, upload-time = "2026-10-22T16:05:51.141Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/96432830ddf63bc42654de54eee77a0cd6949cc65769bda06f353323f23a/coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f", size = 268088, upload-time = "2026-10-22T16:05:52.92Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ee/230c6ec065a6629d41cc89323ceb54d37e2650c104f70fc108f4ee7c5051/coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa", size = 228229, upload-time = "2026-10-22T16:05:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1d/23eb6aadcb3c00764ff2961c0ff55f5dfa2543856d932b949bbc04fa68e9/coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673", size = 229154, upload-time = "2026-10-22T16:05:55.682Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f1/7df117e8e016b7a3ee459e833b3456f3610505afefd071f8727702d98439/coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74", size = 228726, upload-time = "2026-10-22T16:05:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/f9c24911755f91e6f451ca2e2c1804fbcbe9fecbc3310e341bbbbfa1d85b/coverage-7.17.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:adabc1079ba844b096799ba8638d8df434aca1613f95bb7d5791af320cf05559", size = 227196, upload-time = "2026-10-22T16:05:58.543Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a5/2b665b40c626e6bb40e5e8e23d91170b48b5f2f57186f46b07f2fac42386/coverage-7.17.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:6b4593d2871c4c801656dd5861e759f8f5156febd6b6061b02f6a72d64618b7b", size = 227315, upload-time = "2026-10-22T16:06:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8c/22b518d61b930b8a4d62905d046c76a03675ef19b466462011dfb16439a3/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c2c48a36bb970b7265b196032bfd4237c0b373f2b25ef7012d7e905b5b35c19c", size = 225490, upload-time = "2026-10-22T16:06:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/08/5b/d3b141455944e030cd4987f055f8c84a44a1a0b28d81ae93389f44051691/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f8bc2cd4c6aa58be1ca243fb8cddb6c3c42031fe4403991ad9cf2b3baed03d9f", size = 225797, upload-time = "2026-10-22T16:06:03.423Z" }, + { url = "https://files.pythonhosted.org/packages/03/24/aab2156983f7022361352640c583d08f0238e4cd7463eb921172001b4cfe/coverage-7.17.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cbf0b83ce927de6053279ba61342d9f8892afde1385fcba9144cd05d4023d109", size = 226025, upload-time = "2026-10-22T16:06:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/db/94697b9ccf4ec94cdad07bf94e2a21b9702247cb212d2d5f18d2b9c3d0de/coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:66efc0fb99cee34626fc17d3adac9c3cd816bfdad563bb1e983c3ef432f566d4", size = 226193, upload-time = "2026-10-22T16:06:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/a0/fd/db1c4ade4219831d590fff5aa5b7d9ceaf84586e8e60da6fff71b6182251/coverage-7.17.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58a304efdf2253fb2ea03d45d0a04cc174a92a0cd5279c28c5bb893ce7410968", size = 258046, upload-time = "2026-10-22T16:06:07.51Z" }, + { url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c", size = 261735, upload-time = "2026-10-22T16:06:08.861Z" }, + { url = "https://files.pythonhosted.org/packages/64/ad/c1e4367bbd1e7d562d6bc979c82b542f2040544e95a8cd995b694caf0e1a/coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6cdb47a9be2d3313829bed1d296086287682a3ee901ff987b3432b45705b771", size = 262819, upload-time = "2026-10-22T16:06:10.49Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3a/e8e8509b0940403eab15d86f70022790a01fce3e971a161919c544953f7a/coverage-7.17.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:804fbaf81d09b34e2f8b1159d01e211a50713d743d32fd9b2ca6cf0ae507a66f", size = 265194, upload-time = "2026-10-22T16:06:11.847Z" }, + { url = "https://files.pythonhosted.org/packages/1f/77/d64bbdbb734b1059ebcde2b5df567f142a9948079e51af9b0abf8c9ec17b/coverage-7.17.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2afebb96da9a1bbca142f8cc12320c14e5c215e0a49cded4f191f5a31506a28", size = 258410, upload-time = "2026-10-22T16:06:13.234Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/7cf81f08a254532c074eb52cfe6b12bb753d08f2997269c9fac8de359541/coverage-7.17.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd04fd23206be9ad3de13b0b39313094fafa66a1f6baf70f5471c004a9fac47d", size = 260697, upload-time = "2026-10-22T16:06:14.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/56/bacea6dee2d5ccb31ab0cfffcee87ca7efa80ff048840209c8013aeaf02e/coverage-7.17.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:5ebfbc87836529a31d25c2dec1262c38eea852ec1c46c0f690a515a7339d9c5b", size = 258649, upload-time = "2026-10-22T16:06:16.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/97/fd8a6a63740f9ee177c53b59e2f4d0752369bb0fd154086a1e69aa1fb255/coverage-7.17.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a59c3b470ac3b2432af3598a996affbeb10236cd899a005c1c4e517fcff8b592", size = 262989, upload-time = "2026-10-22T16:06:17.85Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/7cd3d3520d45db3788456452b65446182c08f2a7c276f40c9dd11966be02/coverage-7.17.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:7fef12cec4b96f59e09761cbd4fc9bdf4788062d1ae435b920241fc16c8a9549", size = 257955, upload-time = "2026-10-22T16:06:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b9/80343a55e07d854927a915cc21a52e350ae1db9ead33f0248507b960785f/coverage-7.17.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a14e8d69464da9cbe1dc29e7116dbd37296d1d51a695327c478e250349085089", size = 260512, upload-time = "2026-10-22T16:06:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/f3/32/e4c816f0cb3efdc6392bb276ff88359729f9dec605bdd3c1c7a44776f759/coverage-7.17.0-cp315-cp315-win32.whl", hash = "sha256:9a66074231799d8884d267234d49f60c90b43a945dfe069646b72f3058cd4e67", size = 227913, upload-time = "2026-10-22T16:06:22.407Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/283120142ac281834808584249b89957de997c28573ddd65ae106b622121/coverage-7.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:79cf71e5f9a48877ccfed5baa2c72f45af4feeaa596249e23eb15b473134830e", size = 228532, upload-time = "2026-10-22T16:06:23.756Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bc/83dbc6c3bd58042afaefcb0cfd7d7a2e3076989a2f3d9bc6e6edac819784/coverage-7.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:09e563b49c81cd0a33734e926d7a4a6ef1778d74304cde2dcc3aff8b30ea1036", size = 228393, upload-time = "2026-10-22T16:06:25.163Z" }, + { url = "https://files.pythonhosted.org/packages/00/ed/9463d1cb1766a2570343a372c2871c7fb4d561e5051f029399f4d57ce947/coverage-7.17.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a5e2e0625dfc789af0b2e10acfc106b352f314fffbd19e6392a7961c8cbe5d9a", size = 226481, upload-time = "2026-10-22T16:06:26.563Z" }, + { url = "https://files.pythonhosted.org/packages/ae/59/6536c7fa87be13236b4184fc395f14d18f622a5b8ef7448a6bfdf5f24ec0/coverage-7.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:194562ab1eff0d85aee8a17ce4c5c0bc1ebf70a629a7145257123156ad100fd6", size = 226876, upload-time = "2026-10-22T16:06:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bc/4ffae6038f78382c9ab6a4969cb5fc01d50b083d119851085807629abe88/coverage-7.17.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:85682ca89bc4601125dcdff23fa70e6274e13c9ad502fba845e6c274ab2c6569", size = 267241, upload-time = "2026-10-22T16:06:29.356Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/cae4b708b4217d33ebf66b9c57f8910a257346b3eaf054a78d73605ddc4c/coverage-7.17.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0d0fac604a962ff546beb038cba9a5a09d88e6e3f01612fb46f7bd4fe5dc8dca", size = 270079, upload-time = "2026-10-22T16:06:30.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/84/1225ab04e55a6a2e483e903a5eb0f8f081cb5325df833594534aa288d91c/coverage-7.17.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72edd4f9483ae30cd1ba4f230687840ef7b58a16afb1c9e81131fd6d64655f36", size = 272775, upload-time = "2026-10-22T16:06:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/ad/567f8dd9246a553aa8753732c41f0e0654e98e9a617d6282e64eae0e6aa8/coverage-7.17.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91e487ad07c511da77b574820141a7c2da09bc2a4b4090b5be2424295d100697", size = 274110, upload-time = "2026-10-22T16:06:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/66/5f/e0ee76895df40601e4aedffb6b8e71ea48e276a629fb4488b92cffffca14/coverage-7.17.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1cabb31531002fef5197de5df3c84730aef01e750e25154b9813a2583435aba2", size = 267775, upload-time = "2026-10-22T16:06:35.193Z" }, + { url = "https://files.pythonhosted.org/packages/19/6d/d19c6e45e940e5b9ba697b7be9a8e63cef73d90bdea45b670a59df466717/coverage-7.17.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c9ac886242bbd50fb35a2242ba79555a99891e657da33f98b7ca968e3488a350", size = 270268, upload-time = "2026-10-22T16:06:36.597Z" }, + { url = "https://files.pythonhosted.org/packages/27/a3/b2731bb7aef2db800b330603675c3fa58a6497a05d5b5f8625f3b6285f68/coverage-7.17.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d45cdc495671fc99268ff3653e0da4244972e9fc0e1fc3ea9de18781ad28963a", size = 267298, upload-time = "2026-10-22T16:06:37.973Z" }, + { url = "https://files.pythonhosted.org/packages/50/75/d0c90a81b6b6607933109914dc8b0ef0a5f4cacf9482ca868ae773ee5d0b/coverage-7.17.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:12961f738d717a046d4bf3f8d1727eb3ebe2ed5478ff96cd6233d4dfc39323d3", size = 271899, upload-time = "2026-10-22T16:06:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/64/1e/f038f3a2614e9ef839a40a184713aca40b8ed79e626ab5cdc388771045df/coverage-7.17.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1ee0a982b0e1230b3edf768a7dd022bd2377303763895327ac62deb5efacee03", size = 267380, upload-time = "2026-10-22T16:06:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/8d636e639b2d8b1934128573402a7caf66c8cfbc794fa02061d84f115c34/coverage-7.17.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:460679f53893081edb94eeed011d26be37228ba649b853fd2aed7e4394fb1f35", size = 268993, upload-time = "2026-10-22T16:06:42.361Z" }, + { url = "https://files.pythonhosted.org/packages/73/7a/219f6b391cb0636242d06c390a8add5af91afa3e92afd0380dbedd34f487/coverage-7.17.0-cp315-cp315t-win32.whl", hash = "sha256:600e121e412cd6bdb52ec105b95c6a04da0c9710d1534f6e64a42db667907802", size = 228216, upload-time = "2026-10-22T16:06:43.714Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3f/514d294319c5eac11494394545de209079f33993e14526c220eab29b2a96/coverage-7.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c3e11b81faafb1f063248735c3e2c5c4183fc1db2c7f46782170dd7760dffcbc", size = 229146, upload-time = "2026-10-22T16:06:45.262Z" }, + { url = "https://files.pythonhosted.org/packages/f6/82/c0d8bb0a67df838fc7bc6f4060ffc404fd1154fd18e624d83e6abc41aeef/coverage-7.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d472ee1c64868a917a47f02ffb4ac8c22b477f7589d57a3eb0c58b1ad4f0137c", size = 228716, upload-time = "2026-10-22T16:06:47.097Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" }, ] [[package]] name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] -name = "face" -version = "26.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "boltons" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/24/4e/0e106b0ba486cc38c858fb5efe899002f2ec4765e0808b298d8e19a16efb/face-26.0.0.tar.gz", hash = "sha256:ae12136ff0052f124811f5319670a8d9d29b7d2caaaabe542813690967cc6bca", size = 49862, upload-time = "2026-02-14T00:17:12.576Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/1d/c2f7a4334f7501a3474766b5bc0948e8e0b0916217a54d092dd700a5ed3c/face-26.0.0-py3-none-any.whl", hash = "sha256:6ec9cf271d8ee2447f04b14264209a09ec9cbe8252255e61fb7ab6b154e300f9", size = 54825, upload-time = "2026-02-14T00:17:11.519Z" }, -] - -[[package]] -name = "glom" -version = "25.12.0" +name = "docutils" +version = "0.23" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "boltons" }, - { name = "face" }, +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] -sdist = { url = "https://files.pythonhosted.org/packages/78/74/8387f95565ba7c30cd152a585b275ebb9a834d1d32782425c5d2fe0a102c/glom-25.12.0.tar.gz", hash = "sha256:1ae7da88be3693df40ad27bdf57a765a55c075c86c971bcddd67927403eb0069", size = 196128, upload-time = "2025-12-29T06:29:07.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/e6/4129d9a3baa72d747533bb33376543ccadd9a7f9944e5a6e3ae2e245f5d6/glom-25.12.0-py3-none-any.whl", hash = "sha256:b9f21e77f71a6576a43864e85066b8cc3f0f778d0d50961563f8981377a6dcb1", size = 103295, upload-time = "2025-12-29T06:29:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, ] [[package]] @@ -273,14 +406,16 @@ name = "hdhelpers" version = "0.0.10" source = { editable = "." } dependencies = [ - { name = "glom" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, - { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, - { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, - { name = "pandas", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, - { name = "plotly" }, + { name = "numpy" }, + { name = "pandas" }, { name = "pydantic" }, { name = "pytz" }, + { name = "tzdata" }, +] + +[package.optional-dependencies] +plotting = [ + { name = "plotly" }, ] [package.dev-dependencies] @@ -292,21 +427,23 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, - { name = "sphinx" }, + { name = "sphinx", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "sphinx", version = "9.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "types-pytz" }, ] [package.metadata] requires-dist = [ - { name = "glom", specifier = ">25,<26" }, { name = "numpy", marker = "python_full_version < '3.15'" }, { name = "numpy", marker = "python_full_version >= '3.15'", specifier = ">=2.5.2" }, { name = "pandas", marker = "python_full_version < '3.15'", specifier = ">=3" }, { name = "pandas", marker = "python_full_version >= '3.15'", specifier = ">=3.0.6" }, - { name = "plotly" }, + { name = "plotly", marker = "extra == 'plotting'" }, { name = "pydantic" }, { name = "pytz" }, + { name = "tzdata" }, ] +provides-extras = ["plotting"] [package.metadata.requires-dev] dev = [ @@ -323,29 +460,29 @@ dev = [ [[package]] name = "idna" -version = "3.13" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] name = "imagesize" -version = "2.0.0" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz", hash = "sha256:b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5", size = 1883774, upload-time = "2026-08-24T12:35:19.13Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, + { url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl", hash = "sha256:ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02", size = 14794, upload-time = "2026-08-24T12:35:12.548Z" }, ] [[package]] name = "iniconfig" -version = "2.3.0" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] [[package]] @@ -362,168 +499,270 @@ wheels = [ [[package]] name = "librt" -version = "0.9.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, - { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, - { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, - { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, - { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, - { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, ] [[package]] name = "markupsafe" -version = "3.0.3" +version = "3.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, ] [[package]] name = "mypy" -version = "1.20.2" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, - { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, - { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, - { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, - { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, ] [[package]] @@ -537,91 +776,17 @@ wheels = [ [[package]] name = "narwhals" -version = "2.20.0" +version = "2.27.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/257adc69a71011b4c8cda321b00f02c5bf1980ae38ffd05a58d9632d4de8/narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e", size = 627848, upload-time = "2026-04-20T12:11:45.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/21/f64d6b2dbea7bf3f8c38cdc786dcc6ef012ca3d173ad208c782c9a7bedf6/narwhals-2.27.1.tar.gz", hash = "sha256:aed93076a3ea42d9c32c88e4eb5ea422a21937011cbe1f480f9572a523c82094", size = 735013, upload-time = "2026-10-10T06:52:18.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" }, -] - -[[package]] -name = "numpy" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.14.*' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl", hash = "sha256:d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31", size = 483211, upload-time = "2026-10-10T06:52:16.32Z" }, ] [[package]] name = "numpy" version = "2.5.4" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, @@ -693,84 +858,19 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pandas" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.14.*' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, - { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, - { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, - { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, - { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, - { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, - { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, - { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, - { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, - { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, - { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, - { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, - { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, - { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, - { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pandas" version = "3.1.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] dependencies = [ - { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] @@ -829,15 +929,14 @@ wheels = [ [[package]] name = "pandas-stubs" -version = "3.0.0.260204" +version = "3.0.5.260914" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, - { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, + { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/1d/297ff2c7ea50a768a2247621d6451abb2a07c0e9be7ca6d36ebe371658e5/pandas_stubs-3.0.0.260204.tar.gz", hash = "sha256:bf9294b76352effcffa9cb85edf0bed1339a7ec0c30b8e1ac3d66b4228f1fbc3", size = 109383, upload-time = "2026-02-04T15:17:17.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/93/8948ae6c1e1e3d6833596fd266f7be2d27c1451b8be094975ad42c5e842e/pandas_stubs-3.0.5.260914.tar.gz", hash = "sha256:3f6fc1f147f68fd89c007105e7c94a948acb4ecd7eb20dc1c02e153c4ed5c250", size = 117622, upload-time = "2026-09-14T16:42:35.065Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/2f/f91e4eee21585ff548e83358332d5632ee49f6b2dcd96cb5dca4e0468951/pandas_stubs-3.0.0.260204-py3-none-any.whl", hash = "sha256:5ab9e4d55a6e2752e9720828564af40d48c4f709e6a2c69b743014a6fcb6c241", size = 168540, upload-time = "2026-02-04T15:17:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cb/5ad79e02a556cc23fed5816de0109fa8af660c66cfa5f4af74c3e8d4cd26/pandas_stubs-3.0.5.260914-py3-none-any.whl", hash = "sha256:39a1300c5c5c55fdf609e3476805decce5d5015539a4dcb683449f8feaeee2fb", size = 177344, upload-time = "2026-09-14T16:42:33.771Z" }, ] [[package]] @@ -860,29 +959,29 @@ wheels = [ [[package]] name = "plotly" -version = "6.7.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "narwhals" }, { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/c3/72b369f5ed7701b04ab0ea3dcf83e9bbce71c0b3bc6f07f87568550d09ea/plotly-7.1.0.tar.gz", hash = "sha256:f860166a4a3d78c69cb1f4a15f28a5c8283eade98a282a698f3bb853a449ace5", size = 6689315, upload-time = "2026-09-15T19:21:21.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7d/905a3a3d51087515719058c94cfbda2ff0fc14417c20d557ae3e82d8b250/plotly-7.1.0-py3-none-any.whl", hash = "sha256:dbb7fa18afce40d0a8e80d1bf162eceb3faa0ce5a77fe741ad09a74cf78f53f3", size = 9692368, upload-time = "2026-09-15T19:21:18.331Z" }, ] [[package]] name = "pluggy" -version = "1.6.0" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ] [[package]] name = "pydantic" -version = "2.13.3" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -890,98 +989,129 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.3" +version = "2.50.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, - { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, - { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, - { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, ] [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -990,9 +1120,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -1011,28 +1141,29 @@ wheels = [ [[package]] name = "python-dateutil" -version = "2.9.0.post0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, + { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/df/f15f80383f152452d8985b36d95cb8751f901a3e0ff4cd27f7e118f168e2/python_dateutil-3.0.0.tar.gz", hash = "sha256:7ebcb3c29a92f3ceafe79a7a48a5c45677e9e004bbeec5742ac0bbb00560b7a2", size = 271066, upload-time = "2026-10-21T23:41:45.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, ] [[package]] name = "pytz" -version = "2026.1.post1" +version = "2026.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" }, ] [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1040,9 +1171,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -1056,27 +1187,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" +version = "0.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, ] [[package]] @@ -1090,22 +1221,66 @@ wheels = [ [[package]] name = "snowballstemmer" -version = "3.0.1" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +resolution-markers = [ + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/73/567727fc8c8d85bc3adc88a246e12937cfda8d565cf1d80e85bceaa61fe5/sphinx-9.1.1.tar.gz", hash = "sha256:48b627f02908f22c601b3ba9c7b17265b4f315dd05c8bc64cd4da41183f44fbc", size = 8725638, upload-time = "2026-10-20T00:41:13.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/47/18/3c66ea76da7de47e716b181c35bdbfafbd5e9af0bb247b14578e9de348e9/sphinx-9.1.1-py3-none-any.whl", hash = "sha256:f8b60013ce25017be41a509e5c2d265bc1918410061315c36b809c7f335646c0", size = 3923559, upload-time = "2026-10-20T00:41:11.288Z" }, ] [[package]] name = "sphinx" -version = "9.1.0" +version = "9.2.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, + { name = "docutils", version = "0.23", source = { registry = "https://pypi.org/simple" } }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, @@ -1120,9 +1295,9 @@ dependencies = [ { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/d4/413466f0be8b1cb0e3e43893a148b96d7462d0923d82b4166b21afc5f30c/sphinx-9.2.0.tar.gz", hash = "sha256:90470459f3cab15d9dfed146c71cf77694b0445df17b2e0e69059cb9f7fc9ee4", size = 8782438, upload-time = "2026-10-21T00:05:15.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, + { url = "https://files.pythonhosted.org/packages/c4/28/a0c50761cd215b7042f4bc617a6bc5c0cd7be62f75bc908e169e28a4d21e/sphinx-9.2.0-py3-none-any.whl", hash = "sha256:4c3ec13b612712c0d3ce4cbdd9e3eb91e68577e07a8e31f32f21cf178453578c", size = 3925388, upload-time = "2026-10-21T00:05:12.241Z" }, ] [[package]] @@ -1154,11 +1329,11 @@ wheels = [ [[package]] name = "sphinxcontrib-jsmath" -version = "1.0.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/02/4f334c7fc3e54c51d2364575a41cd2fae6847e7f53001949f0be5f32e18b/sphinxcontrib_jsmath-2.0.0.tar.gz", hash = "sha256:551f780cf9687f9c3c74f0cf9f18aca06e0d6958853453f3803fe4d357c5b924", size = 14511, upload-time = "2026-10-20T18:22:05.514Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/da/c2/2965c35b39587d4b7a397f65a9c7d4b942b031061e7ac1ce91782f394a0d/sphinxcontrib_jsmath-2.0.0-py3-none-any.whl", hash = "sha256:15ca92fe0eec82f0baf91a7efcc51ffacdb605924be6327683a52ebdacb6744c", size = 78095, upload-time = "2026-10-20T18:22:04.42Z" }, ] [[package]] @@ -1181,48 +1356,48 @@ wheels = [ [[package]] name = "types-pytz" -version = "2026.1.1.20260408" +version = "2026.5.0.20261006" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/b7/33f5a4f29b1f285b99ff79a607751a7996194cbb98705e331dab7a2daa28/types_pytz-2026.1.1.20260408.tar.gz", hash = "sha256:89b6a34b9198ea2a4b98a9d15cbca987053f52a105fd44f7ce3789cae4349408", size = 10788, upload-time = "2026-04-08T04:28:14.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/47/b493f47f2dd0971db06459439ebdeb114cf95029188268099b331bbc4727/types_pytz-2026.5.0.20261006.tar.gz", hash = "sha256:1a522c2ec03aad8d4baaf97105958019ad51704b1e471c882d1c6ccea3e5e64b", size = 11073, upload-time = "2026-10-06T08:15:12.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/90/12c059e6bb330a22d9cc97daf027ac7fb7f50fbf518e4d88185b4d39120e/types_pytz-2026.1.1.20260408-py3-none-any.whl", hash = "sha256:c7e4dec76221fb7d0c97b91ad8561d689bebe39b6bcb7b728387e7ffd8cde788", size = 10124, upload-time = "2026-04-08T04:28:13.353Z" }, + { url = "https://files.pythonhosted.org/packages/77/38/e375026fb4ff74fed6bdb84d7d336571d0603b6b9675751579cec84cfc9b/types_pytz-2026.5.0.20261006-py3-none-any.whl", hash = "sha256:9e4a893b362a8eed4e10a348c80603ade65bdb3819419e589364afafe2ea08b1", size = 10168, upload-time = "2026-10-06T08:15:11.985Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] name = "tzdata" -version = "2026.2" +version = "2026.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, ] [[package]] name = "urllib3" -version = "2.6.3" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ]