diff --git a/.gitignore b/.gitignore index 97025922..c8e0eb9c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,9 @@ simdb-coverage-report src/simdb/_version.py *.egg-info *.egg -*.whl \ No newline at end of file +*.whl +.simdb-instances/ +_study +.serena +myenv +testpulse diff --git a/config/simdb.cfg b/config/simdb.cfg index 4d0f0162..4a6816f5 100644 --- a/config/simdb.cfg +++ b/config/simdb.cfg @@ -24,7 +24,7 @@ password = simdb database = simdb [validation] -path = ./validation +path = ./validation/iter_scenarios_validation.yaml auto_validate = True error_on_fail = True diff --git a/docs/maintenance_guide.md b/docs/maintenance_guide.md index e5664af2..475bfd5a 100644 --- a/docs/maintenance_guide.md +++ b/docs/maintenance_guide.md @@ -210,15 +210,16 @@ SSL_ENABLED = True ... ``` -Now create a validation schema in the application configuration directory, which can be located by using: +Now create a validation schema and configure its path. Set `validation.path` in the server config to point directly to your YAML schema file: +```ini +[validation] +path = /path/to/iter_scenarios_validation.yaml ``` -dirname "$(simdb config path)" -``` -In this directory, you should create a file ‘validation-schema.yaml’ specifying the validation schema. -Example of validation-schema.yaml: -``` +Example schema file: + +```yaml description: required: true type: string diff --git a/pyproject.toml b/pyproject.toml index 17816432..558f76c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "numpy>=1.14", "pydantic>=2.10.6", "python-dateutil>=2.6", + "plotext==5.3.2", "pyyaml>=3.13", "requests>=2.27.0", "semantic-version>=2.8", diff --git a/src/simdb/cli/commands/simulation.py b/src/simdb/cli/commands/simulation.py index d66b0851..8fc0e6c5 100644 --- a/src/simdb/cli/commands/simulation.py +++ b/src/simdb/cli/commands/simulation.py @@ -18,7 +18,12 @@ from simdb.validation import ValidationError, Validator from . import check_meta_args, pass_config -from .utils import print_simulations +from .utils import ( + is_numeric_1d, + print_quantity, + print_simulations, + show_quantity_textual_plot, +) from .validators import validate_non_negative @@ -377,6 +382,69 @@ def simulation_query( ) +@simulation.command("data", cls=n_required_args_adaptor(2)) +@pass_config +@click.argument("remote", required=False) +@click.argument("sim_id") +@click.argument("ids_path") +@click.option("--username", help="Username used to authenticate with the remote.") +@click.option("--password", help="Password used to authenticate with the remote.") +def simulation_data( + config: Config, + remote: Optional[str], + sim_id: str, + ids_path: str, + username: Optional[str], + password: Optional[str], +): + """Fetch IDS field data for simulation SIM_ID (UUID or alias) from REMOTE. + + \b + IDS_PATH format: + ids_name[:]/path/to/field + + \b + Examples: + simdb sim data iter 4dd781b... profiles_1d[0]/grid/rho_tor_norm + simdb sim data 4dd781b... equilibrium:0/time_slice[0]/profiles_1d/psi + """ + api = RemoteAPI(remote, username, password, config) + + try: + result = api.get_simulation_data(sim_id, ids_path) + except Exception as err: + raise click.ClickException(str(err)) from err + + click.echo(f"simulation : {result['simulation']}") + click.echo(f"path : {result['path']} (occurrence {result['occurrence']})") + + coordinates = result.get("coordinates") or [] + plot_coordinate = next( + ( + coord + for coord in coordinates + if isinstance(coord.get("data"), list) + and isinstance(result["field"].get("data"), list) + and len(coord["data"]) == len(result["field"]["data"]) + ), + None, + ) + field_is_1d = is_numeric_1d(result["field"].get("data")) + if field_is_1d: + show_quantity_textual_plot( + result["field"], label="field", x_quantity=plot_coordinate + ) + else: + print_quantity(result["field"], label="field") + + if config.verbose and coordinates: + for coord in coordinates: + if field_is_1d and is_numeric_1d(coord.get("data")): + continue + if isinstance(coord.get("data"), list): + print_quantity(coord, label=f"coord {coord['name']}", show_stats=False) + + @simulation.command("validate", cls=n_required_args_adaptor(1)) @pass_config @click.argument("remote", required=False) diff --git a/src/simdb/cli/commands/utils.py b/src/simdb/cli/commands/utils.py index ab2c919a..e1b42cb7 100644 --- a/src/simdb/cli/commands/utils.py +++ b/src/simdb/cli/commands/utils.py @@ -1,7 +1,12 @@ from collections import OrderedDict -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, TypeVar import click +import plotext +from rich.console import Console, Group +from rich.panel import Panel +from rich.table import Table +from rich.text import Text if TYPE_CHECKING: # Only importing these for type checking and documentation generation in order to @@ -10,6 +15,226 @@ else: Config = TypeVar("Config") +_RICH_CONSOLE = Console() + + +def _get_shape(data: Any) -> Tuple[int, ...]: + """Recursively compute shape of a nested list""" + if not isinstance(data, list): + return () + if not data: + return (0,) + return (len(data), *_get_shape(data[0])) + + +def _fmt_val(v: Any) -> str: + if isinstance(v, float): + return f"{v:.6g}" + return str(v) + + +def _fmt_row(row: list) -> str: + """Format a 1-D list with numpy-style head/tail truncation.""" + if len(row) <= 8: + return " ".join(_fmt_val(v) for v in row) + head = " ".join(_fmt_val(v) for v in row[:3]) + tail = " ".join(_fmt_val(v) for v in row[-3:]) + return f"{head} ... {tail}" + + +def _is_numeric(v: Any) -> bool: + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def is_numeric_1d(data: Any) -> bool: + return isinstance(data, list) and bool(data) and all(_is_numeric(v) for v in data) + + +def _quantity_axis_label(q: dict, fallback: str = "") -> str: + name = q.get("name") or fallback + units = q.get("units") or "-" + label = str(name).rsplit("/", 1)[-1] or str(name) + return f"{label} [{units}]" + + +def _build_array_body(data: list, shape: Tuple[int, ...]) -> str: + """Build string for 1-D or 2-D arrays.""" + if len(shape) == 1: + return f"[{_fmt_row(data)}]" + + if len(shape) == 2: + if len(data) <= 8: + rows = data + lines = [f" [{_fmt_row(row)}]" for row in rows] + else: + lines = [f" [{_fmt_row(row)}]" for row in data[:3]] + lines.append(" ...") + lines += [f" [{_fmt_row(row)}]" for row in data[-3:]] + formatted_lines = "\n".join(lines) + return f"[\n{formatted_lines}\n]" + + return f"<{len(shape)}-D array, shape {shape}>" + + +def _iter_numeric(data: Any) -> Iterable[float]: + """Yield all numeric leaf values from a nested list, skipping None.""" + if isinstance(data, list): + for item in data: + yield from _iter_numeric(item) + elif _is_numeric(data): + yield float(data) + + +def _compute_stats(data: Any) -> Optional[Dict[str, float]]: + """Return basic statistics for numeric data, or None if not applicable.""" + values = list(_iter_numeric(data)) + if len(values) < 2: + return None + n = len(values) + vmin = min(values) + vmax = max(values) + mean = sum(values) / n + std = (sum((x - mean) ** 2 for x in values) / n) ** 0.5 + sorted_v = sorted(values) + mid = n // 2 + median = sorted_v[mid] if n % 2 else (sorted_v[mid - 1] + sorted_v[mid]) / 2 + return { + "n": n, + "min": vmin, + "max": vmax, + "mean": mean, + "std": std, + "median": median, + } + + +def _stats_table(stats: Dict[str, float]) -> Table: + table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2)) + for key in ("n", "min", "max", "mean", "std", "median"): + table.add_column(key, justify="right") + table.add_row( + str(int(stats["n"])), + _fmt_val(stats["min"]), + _fmt_val(stats["max"]), + _fmt_val(stats["mean"]), + _fmt_val(stats["std"]), + _fmt_val(stats["median"]), + ) + return table + + +def _plot_stats_table(stats: Dict[str, float], shape: Tuple[int, ...]) -> Table: + table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2)) + for key in ("n", "min", "max", "mean", "std", "median"): + table.add_column(key, justify="right") + table.add_row( + str(int(stats["n"])), + _fmt_val(stats["min"]), + _fmt_val(stats["max"]), + _fmt_val(stats["mean"]), + _fmt_val(stats["std"]), + _fmt_val(stats["median"]), + ) + return table + + +def _plot_panel( + *, + plot: Text, + title: str, + units: str, + stats: Optional[Dict[str, float]], + shape: Tuple[int, ...], +) -> None: + content = plot + if stats: + content = Group(plot, _plot_stats_table(stats, shape)) + + _RICH_CONSOLE.print( + Panel( + content, + title=f"[bold]{title}[/bold] [dim]\\[{units}][/dim]", + subtitle=f"shape {shape}", + ) + ) + + +def show_quantity_textual_plot( + q: dict, + label: str = "", + x_quantity: Optional[dict] = None, +) -> None: + """Print line plot for a 1-D numeric QuantityData dict.""" + name = q["name"] + units = q["units"] or "-" + data = q["data"] + if not is_numeric_1d(data): + print_quantity(q, label=label) + return + + y_values = [float(value) for value in data] + shape = _get_shape(data) + x_values = None + xlabel = "index [-]" + if ( + x_quantity + and is_numeric_1d(x_quantity.get("data")) + and len(x_quantity["data"]) == len(y_values) + ): + x_values = [float(value) for value in x_quantity["data"]] + xlabel = _quantity_axis_label(x_quantity, fallback="x") + + title = label or name + if x_values is None: + x_values = [float(index) for index in range(len(y_values))] + + console_width = _RICH_CONSOLE.size.width + plot_width = max(48, min(70, console_width - 12)) + + plotext.clear_figure() + plotext.theme("clear") + plotext.plotsize(plot_width, 18) + plotext.xlabel(xlabel) + plotext.ylabel(_quantity_axis_label(q, fallback=label or "field")) + plotext.plot(x_values, y_values, marker="braille", color="cyan") + plot = Text.from_ansi(plotext.build()) + stats = _compute_stats(y_values) + _plot_panel( + plot=plot, + title=title, + units=units, + stats=stats, + shape=shape, + ) + print_quantity(q, label=label) + + +def print_quantity(q: dict, label: str = "", show_stats: bool = True) -> None: + """Print a QuantityData dict with array display and stats.""" + name = q["name"] + units = q["units"] or "-" + data = q["data"] + title = f"[bold]{label or name}[/bold] [dim]\\[{units}][/dim]" + + if not isinstance(data, list): + _RICH_CONSOLE.print(Panel(f"{_fmt_val(data)}", title=title, subtitle="scalar")) + return + + shape = _get_shape(data) + stats = _compute_stats(data) + array_body = _build_array_body(data, shape) + subtitle = f"shape ({shape[0]},)" if len(shape) == 1 else f"shape {shape}" + if show_stats and stats: + _RICH_CONSOLE.print( + Panel( + Group(array_body, _stats_table(stats)), + title=title, + subtitle=subtitle, + ) + ) + else: + _RICH_CONSOLE.print(Panel(array_body, title=title, subtitle=subtitle)) + def _flatten_dict(values: Dict) -> List[Tuple[str, str]]: items = [] diff --git a/src/simdb/cli/remote_api.py b/src/simdb/cli/remote_api.py index a81d082c..28520b2f 100644 --- a/src/simdb/cli/remote_api.py +++ b/src/simdb/cli/remote_api.py @@ -663,6 +663,11 @@ def delete_metadata(self, sim_id: str, key: str) -> List[str]: res = self.delete("simulation/metadata/" + sim_id, {"key": key}) return [data["value"] for data in res.json()] + @try_request + def get_simulation_data(self, sim_id: str, path: str) -> Dict[str, Any]: + res = self.get(f"simulation/{sim_id}/data", params={"path": path}) + return res.json() + @try_request def get_directory(self) -> str: res = self.get("staging_dir") diff --git a/src/simdb/json.py b/src/simdb/json.py index 89301c88..fd35b655 100644 --- a/src/simdb/json.py +++ b/src/simdb/json.py @@ -49,4 +49,9 @@ def default(self, o: Any) -> Any: return {"_type": "uuid.UUID", "hex": o.hex} elif isinstance(o, enum.Enum): return o.value + elif isinstance(o, np.ndarray): + if np.issubdtype(o.dtype, np.number): + valid = o[~np.isnan(o)] if np.issubdtype(o.dtype, np.floating) else o + return {"min": float(valid.min()), "max": float(valid.max())} + return o.tolist() return super().default(o) diff --git a/src/simdb/remote/apis/v1_2/__init__.py b/src/simdb/remote/apis/v1_2/__init__.py index 920f303b..6b8bc01d 100644 --- a/src/simdb/remote/apis/v1_2/__init__.py +++ b/src/simdb/remote/apis/v1_2/__init__.py @@ -10,6 +10,7 @@ from simdb.remote.core.typing import current_app from simdb.remote.models import StagingDirectoryResponse +from .simulation_data import api as data_ns from .simulations import api as sim_ns api = Api( @@ -31,7 +32,7 @@ ) api.add_namespace(sim_ns) -namespaces = [metadata_ns, watcher_ns, file_ns, sim_ns] +namespaces = [metadata_ns, watcher_ns, file_ns, sim_ns, data_ns] @api.route("/staging_dir", defaults={"sim_hex": None}) diff --git a/src/simdb/remote/apis/v1_2/simulation_data.py b/src/simdb/remote/apis/v1_2/simulation_data.py new file mode 100644 index 00000000..1552a768 --- /dev/null +++ b/src/simdb/remote/apis/v1_2/simulation_data.py @@ -0,0 +1,222 @@ +"""IMAS simulation data endpoint: /data. + +TODO: Temporary solution to retrieve data (for IBEX backend) +""" + +from typing import Annotated, Any, NamedTuple, Optional + +import numpy as np +from flask_restx import Namespace, Resource +from imas import IDSFactory +from imas.ids_convert import dd_version_map_from_factories +from imas.ids_defs import EMPTY_FLOAT +from imas.ids_primitive import IDSPrimitive + +from simdb.cli.manifest import DataObject +from simdb.database import DatabaseError +from simdb.imas.utils import ( + ImasError, + open_imas, +) +from simdb.remote.core.auth import User, requires_auth +from simdb.remote.core.pydantic_utils import ( + Query, + ResponseException, + ServerException, + pydantic_validate, +) +from simdb.remote.core.typing import current_app +from simdb.remote.models import ImasDataQueryParams, ImasDataResponse, QuantityData +from simdb.uri import URI + +api = Namespace("data", path="/") + + +# Helpers + + +def _to_python(value: Any) -> Any: + """Convert a value returned by IDSPrimitive.value to a JSON-serialisable + Python object.""" + if isinstance(value, np.ndarray): + flat = value.tolist() + + def _clean(v): + if isinstance(v, float) and ( + v != v or v == float("inf") or v == float("-inf") or v == EMPTY_FLOAT + ): + return None + if isinstance(v, list): + return [_clean(x) for x in v] + return v + + return _clean(flat) + return value + + +def _parse_ids_path(path: str) -> tuple: + """Parse ``ids_name[:occurrence][/ids_path]`` into a 3-tuple""" + head, _, ids_path = path.partition("/") + if ":" in head: + ids_name, occ_str = head.split(":", 1) + try: + occurrence = int(occ_str) + except ValueError as exc: + raise ValueError( + f"Invalid occurrence in path '{path}': '{occ_str}'" + ) from exc + else: + ids_name, occurrence = head, 0 + return ids_name, occurrence, ids_path + + +def _get_coordinates(node: IDSPrimitive, ids_name: str) -> list: + """Return a :class:`QuantityData` for each coordinate dimension of *node*.""" + coords = [] + for i in range(node.metadata.ndim): + coord = node.coordinates[i] + if isinstance(coord, IDSPrimitive): + data = ( + _to_python(coord.value) + if coord.has_value + else list(range(node.shape[i])) + ) + coords.append( + QuantityData( + name=f"{ids_name}/{coord._path}", + units=coord.metadata.units or "", + data=data, + ) + ) + else: + # Index-based coordinate: coord is already a numpy arange + coords.append( + QuantityData( + name=f"dim_{i + 1}", + units="", + data=coord.tolist(), + ) + ) + return coords + + +def _resolve_renamed_ids_path( + ids_obj: Any, ids_name: str, ids_path: str +) -> Optional[str]: + """Return the stored DD path for a requested current-DD path, if renamed.""" + if not ids_path: + return None + + stored_version = getattr(ids_obj, "_version", None) or getattr( + ids_obj, "_dd_version", None + ) + if not stored_version: + return None + + ddmap, _source_is_older = dd_version_map_from_factories( + ids_name, + IDSFactory(stored_version), + IDSFactory(), + ) + return ddmap.new_to_old.path.get(ids_path) + + +def _get_ids_node(entry, ids_name: str, occurrence: int, ids_path: str) -> IDSPrimitive: + """Return the :class:`IDSPrimitive` leaf node at *ids_path* inside *ids_name*.""" + ids_obj = entry.get( + ids_name, + occurrence, + lazy=True, + autoconvert=False, + ignore_unknown_dd_version=True, + ) + try: + node = ids_obj[ids_path] if ids_path else ids_obj + except (AttributeError, IndexError, KeyError) as exc: + renamed_path = _resolve_renamed_ids_path(ids_obj, ids_name, ids_path) + if not renamed_path: + raise exc + try: + node = ids_obj[renamed_path] + except (AttributeError, IndexError, KeyError): + raise exc from None + + if not isinstance(node, IDSPrimitive): + raise ValueError( + f"path does not point to a scalar/array leaf " + f"(reached {type(node).__name__}); add more path segments" + ) + if not node.has_value: + raise ValueError("field is not populated (no data written)") + return node + + +class _SimulationImasFile(NamedTuple): + simulation: Any + imas_file: Any + + +def _get_simulation_and_imas_file(sim_id: str) -> _SimulationImasFile: + try: + simulation = current_app.db.get_simulation(sim_id) + except DatabaseError as exc: + raise ResponseException(str(exc), 404) from exc + + imas_outputs = [f for f in simulation.outputs if f.type == DataObject.Type.IMAS] + if not imas_outputs: + raise ResponseException(f"Simulation {sim_id} has no IMAS output files", 404) + + return _SimulationImasFile(simulation, imas_outputs[0]) + + +# Endpoints + + +@api.route("/simulation//data") +class SimulationImasData(Resource): + @requires_auth() + @pydantic_validate(api) + def get( + self, + sim_id: str, + user: User, + params: Annotated[ImasDataQueryParams, Query()], + ) -> ImasDataResponse: + """Return the value at a given IDS path for a simulation's IMAS output.""" + result = _get_simulation_and_imas_file(sim_id) + + try: + ids_name, occurrence, ids_path = _parse_ids_path(params.path) + except ValueError as exc: + raise ResponseException(str(exc)) from exc + + try: + imas_uri = URI(str(result.imas_file.uri)) + if imas_uri.authority.host and "cache_mode" not in imas_uri.query: + imas_uri.query.set("cache_mode", "none") + entry = open_imas(imas_uri) + with entry: + node = _get_ids_node(entry, ids_name, occurrence, ids_path) + coordinates = _get_coordinates(node, ids_name) + field = QuantityData( + name=f"{ids_name}/{node._path}", + units=node.metadata.units or "", + data=_to_python(node.value), + ) + except (ValueError, AttributeError, IndexError, KeyError) as exc: + raise ResponseException(f"Invalid IDS path '{params.path}': {exc}") from exc + except ImasError as exc: + raise ServerException(f"Failed to open IMAS data: {exc}") from exc + except Exception as exc: + msg = str(exc) + if "is empty" in msg or "not found" in msg.lower(): + raise ResponseException(msg, 404) from exc + raise ServerException(msg) from exc + + return ImasDataResponse( + simulation=str(result.simulation.uuid), + path=params.path, + occurrence=occurrence, + field=field, + coordinates=coordinates, + ) diff --git a/src/simdb/remote/models.py b/src/simdb/remote/models.py index d8d2d2d0..60e063b5 100644 --- a/src/simdb/remote/models.py +++ b/src/simdb/remote/models.py @@ -26,7 +26,9 @@ BeforeValidator, ConfigDict, Field, + InstanceOf, PlainSerializer, + field_validator, model_validator, ) from pydantic import ( @@ -108,21 +110,6 @@ class RangeValue(BaseModel): max: float -MetadataValue = Union[ - CustomUUID, - str, - int, - float, - bool, - list, - RangeValue, - dict[str, Any], - None, -] -"""Supported types for simulation metadata values. Numpy arrays and regular arrays -containing numeric data are automatically converted to RangeValue.""" - - class StatusPatchData(BaseModel): """Post data for updating simulation status.""" @@ -181,6 +168,53 @@ def __getitem__(self, item) -> FileData: return self.root[item] +def _deserialize_numpy(v: Any) -> Any: + if isinstance(v, np.ndarray): + return v + if isinstance(v, dict) and v.get("_type") == "numpy.ndarray": + np_bytes = base64.b64decode(v["bytes"].encode()) + arr = np.frombuffer(np_bytes, dtype=v["dtype"]) + if "shape" in v: + arr = arr.reshape(v["shape"]) + return arr + raise ValueError(f"Cannot deserialize {v} to np.ndarray") + + +def _serialize_numpy(o: np.ndarray) -> dict: + """Serialize numpy arrays to dict format for the web dashboard.""" + encoded_bytes = base64.b64encode(o.data).decode() + return { + "_type": "numpy.ndarray", + "dtype": o.dtype.name, + "shape": o.shape, + "bytes": encoded_bytes, + } + + +NumpyArray = Annotated[ + InstanceOf[np.ndarray], + BeforeValidator(_deserialize_numpy), + PlainSerializer(_serialize_numpy, return_type=dict), +] + + +MetadataValue = Union[ + CustomUUID, + str, + int, + float, + bool, + RangeValue, + list, + dict, + NumpyArray, + None, +] +"""Supported types for simulation metadata values. RangeValue, numpy arrays and +scalars are automatically converted to their plain Python equivalents before +validation.""" + + class MetadataData(BaseModel): """Key-value pair for simulation metadata.""" @@ -572,6 +606,48 @@ class StagingDirectoryResponse(BaseModel): """Path to the staging dir.""" +class ImasDataQueryParams(BaseModel): + """Query parameters for the IMAS field-data endpoint.""" + + path: str + """Full IDS path including IDS name and optional occurrence.""" + + @field_validator("path", mode="before") + @classmethod + def _strip_path(cls, v: Any) -> str: + v = str(v).strip() + if not v: + raise ValueError("must not be empty") + return v + + +class QuantityData(BaseModel): + """A named, unit-bearing data quantity (field value or coordinate).""" + + name: str + """IDS path of this quantity relative to the IDS root""" + units: str + """Physical units of the quantity""" + data: Any + """Data value: a Python scalar for 0-D quantities, or a nested list for + arrays. """ + + +class ImasDataResponse(BaseModel): + """Response from the IMAS field-data endpoint.""" + + simulation: str + """UUID of the simulation.""" + path: str + """Requested IDS path.""" + occurrence: int + """IDS occurrence index.""" + field: QuantityData + """The requested quantity""" + coordinates: List[QuantityData] + """Coordinates for each dimension of *field*, in dimension order.""" + + class ErrorResponse(BaseModel): """Response model for server errors.""" diff --git a/src/simdb/validation/validator.py b/src/simdb/validation/validator.py index 2daa21ca..f9a3bb2b 100644 --- a/src/simdb/validation/validator.py +++ b/src/simdb/validation/validator.py @@ -1,6 +1,7 @@ import re +import warnings from pathlib import Path -from typing import Any, Dict, List, Optional, cast +from typing import Any, Dict, List, Optional, Union, cast import cerberus import numpy as np @@ -8,6 +9,7 @@ from simdb.config import Config, ConfigError from simdb.database.models.simulation import Simulation +from simdb.remote.models import RangeValue ValidatorBase = cast(Any, cerberus.Validator) @@ -65,7 +67,8 @@ def _compare(self, comparison, field, value, comparator: str, message: str): if comparison is None: return if isinstance(value, np.ndarray): - value = value[~np.isnan(value)] + if np.issubdtype(value.dtype, np.floating): + value = value[~np.isnan(value)] if value.size == 0: self._error(field, "Values in numpy array are NaN or empty") if not getattr(value, comparator)(comparison).all(): @@ -112,26 +115,32 @@ def _normalize_coerce_float(cls, value): def _normalize_coerce_numpy(cls, value): if isinstance(value, np.ndarray): return value + elif isinstance(value, dict) and "min" in value and "max" in value: + return np.array([value["min"], value["max"]], dtype=float) + elif isinstance(value, RangeValue): + return np.array([float(value.min), float(value.max)]) elif isinstance(value, str): return np.fromstring(value[1:-1], sep=" ") else: return np.array(value) -def _load_schema(path: Path): +def _load_schema(path: Union[Path, str]): + path = Path(path) if not path.exists(): - return [{}] + warnings.warn(f"Validation schema not found: {path}", stacklevel=2) + return {} - # load schema from file with path.open() as file: try: schema = yaml.load(file, Loader=yaml.SafeLoader) - return schema except yaml.YAMLError as err: raise LoadError( - f"Failed to read validation schema from file {file}" + f"Failed to read validation schema from file {path}" ) from err + return schema + class Validator: _validator: CustomValidator @@ -141,7 +150,7 @@ class Validator: def validation_schemas( cls, config: Config, simulation: Optional[Simulation], path=None ) -> List[Dict]: - root = Path( + configured_path = Path( str( config.get_option( "validation.path", default=str(config.config_directory) @@ -149,11 +158,19 @@ def validation_schemas( ) ) + if not configured_path.is_file(): + raise ConfigError( + f"validation.path '{configured_path}' is not a valid file. " + "Set validation.path to the full path of your validation " + "schema YAML file." + ) + default_schema_path = configured_path + paths = [] if path: paths.append(path) else: - paths.append(root / "validation-schema.yaml") + paths.append(default_schema_path) # Look for config sections like [validation "key=value"] and see if the # simulationhas metadata matching the given test. If matching, adding the diff --git a/tests/cli/test_cli_simulation_command.py b/tests/cli/test_cli_simulation_command.py index 0120fc02..5f61c600 100644 --- a/tests/cli/test_cli_simulation_command.py +++ b/tests/cli/test_cli_simulation_command.py @@ -85,3 +85,55 @@ def test_simulation_validate_command(remote_api, get_local_db): runner = CliRunner() result = runner.invoke(cli, [f"--config-file={config_file}", "simulation"]) assert result.exception is None + + +@mock.patch("simdb.cli.commands.simulation.show_quantity_textual_plot") +@mock.patch("simdb.cli.commands.simulation.RemoteAPI") +def test_simulation_data_command(mock_remote_api_cls, mock_textual_plot): + """``simdb simulation data`` prints field info.""" + mock_api = mock_remote_api_cls.return_value + mock_api.get_simulation_data.return_value = { + "simulation": "a304a6955b3f11f1809bd4f5ef75ec04", + "path": "core_profiles/profiles_1d[0]/electrons/temperature", + "occurrence": 0, + "field": { + "name": "core_profiles/profiles_1d[0]/electrons/temperature", + "units": "eV", + "data": [1000.0, 1200.0, 900.0], + }, + "coordinates": [ + { + "name": "core_profiles/profiles_1d[0]/grid/rho_tor_norm", + "units": "", + "data": [0.0, 0.5, 1.0], + } + ], + } + + config_file = config_test_file() + runner = CliRunner() + result = runner.invoke( + cli, + [ + f"--config-file={config_file}", + "simulation", + "data", + "test_sim", + "core_profiles/profiles_1d[0]/electrons/temperature", + ], + ) + + assert result.exception is None, result.output + mock_api.get_simulation_data.assert_called_once_with( + "test_sim", "core_profiles/profiles_1d[0]/electrons/temperature" + ) + result_data = mock_api.get_simulation_data.return_value + mock_textual_plot.assert_called_once_with( + result_data["field"], + label="field", + x_quantity=result_data["coordinates"][0], + ) + assert "simulation : a304a6955b3f11f1809bd4f5ef75ec04" in result.output + assert "shape (3,)" not in result.output + assert "1000" not in result.output + assert "1200" not in result.output diff --git a/tests/remote/api/test_metadata.py b/tests/remote/api/test_metadata.py index dbd99cee..f3e5e32d 100644 --- a/tests/remote/api/test_metadata.py +++ b/tests/remote/api/test_metadata.py @@ -51,6 +51,30 @@ def test_get_metadata_values(client): assert "machine-a" in rv.json or "machine-b" in rv.json +def test_get_metadata_list_value(client): + """Test that float lists are auto-converted to Range (new behavior).""" + list_data = [1.0, 2.5, 3.7] + simulation_data_1 = generate_simulation_data(metadata={"ip": list_data}) + rv_post_1 = post_simulation(client, simulation_data_1) + assert rv_post_1.status_code == 200 + + rv = client.get("/v1.2/metadata", headers=HEADERS) + assert rv.status_code == 200 + mkeys = MetadataKeyInfoList.model_validate_json(rv.data) + mkey = next((k for k in mkeys.root if k.name == "ip"), None) + assert mkey is not None, "ip key not found in metadata keys" + assert mkey.type == "Range" + + rv = client.get("/v1.2/metadata/ip", headers=HEADERS) + assert rv.status_code == 200 + mdata = MetadataValueList.model_validate_json(rv.data) + assert len(mdata.root) == 1 + a = mdata.root[0] + assert isinstance(a, RangeValue) + assert a.min == 1.0 + assert a.max == 3.7 + + def test_get_metadata_range_value(client): """Test metadata Range storage""" # Create a simulation with a range metadata value diff --git a/uv.lock b/uv.lock index 4a21152b..026fbc87 100644 --- a/uv.lock +++ b/uv.lock @@ -2661,6 +2661,7 @@ dependencies = [ { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "plotext" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pyjwt", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, @@ -2734,7 +2735,9 @@ build-docs = [ { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx-autodoc-typehints", version = "3.10.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "sphinx-rtd-theme" }, + { name = "sphinx-immaterial", version = "0.11.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "sphinx-immaterial", version = "0.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "sphinx-immaterial", version = "0.13.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] imas-validator = [ { name = "imas-validator" }, @@ -2796,6 +2799,7 @@ requires-dist = [ { name = "myst-parser", marker = "extra == 'build-docs'", specifier = ">=0.18.0" }, { name = "nbsphinx", marker = "extra == 'build-docs'", specifier = ">=0.8.0" }, { name = "numpy", specifier = ">=1.14" }, + { name = "plotext", specifier = "==5.3.2" }, { name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.8.0" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pyjwt", specifier = ">=1.4.0" }, @@ -2811,7 +2815,7 @@ requires-dist = [ { name = "simplejson", marker = "extra == 'server'", specifier = "~=3.0" }, { name = "sphinx", marker = "extra == 'build-docs'", specifier = ">=4.5" }, { name = "sphinx-autodoc-typehints", marker = "extra == 'build-docs'", specifier = ">=1.12.0" }, - { name = "sphinx-rtd-theme", marker = "extra == 'build-docs'", specifier = ">=1.0.0" }, + { name = "sphinx-immaterial", marker = "extra == 'build-docs'", specifier = ">=0.11.14" }, { name = "sqlalchemy", specifier = ">=1.2.12,<2.0" }, { name = "urllib3", specifier = ">=1.26" }, { name = "werkzeug", marker = "extra == 'server'", specifier = "==2.0.3" }, @@ -4210,6 +4214,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -4765,6 +4778,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pydantic-extra-types" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9' and platform_python_implementation != 'PyPy'", + "python_full_version < '3.9' and platform_python_implementation == 'PyPy'", +] +dependencies = [ + { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/10/fb64987804cde41bcc39d9cd757cd5f2bb5d97b389d81aa70238b14b8a7e/pydantic_extra_types-2.10.6.tar.gz", hash = "sha256:c63d70bf684366e6bbe1f4ee3957952ebe6973d41e7802aea0b770d06b116aeb", size = 141858, upload-time = "2025-10-08T13:47:49.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/04/5c918669096da8d1c9ec7bb716bd72e755526103a61bc5e76a3e4fb23b53/pydantic_extra_types-2.10.6-py3-none-any.whl", hash = "sha256:6106c448316d30abf721b5b9fecc65e983ef2614399a24142d689c7546cc246a", size = 40949, upload-time = "2025-10-08T13:47:48.268Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version >= '3.13' and platform_machine != 'ARM64') or (python_full_version >= '3.13' and sys_platform != 'win32')", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.12.*' and platform_machine != 'ARM64') or (python_full_version == '3.12.*' and sys_platform != 'win32')", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.11.*' and platform_machine != 'ARM64') or (python_full_version == '3.11.*' and sys_platform != 'win32')", + "python_full_version == '3.10.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version == '3.9'", +] +dependencies = [ + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -6719,23 +6774,76 @@ wheels = [ ] [[package]] -name = "sphinx-rtd-theme" -version = "3.1.0" +name = "sphinx-immaterial" +version = "0.11.14" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9' and platform_python_implementation != 'PyPy'", + "python_full_version < '3.9' and platform_python_implementation == 'PyPy'", +] dependencies = [ - { name = "docutils", version = "0.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "appdirs", marker = "python_full_version < '3.9'" }, + { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pydantic-extra-types", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "sphinx", version = "7.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/1f/5403cb6bd08f2f13c86d9b5367e56078dbe17d11c0bd91becdf34d454976/sphinx_immaterial-0.11.14.tar.gz", hash = "sha256:e1e8ba93c78a3e007743fede01a3be43f5ae97c5cc19b8e2a4d2aa058abead61", size = 8330984, upload-time = "2024-07-03T20:09:35.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/fa/db6916f719970ebdd433c5606bea98bbf899d71f255efced93992f944f1a/sphinx_immaterial-0.11.14-py3-none-any.whl", hash = "sha256:dd1a30614c8ecaa931155189e7d54f211232e31cf3e5c6d28ba9f04a4817f0a3", size = 10872122, upload-time = "2024-07-03T20:09:31.945Z" }, +] + +[[package]] +name = "sphinx-immaterial" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version == '3.9'", +] +dependencies = [ + { name = "appdirs", marker = "python_full_version == '3.9.*'" }, + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pydantic-extra-types", version = "2.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/e8/c0ac85c8864b4aada1aa71c0c7a326cce1d8581689c18cb05348ce30bf24/sphinx_immaterial-0.12.5.tar.gz", hash = "sha256:a7c0c4be3dcb4960eb7b299dfee07cdf8a02bf56821f5d0d62e5d31b7b7b5ec5", size = 8349000, upload-time = "2025-01-30T22:51:51.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/99/90471644a1dfa18fb801544c9eb3663893801cec049defe077e0e6026c1e/sphinx_immaterial-0.12.5-py3-none-any.whl", hash = "sha256:4173b22ad343fd9c75b51baf305851d89b98b94603c474b428e30e8c8476673b", size = 10885262, upload-time = "2025-01-30T22:51:47.207Z" }, +] + +[[package]] +name = "sphinx-immaterial" +version = "0.13.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version >= '3.13' and platform_machine != 'ARM64') or (python_full_version >= '3.13' and sys_platform != 'win32')", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.12.*' and platform_machine != 'ARM64') or (python_full_version == '3.12.*' and sys_platform != 'win32')", + "python_full_version == '3.11.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.11.*' and platform_machine != 'ARM64') or (python_full_version == '3.11.*' and sys_platform != 'win32')", + "python_full_version == '3.10.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", +] +dependencies = [ + { name = "appdirs", marker = "python_full_version >= '3.10'" }, + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic-extra-types", version = "2.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jquery" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, + { url = "https://files.pythonhosted.org/packages/3d/42/6e958fc5d80ccd18c87d1b7d7c0e17fed04c0ed8a72933dd41c8643622d4/sphinx_immaterial-0.13.9-py3-none-any.whl", hash = "sha256:5ea92d2ddc6befcd0fedbd3e6766ea4746e94d9a8a5cc0ab092a946e1fde4254", size = 13742592, upload-time = "2026-02-06T16:53:11.262Z" }, ] [[package]] @@ -6840,22 +6948,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, ] -[[package]] -name = "sphinxcontrib-jquery" -version = "4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx", version = "7.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, -] - [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" diff --git a/validation/validation-schema.yaml b/validation/validation-schema.yaml deleted file mode 120000 index 94eadc88..00000000 --- a/validation/validation-schema.yaml +++ /dev/null @@ -1 +0,0 @@ -iter_scenarios_validation.yaml \ No newline at end of file