← Files JinkōARCHIVED FILE

skills/jinko-data-table/scripts/create_data_table.py

12.2 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

#!/usr/bin/env python3
"""Create a Jinkō data table from CSV, SQLite, or pandas DataFrame.

Dry-run by default. Pass --apply to create the DataTable project item.
"""

from __future__ import annotations

import argparse
import csv
import math
import re
import sys
from pathlib import Path
from typing import Any

try:
    from dotenv import load_dotenv
except ImportError:  # pragma: no cover - depends on local environment
    load_dotenv = None


POINT_REQUIRED = {"obsId", "time", "value"}
RANGE_REQUIRED = {"obsId", "time", "narrowRangeLowBound", "narrowRangeHighBound"}
NUMERIC_COLUMNS = {
    "value",
    "narrowRangeLowBound",
    "narrowRangeHighBound",
    "wideRangeLowBound",
    "wideRangeHighBound",
    "weight",
}
ISO_DURATION = re.compile(
    r"^P(?=.+)(?:\d+(?:\.\d+)?[YMWD])*(?:T(?:\d+(?:\.\d+)?[HMS])*)?$"
)


def load_env() -> None:
    if load_dotenv is not None:
        load_dotenv()


def load_sdk():
    try:
        from jinko import JinkoClient
        from jinko.exceptions import JinkoError
    except ImportError:
        print(
            "Cannot import jinko. Install the SDK: pip install jinko-sdk",
            file=sys.stderr,
        )
        return None
    return JinkoClient, JinkoError


def summarize_csv(
    path: Path,
    *,
    allowed_obs_ids: set[str] | None = None,
    require_experiment_ref: bool = False,
    require_unit: bool = False,
) -> tuple[list[str], int, str, set[str]]:
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle)
        columns = reader.fieldnames or []
        rows = list(reader)
    if len(columns) != len(set(columns)):
        raise ValueError("CSV column names must be unique")
    column_set = set(columns)
    has_point_shape = POINT_REQUIRED.issubset(column_set)
    has_range_shape = RANGE_REQUIRED.issubset(column_set)
    if has_point_shape and has_range_shape:
        raise ValueError("CSV cannot mix point-value and range columns")
    if has_point_shape:
        row_type = "point-value"
        required = POINT_REQUIRED
    elif has_range_shape:
        row_type = "range"
        required = RANGE_REQUIRED
    else:
        raise ValueError(
            "CSV must include either obsId,time,value or "
            "obsId,time,narrowRangeLowBound,narrowRangeHighBound"
        )
    if not rows:
        raise ValueError("CSV must contain at least one data row")

    obs_ids = set()
    for line_number, row in enumerate(rows, start=2):
        missing = sorted(key for key in required if not str(row.get(key) or "").strip())
        if missing:
            raise ValueError(
                f"CSV line {line_number} is missing required values: "
                + ", ".join(missing)
            )
        obs_id = str(row["obsId"]).strip()
        obs_ids.add(obs_id)
        time = str(row["time"]).strip()
        if not ISO_DURATION.fullmatch(time) or time.endswith("T"):
            raise ValueError(
                f"CSV line {line_number} has invalid ISO-8601 time {time!r}"
            )
        for column in NUMERIC_COLUMNS.intersection(column_set):
            raw = str(row.get(column) or "").strip()
            if not raw:
                continue
            try:
                value = float(raw)
            except ValueError as exc:
                raise ValueError(
                    f"CSV line {line_number} has non-numeric {column}={raw!r}"
                ) from exc
            if not math.isfinite(value):
                raise ValueError(f"CSV line {line_number} requires finite {column}")
            if column == "weight" and value < 0:
                raise ValueError(f"CSV line {line_number} has negative weight")
        if row_type == "range":
            narrow_low = float(row["narrowRangeLowBound"])
            narrow_high = float(row["narrowRangeHighBound"])
            if narrow_low >= narrow_high:
                raise ValueError(
                    f"CSV line {line_number} requires narrowRangeLowBound "
                    "< narrowRangeHighBound"
                )
            wide_low = str(row.get("wideRangeLowBound") or "").strip()
            wide_high = str(row.get("wideRangeHighBound") or "").strip()
            if bool(wide_low) != bool(wide_high):
                raise ValueError(
                    f"CSV line {line_number} must provide both wide range bounds"
                )
            if wide_low and not (
                float(wide_low) < narrow_low < narrow_high < float(wide_high)
            ):
                raise ValueError(
                    f"CSV line {line_number} wide bounds must strictly contain "
                    "the narrow range"
                )
        else:
            point_value = float(row["value"])
            wide_low = str(row.get("wideRangeLowBound") or "").strip()
            wide_high = str(row.get("wideRangeHighBound") or "").strip()
            if bool(wide_low) != bool(wide_high):
                raise ValueError(
                    f"CSV line {line_number} must provide both wide range bounds"
                )
            if wide_low and not float(wide_low) < point_value < float(wide_high):
                raise ValueError(
                    f"CSV line {line_number} wide bounds must strictly contain value"
                )
        if require_experiment_ref and not str(row.get("experimentRef") or "").strip():
            raise ValueError(f"CSV line {line_number} requires experimentRef")
        if require_unit and not str(row.get("unit") or "").strip():
            raise ValueError(f"CSV line {line_number} requires unit")

    if allowed_obs_ids is not None:
        unexpected = sorted(obs_ids - allowed_obs_ids)
        if unexpected:
            raise ValueError(
                "CSV contains unexpected obsId values: " + ", ".join(unexpected)
            )
    return columns, len(rows), row_type, obs_ids


def valid_for_fitness_from_content(content: Any) -> bool | None:
    if isinstance(content, dict):
        public = (content.get("metadata") or {}).get("public") or {}
        return public.get("validForFitnessFunction")

    metadata = getattr(content, "metadata", None)
    public = getattr(metadata, "public", None)
    return getattr(public, "validForFitnessFunction", None)


def print_fitness_status(table: Any) -> bool | None:
    try:
        content = table.content()
        valid = valid_for_fitness_from_content(content)
    except Exception as exc:  # noqa: BLE001 - diagnostic helper should stay concise
        print(f"Could not read data-table metadata: {exc}", file=sys.stderr)
        return None

    if valid is None:
        print("validForFitnessFunction: <not reported>")
    else:
        print(f"validForFitnessFunction: {valid}")
    return valid


def resolve_folder(client: Any, folder_ref: str | None, *, create: bool) -> Any | None:
    if folder_ref is None:
        return None

    folder = client.get_folder(folder_ref)
    if folder is not None:
        return folder

    folder = client.get_folder_by_name(folder_ref, exact_match_only=True)
    if folder is not None:
        return folder

    if not create:
        raise ValueError(
            f"Folder {folder_ref!r} was not found. Pass --create-folder to create it."
        )

    return client.create_folder(folder_ref)


def main() -> int:
    parser = argparse.ArgumentParser(description="Create a Jinkō data table.")
    parser.add_argument("--source", required=True, help="CSV or SQLite source path.")
    parser.add_argument(
        "--method", choices=["csv", "sqlite", "dataframe"], default="csv"
    )
    parser.add_argument("--name", default="sdk-data-table")
    parser.add_argument(
        "--description", default="Data table created with the Jinkō SDK."
    )
    parser.add_argument(
        "--apply", action="store_true", help="Actually create the DataTable."
    )
    parser.add_argument(
        "--folder",
        help="Existing folder id or exact folder name for the new data table.",
    )
    parser.add_argument(
        "--create-folder",
        action="store_true",
        help="Create --folder when missing. Treats --folder as a folder name.",
    )
    parser.add_argument(
        "--allowed-obs-id",
        action="append",
        help="Allowed obsId value. May be repeated; rejects any other obsId.",
    )
    parser.add_argument(
        "--require-experiment-ref",
        action="store_true",
        help="Require a non-empty experimentRef on every CSV row.",
    )
    parser.add_argument(
        "--require-unit",
        action="store_true",
        help="Require a non-empty unit on every CSV row.",
    )
    parser.add_argument(
        "--require-fitness",
        action="store_true",
        help="After creation, fail unless validForFitnessFunction is explicitly true.",
    )
    args = parser.parse_args()

    if args.create_folder and not args.folder:
        print("--create-folder requires --folder", file=sys.stderr)
        return 1

    source = Path(args.source)
    if not source.exists():
        print(f"Source file does not exist: {source}", file=sys.stderr)
        return 1

    if args.method == "sqlite" and (
        args.allowed_obs_id or args.require_experiment_ref or args.require_unit
    ):
        print(
            "--allowed-obs-id, --require-experiment-ref, and --require-unit "
            "require CSV or dataframe input",
            file=sys.stderr,
        )
        return 1

    if args.method in {"csv", "dataframe"}:
        try:
            columns, row_count, row_type, obs_ids = summarize_csv(
                source,
                allowed_obs_ids=set(args.allowed_obs_id)
                if args.allowed_obs_id
                else None,
                require_experiment_ref=args.require_experiment_ref,
                require_unit=args.require_unit,
            )
        except ValueError as exc:
            print(str(exc), file=sys.stderr)
            return 1
        print(f"CSV rows: {row_count}")
        print(f"CSV row type: {row_type}")
        print(f"CSV columns: {', '.join(columns)}")
        print(f"CSV obsIds: {', '.join(sorted(obs_ids))}")
    else:
        print(f"SQLite bytes: {source.stat().st_size}")

    if not args.apply:
        print(f"Would create DataTable named {args.name!r} using {args.method} method.")
        if args.folder:
            action = "Would create or reuse" if args.create_folder else "Would reuse"
            print(f"{action} folder: {args.folder}")
        print("Run again with --apply to create the data table.")
        return 0

    load_env()
    sdk = load_sdk()
    if sdk is None:
        return 1
    JinkoClient, JinkoError = sdk

    try:
        client = JinkoClient()
        folder = resolve_folder(client, args.folder, create=args.create_folder)
        if args.method == "csv":
            table = client.create_data_table_from_csv(
                csv_file_path=str(source),
                folder=folder,
                name=args.name,
                description=args.description,
            )
        elif args.method == "sqlite":
            table = client.create_data_table_from_sqlite(
                sqlite_file_path=str(source),
                folder=folder,
                name=args.name,
                description=args.description,
            )
        else:
            try:
                import pandas as pd
            except ImportError:
                print(
                    "DataFrame method requires pandas: pip install pandas",
                    file=sys.stderr,
                )
                return 1
            table = client.create_data_table_from_dataframe(
                pd.read_csv(source),
                folder=folder,
                name=args.name,
                description=args.description,
            )

        print(f"Created DataTable {table.sid}")
        if folder is not None:
            print(f"Folder: {folder.path}")
        if getattr(table, "url", None):
            print(table.url)
        fitness = print_fitness_status(table)
        if args.require_fitness and fitness is not True:
            print(
                "Created DataTable is not confirmed valid for a fitness function",
                file=sys.stderr,
            )
            return 3
        return 0
    except (ValueError, JinkoError) as exc:
        print(f"Jinkō SDK request failed: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 178dad6a136f02bd09b45fcdef3a1a10fbf55f62839a7a1e160196da91336eb3