sciduckdb 0.1.0__tar.gz → 0.1.9.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,3 +13,4 @@ __pycache__/
13
13
  *.duckdb
14
14
  *.duckdb.wal
15
15
  *.wal
16
+ scistack-gui/frontend/node_modules/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sciduckdb
3
- Version: 0.1.0
3
+ Version: 0.1.9.dev0
4
4
  Summary: A thin DuckDB layer for managing versioned scientific data
5
5
  Author: SciStack Contributors
6
6
  License-Expression: MIT
@@ -1,10 +1,14 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = ["hatchling", "hatch-vcs"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
+ [tool.hatch.version]
6
+ source = "vcs"
7
+ raw-options = { search_parent_directories = true, local_scheme = "no-local-version" }
8
+
5
9
  [project]
6
10
  name = "sciduckdb"
7
- version = "0.1.0"
11
+ dynamic = ["version"]
8
12
  description = "A thin DuckDB layer for managing versioned scientific data"
9
13
  readme = "README.md"
10
14
  license = "MIT"
@@ -2,27 +2,33 @@
2
2
 
3
3
  from .sciduckdb import (
4
4
  SciDuck,
5
+ _bulk_df_to_storage_rows,
6
+ _dataframe_to_storage_rows,
7
+ _flatten_dict,
8
+ _infer_data_columns,
5
9
  _infer_duckdb_type,
6
10
  _numpy_dtype_to_duckdb,
7
11
  _python_to_storage,
12
+ _record_schema_mismatch,
13
+ _storage_signature,
8
14
  _storage_to_python,
9
15
  _storage_to_python_column,
10
- _infer_data_columns,
11
- _value_to_storage_row,
12
- _dataframe_to_storage_rows,
13
- _bulk_df_to_storage_rows,
14
- _flatten_dict,
15
16
  _unflatten_dict,
17
+ _value_to_storage_row,
18
+ schema_keys_from_db,
16
19
  )
17
20
 
18
21
  __all__ = [
19
22
  "SciDuck",
23
+ "schema_keys_from_db",
20
24
  "_infer_duckdb_type",
21
25
  "_numpy_dtype_to_duckdb",
22
26
  "_python_to_storage",
23
27
  "_storage_to_python",
24
28
  "_storage_to_python_column",
25
29
  "_infer_data_columns",
30
+ "_record_schema_mismatch",
31
+ "_storage_signature",
26
32
  "_value_to_storage_row",
27
33
  "_dataframe_to_storage_rows",
28
34
  "_bulk_df_to_storage_rows",
@@ -11,15 +11,16 @@ nested LIST, JSON) so the database can be inspected with DBeaver or any
11
11
  DuckDB-compatible viewer.
12
12
  """
13
13
 
14
- import duckdb
15
- import logging
16
- import pandas as pd
17
- import numpy as np
18
- import json
19
14
  import datetime
15
+ import json
16
+ import logging
20
17
  import threading
21
18
  from pathlib import Path
22
- from typing import Any, Dict, List, Optional, Tuple, Union
19
+ from typing import Any
20
+
21
+ import duckdb
22
+ import numpy as np
23
+ import pandas as pd
23
24
 
24
25
  logger = logging.getLogger("sciduck")
25
26
 
@@ -40,6 +41,7 @@ def _schema_str(value):
40
41
  # Type mapping helpers
41
42
  # ---------------------------------------------------------------------------
42
43
 
44
+
43
45
  def _numpy_dtype_to_duckdb(dtype: np.dtype) -> str:
44
46
  """Map a numpy scalar dtype to a DuckDB type string."""
45
47
  kind = dtype.kind
@@ -63,7 +65,7 @@ def _numpy_dtype_to_duckdb(dtype: np.dtype) -> str:
63
65
  return "VARCHAR"
64
66
 
65
67
 
66
- def _infer_duckdb_type(value: Any) -> Tuple[str, dict]:
68
+ def _infer_duckdb_type(value: Any) -> tuple[str, dict]:
67
69
  """
68
70
  Infer the DuckDB column type and a metadata dict for round-trip
69
71
  restoration from a single Python/numpy value.
@@ -197,6 +199,17 @@ def _python_to_storage(value: Any, meta: dict) -> Any:
197
199
  """Convert a Python value to its DuckDB-storable form."""
198
200
  ptype = meta.get("python_type", "")
199
201
 
202
+ # _infer_data_columns unwraps length-1 arrays to scalars when it picks the
203
+ # column type, e.g. {"x": np.array([1.0])} -> DOUBLE column with
204
+ # python_type="float". Mirror that unwrap here so the stored value is a
205
+ # scalar matching the column; otherwise the row carries a DOUBLE[] into a
206
+ # DOUBLE column and DuckDB rejects the cast (DOUBLE[] -> DOUBLE).
207
+ if ptype in ("float", "int", "bool", "str"):
208
+ if isinstance(value, np.ndarray) and value.size == 1:
209
+ value = value.item()
210
+ elif isinstance(value, np.generic):
211
+ value = value.item()
212
+
200
213
  if ptype == "ndarray":
201
214
  arr = value
202
215
  # Scalar in a column typed as ndarray (e.g. ragged vectors): wrap as 1-element list
@@ -345,9 +358,10 @@ def _unflatten_dict(flat, path_map):
345
358
  # DatabaseManager)
346
359
  # ---------------------------------------------------------------------------
347
360
 
361
+
348
362
  def _infer_data_columns(
349
- sample_value: Any, data_col_name: Optional[str] = None
350
- ) -> Tuple[dict, dict]:
363
+ sample_value: Any, data_col_name: str | None = None
364
+ ) -> tuple[dict, dict]:
351
365
  """
352
366
  From a sample data value, return:
353
367
  - data_col_types: dict of {col_name: duckdb_type_str}
@@ -410,6 +424,93 @@ def _infer_data_columns(
410
424
  return {col_name: ddb_type}, meta
411
425
 
412
426
 
427
+ # DuckDB type categories used by _storage_signature. Records can only share a
428
+ # column if their values reduce to the same (category, array-depth) signature;
429
+ # this is intentionally coarse so benign coercions (BIGINT -> DOUBLE) are
430
+ # allowed while shape changes (scalar vs vector) and category changes
431
+ # (DOUBLE vs VARCHAR) are rejected.
432
+ _NUMERIC_DDB_TYPES = {
433
+ "BOOLEAN",
434
+ "TINYINT",
435
+ "SMALLINT",
436
+ "INTEGER",
437
+ "BIGINT",
438
+ "HUGEINT",
439
+ "UTINYINT",
440
+ "USMALLINT",
441
+ "UINTEGER",
442
+ "UBIGINT",
443
+ "FLOAT",
444
+ "REAL",
445
+ "DOUBLE",
446
+ }
447
+ _TEMPORAL_DDB_TYPES = {"DATE", "TIME", "TIMESTAMP", "INTERVAL"}
448
+
449
+
450
+ def _storage_signature(ddb_type: str) -> tuple[str, int]:
451
+ """Reduce a DuckDB column type string to (base_category, array_depth).
452
+
453
+ array_depth counts trailing ``[]`` (0 = scalar, 1 = vector, 2 = matrix).
454
+ base_category is a coarse bucket so that, e.g., ``BIGINT`` and ``DOUBLE``
455
+ are both ``numeric`` (DuckDB coerces them into one column) but ``DOUBLE``
456
+ and ``VARCHAR`` differ. This is the unit of comparison for deciding
457
+ whether two records can be stored in the same batch column.
458
+ """
459
+ t = ddb_type.strip().upper()
460
+ depth = 0
461
+ while t.endswith("[]"):
462
+ depth += 1
463
+ t = t[:-2].strip()
464
+ if t in _NUMERIC_DDB_TYPES or t.startswith("DECIMAL"):
465
+ category = "numeric"
466
+ elif t == "VARCHAR":
467
+ category = "string"
468
+ elif t == "JSON":
469
+ category = "json"
470
+ elif t in _TEMPORAL_DDB_TYPES:
471
+ category = "temporal"
472
+ else:
473
+ category = t
474
+ return (category, depth)
475
+
476
+
477
+ def _record_schema_mismatch(ref_col_types: dict, rec_col_types: dict) -> str | None:
478
+ """Return a human-readable reason a record can't join the batch, or None.
479
+
480
+ A record "fits" the batch schema when it has exactly the reference column
481
+ set and every column's storage signature matches. This is the predicate
482
+ used by save_batch to skip (with a warning) records that would otherwise
483
+ abort the atomic batch insert:
484
+
485
+ * empty/partial dicts -> missing keys
486
+ * unexpected dict keys -> extra keys
487
+ * a scalar where the column stores a vector (or vice versa),
488
+ or a string where the column is numeric -> signature mismatch
489
+ """
490
+ ref_keys = set(ref_col_types)
491
+ rec_keys = set(rec_col_types)
492
+ if ref_keys != rec_keys:
493
+ missing = sorted(ref_keys - rec_keys)
494
+ extra = sorted(rec_keys - ref_keys)
495
+ parts = []
496
+ if missing:
497
+ parts.append(f"missing keys {missing}")
498
+ if extra:
499
+ parts.append(f"unexpected keys {extra}")
500
+ if not parts:
501
+ parts.append("key set differs from batch schema")
502
+ return "; ".join(parts)
503
+ for col in ref_col_types:
504
+ ref_sig = _storage_signature(ref_col_types[col])
505
+ rec_sig = _storage_signature(rec_col_types[col])
506
+ if ref_sig != rec_sig:
507
+ return (
508
+ f"column '{col}' shape/type mismatch: batch column is "
509
+ f"{ref_col_types[col]} but record value is {rec_col_types[col]}"
510
+ )
511
+ return None
512
+
513
+
413
514
  def _dataframe_to_storage_rows(df: pd.DataFrame, dtype_meta: dict) -> list:
414
515
  """Convert a DataFrame to a list of per-row storage values.
415
516
 
@@ -433,9 +534,7 @@ def _dataframe_to_storage_rows(df: pd.DataFrame, dtype_meta: dict) -> list:
433
534
  return rows
434
535
 
435
536
 
436
- def _bulk_df_to_storage_rows(
437
- df_list: list, record_ids: list, dtype_meta: dict
438
- ) -> list:
537
+ def _bulk_df_to_storage_rows(df_list: list, record_ids: list, dtype_meta: dict) -> list:
439
538
  """Bulk convert N DataFrames to (record_id, ...storage_values) rows.
440
539
 
441
540
  Equivalent to calling _dataframe_to_storage_rows N times and assembling
@@ -453,7 +552,7 @@ def _bulk_df_to_storage_rows(
453
552
  # Fall back to per-row path if schemas differ (shouldn't happen in normal use).
454
553
  if not all(list(df.columns) == first_cols for df in df_list):
455
554
  rows: list = []
456
- for rid, df in zip(record_ids, df_list):
555
+ for rid, df in zip(record_ids, df_list, strict=False):
457
556
  for storage_row in _dataframe_to_storage_rows(df, dtype_meta):
458
557
  rows.append((rid,) + tuple(storage_row))
459
558
  return rows
@@ -461,7 +560,7 @@ def _bulk_df_to_storage_rows(
461
560
  # Build flat record_id list: one entry per storage row (multi-row records
462
561
  # contribute len(df) entries, typical 1-row records contribute 1).
463
562
  expanded_rids = []
464
- for rid, df in zip(record_ids, df_list):
563
+ for rid, df in zip(record_ids, df_list, strict=False):
465
564
  expanded_rids.extend([rid] * len(df))
466
565
 
467
566
  # Concat once so column operations don't cross DataFrame boundaries.
@@ -476,7 +575,8 @@ def _bulk_df_to_storage_rows(
476
575
  if ptype == "ndarray":
477
576
  vals = raw.to_numpy()
478
577
  col_arrays[col] = [
479
- v.tolist() if isinstance(v, np.ndarray)
578
+ v.tolist()
579
+ if isinstance(v, np.ndarray)
480
580
  else (v if isinstance(v, list) else [v])
481
581
  for v in vals
482
582
  ]
@@ -516,10 +616,7 @@ def _value_to_storage_row(value: Any, dtype_meta: dict) -> list:
516
616
  flat, _ = _flatten_dict(value)
517
617
  else:
518
618
  flat = value
519
- return [
520
- _python_to_storage(flat[col], col_metas[col])
521
- for col in col_metas
522
- ]
619
+ return [_python_to_storage(flat[col], col_metas[col]) for col in col_metas]
523
620
  else:
524
621
  # Single column — get the one key (could be "value" or a named column)
525
622
  col_name = next(iter(col_metas))
@@ -531,6 +628,43 @@ def _value_to_storage_row(value: Any, dtype_meta: dict) -> list:
531
628
  # Main class
532
629
  # ---------------------------------------------------------------------------
533
630
 
631
+
632
+ def schema_keys_from_db(db_path: str | Path) -> list[str]:
633
+ """Read the dataset schema keys stored in an existing database.
634
+
635
+ Opens ``db_path`` read-only, reads the ``_schema`` table's key columns
636
+ (everything except ``schema_id``/``schema_level``, in ordinal order), and
637
+ closes the connection. This lets tools open a database without knowing
638
+ its schema keys in advance (e.g. the ``scidb`` CLI).
639
+
640
+ Raises ValueError if the file has no ``_schema`` table (not a
641
+ scidb/sciduckdb database) and whatever duckdb raises if the file does not
642
+ exist or is locked.
643
+ """
644
+ con = duckdb.connect(str(db_path), read_only=True)
645
+ try:
646
+ rows = con.execute(
647
+ "SELECT column_name FROM information_schema.columns "
648
+ "WHERE table_name = '_schema' "
649
+ "AND column_name NOT IN ('schema_id', 'schema_level') "
650
+ "ORDER BY ordinal_position"
651
+ ).fetchall()
652
+ has_table = (
653
+ con.execute(
654
+ "SELECT COUNT(*) FROM information_schema.tables "
655
+ "WHERE table_name = '_schema'"
656
+ ).fetchall()[0][0]
657
+ > 0
658
+ )
659
+ finally:
660
+ con.close()
661
+ if not has_table:
662
+ raise ValueError(
663
+ f"{db_path} has no _schema table — not a scidb/sciduckdb database"
664
+ )
665
+ return [r[0] for r in rows]
666
+
667
+
534
668
  class SciDuck:
535
669
  """
536
670
  A thin DuckDB layer for managing versioned, schema-aware scientific data.
@@ -541,15 +675,28 @@ class SciDuck:
541
675
  Path to the DuckDB database file. Use ":memory:" for in-memory.
542
676
  dataset_schema : list of str
543
677
  Ordered hierarchy, e.g. ["subject", "session", "trial"].
678
+ read_only : bool
679
+ Open the underlying DuckDB connection read-only. No DDL is executed
680
+ (the database must already exist); DuckDB rejects every write on the
681
+ connection, so a read-only SciDuck can never contend for the write
682
+ lock beyond DuckDB's shared read lock.
544
683
  """
545
684
 
546
- def __init__(self, db_path: Union[str, Path], dataset_schema: List[str]):
685
+ def __init__(
686
+ self, db_path: str | Path, dataset_schema: list[str], read_only: bool = False
687
+ ):
547
688
  self.db_path = str(db_path)
548
689
  self.dataset_schema = list(dataset_schema)
690
+ self.read_only = bool(read_only)
549
691
  self._lock = threading.Lock()
550
- logger.info("DuckDB lock ACQUIRED: %s", self.db_path)
551
- self.con = duckdb.connect(self.db_path)
552
- self._init_metadata_tables()
692
+ logger.debug(
693
+ "DuckDB lock ACQUIRED (read_only=%s): %s", self.read_only, self.db_path
694
+ )
695
+ self.con = duckdb.connect(self.db_path, read_only=self.read_only)
696
+ if self.read_only:
697
+ self._validate_schema_columns()
698
+ else:
699
+ self._init_metadata_tables()
553
700
 
554
701
  # ------------------------------------------------------------------
555
702
  # Thin internal interface (future backend swap point)
@@ -570,6 +717,37 @@ class SciDuck:
570
717
  with self._lock:
571
718
  return self.con.executemany(sql, params_list)
572
719
 
720
+ def _bulk_insert(self, table: str, columns, rows, conflict_cols=None) -> None:
721
+ """Insert many rows with a single vectorized ``INSERT ... SELECT``.
722
+
723
+ DuckDB's ``executemany`` re-runs the prepared single-row statement once
724
+ per row; against a PRIMARY-KEY / indexed table that is pathologically
725
+ slow (per-row index probe + maintenance), e.g. ~497s for 8k rows into
726
+ ``_record``. This registers the rows as one DataFrame and issues a single
727
+ bulk insert instead (sub-second for the same data).
728
+
729
+ ``rows`` is an iterable of value tuples in ``columns`` order. When
730
+ ``conflict_cols`` is given, appends ``ON CONFLICT (...) DO NOTHING`` so
731
+ the insert stays idempotent. Safe to call inside an open transaction
732
+ (the register/insert/unregister run on the same connection).
733
+ """
734
+ rows = list(rows)
735
+ if not rows:
736
+ return
737
+ columns = list(columns)
738
+ df = pd.DataFrame(rows, columns=columns)
739
+ col_str = ", ".join(f'"{c}"' for c in columns)
740
+ sql = f'INSERT INTO "{table}" ({col_str}) SELECT * FROM _bulk_insert_df'
741
+ if conflict_cols:
742
+ conflict_str = ", ".join(f'"{c}"' for c in conflict_cols)
743
+ sql += f" ON CONFLICT ({conflict_str}) DO NOTHING"
744
+ with self._lock:
745
+ self.con.register("_bulk_insert_df", df)
746
+ try:
747
+ self.con.execute(sql)
748
+ finally:
749
+ self.con.unregister("_bulk_insert_df")
750
+
573
751
  def _begin(self):
574
752
  with self._lock:
575
753
  self.con.execute("BEGIN TRANSACTION")
@@ -600,8 +778,8 @@ class SciDuck:
600
778
 
601
779
  def _table_exists(self, name: str) -> bool:
602
780
  rows = self._fetchall(
603
- "SELECT COUNT(*) FROM information_schema.tables "
604
- "WHERE table_name = ?", [name]
781
+ "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ?",
782
+ [name],
605
783
  )
606
784
  return rows[0][0] > 0
607
785
 
@@ -647,25 +825,42 @@ class SciDuck:
647
825
 
648
826
  # Validate schema consistency if _schema already has data
649
827
  if self._fetchall("SELECT COUNT(*) FROM _schema")[0][0] > 0:
650
- existing_cols = [
651
- row[0] for row in self._fetchall(
652
- "SELECT column_name FROM information_schema.columns "
653
- "WHERE table_name = '_schema' "
654
- "AND column_name NOT IN ('schema_id', 'schema_level') "
655
- "ORDER BY ordinal_position"
656
- )
657
- ]
658
- if existing_cols != self.dataset_schema:
828
+ self._validate_schema_columns()
829
+
830
+ def _validate_schema_columns(self):
831
+ """Check that the existing _schema columns match dataset_schema.
832
+
833
+ Read-only companion to _init_metadata_tables: runs the same
834
+ consistency check without any DDL, so it is safe on a read-only
835
+ connection (where the tables must already exist).
836
+ """
837
+ if not self._table_exists("_schema"):
838
+ if self.read_only:
659
839
  raise ValueError(
660
- f"Database schema mismatch. "
661
- f"Existing: {existing_cols}, Provided: {self.dataset_schema}"
840
+ f"{self.db_path} has no _schema table — not a scidb/sciduckdb "
841
+ f"database (read-only open cannot create it)"
662
842
  )
843
+ return
844
+ existing_cols = [
845
+ row[0]
846
+ for row in self._fetchall(
847
+ "SELECT column_name FROM information_schema.columns "
848
+ "WHERE table_name = '_schema' "
849
+ "AND column_name NOT IN ('schema_id', 'schema_level') "
850
+ "ORDER BY ordinal_position"
851
+ )
852
+ ]
853
+ if existing_cols != self.dataset_schema:
854
+ raise ValueError(
855
+ f"Database schema mismatch. "
856
+ f"Existing: {existing_cols}, Provided: {self.dataset_schema}"
857
+ )
663
858
 
664
859
  # ------------------------------------------------------------------
665
860
  # Schema entry management
666
861
  # ------------------------------------------------------------------
667
862
 
668
- def _schema_key_columns(self, schema_level: str) -> List[str]:
863
+ def _schema_key_columns(self, schema_level: str) -> list[str]:
669
864
  """Return schema columns from the top down to (and including) schema_level."""
670
865
  idx = self.dataset_schema.index(schema_level)
671
866
  return self.dataset_schema[: idx + 1]
@@ -688,16 +883,16 @@ class SciDuck:
688
883
 
689
884
  where = " AND ".join(conditions)
690
885
  rows = self._fetchall(
691
- f'SELECT schema_id FROM _schema WHERE schema_level = ? AND {where}',
886
+ f"SELECT schema_id FROM _schema WHERE schema_level = ? AND {where}",
692
887
  params,
693
888
  )
694
889
  if rows:
695
890
  return rows[0][0]
696
891
 
697
892
  # Insert new entry — use MAX+1 for consistency with batch path
698
- new_id = self._fetchall(
699
- "SELECT COALESCE(MAX(schema_id), 0) + 1 FROM _schema"
700
- )[0][0]
893
+ new_id = self._fetchall("SELECT COALESCE(MAX(schema_id), 0) + 1 FROM _schema")[
894
+ 0
895
+ ][0]
701
896
  col_names = ["schema_id", "schema_level"] + key_cols
702
897
  placeholders = ", ".join(["?"] * len(col_names))
703
898
  col_str = ", ".join(f'"{c}"' for c in col_names)
@@ -744,16 +939,14 @@ class SciDuck:
744
939
 
745
940
  # Build a single query to find all existing matches at this level
746
941
  # We fetch all rows for this schema_level and match in Python
747
- null_conditions = " AND ".join(
748
- f'"{col}" IS NULL' for col in null_cols
749
- )
750
- where_clause = f'schema_level = ?'
942
+ null_conditions = " AND ".join(f'"{col}" IS NULL' for col in null_cols)
943
+ where_clause = "schema_level = ?"
751
944
  if null_conditions:
752
- where_clause += f' AND {null_conditions}'
945
+ where_clause += f" AND {null_conditions}"
753
946
 
754
947
  col_select = ", ".join(f'"{c}"' for c in key_cols)
755
948
  rows = self._fetchall(
756
- f'SELECT schema_id, {col_select} FROM _schema WHERE {where_clause}',
949
+ f"SELECT schema_id, {col_select} FROM _schema WHERE {where_clause}",
757
950
  [schema_level],
758
951
  )
759
952
 
@@ -761,7 +954,9 @@ class SciDuck:
761
954
  existing_lookup = {}
762
955
  for row in rows:
763
956
  sid = row[0]
764
- row_key = tuple(_schema_str(v) if v is not None else "" for v in row[1:])
957
+ row_key = tuple(
958
+ _schema_str(v) if v is not None else "" for v in row[1:]
959
+ )
765
960
  existing_lookup[row_key] = sid
766
961
 
767
962
  # Match entries against existing rows
@@ -782,7 +977,6 @@ class SciDuck:
782
977
  first_id = max_row[0][0] + 1
783
978
 
784
979
  col_names = ["schema_id", "schema_level"] + key_cols
785
- col_str = ", ".join(f'"{c}"' for c in col_names)
786
980
 
787
981
  insert_rows = []
788
982
  for idx, (combo_key, key_values, _) in enumerate(missing):
@@ -793,11 +987,7 @@ class SciDuck:
793
987
  insert_rows.append(row)
794
988
  result[combo_key] = new_id
795
989
 
796
- # Use DataFrame-based insert for speed
797
- insert_df = pd.DataFrame(insert_rows, columns=col_names)
798
- self.con.execute(
799
- f"INSERT INTO _schema ({col_str}) SELECT * FROM insert_df"
800
- )
990
+ self._bulk_insert("_schema", col_names, insert_rows)
801
991
 
802
992
  return result
803
993
 
@@ -809,7 +999,7 @@ class SciDuck:
809
999
  self,
810
1000
  name: str,
811
1001
  data: Any,
812
- schema_level: Optional[str] = None,
1002
+ schema_level: str | None = None,
813
1003
  description: str = "",
814
1004
  force: bool = False,
815
1005
  **schema_keys,
@@ -844,16 +1034,22 @@ class SciDuck:
844
1034
  if schema_keys:
845
1035
  provided_schema_cols = [k for k in self.dataset_schema if k in schema_keys]
846
1036
  if schema_level is None:
847
- schema_level = provided_schema_cols[-1] if provided_schema_cols else self.dataset_schema[-1]
1037
+ schema_level = (
1038
+ provided_schema_cols[-1]
1039
+ if provided_schema_cols
1040
+ else self.dataset_schema[-1]
1041
+ )
848
1042
  if schema_level not in self.dataset_schema:
849
1043
  raise ValueError(
850
1044
  f"schema_level '{schema_level}' not in {self.dataset_schema}"
851
1045
  )
852
1046
  key_cols = provided_schema_cols
853
- entries = [(
854
- {k: schema_keys[k] for k in key_cols},
855
- data,
856
- )]
1047
+ entries = [
1048
+ (
1049
+ {k: schema_keys[k] for k in key_cols},
1050
+ data,
1051
+ )
1052
+ ]
857
1053
 
858
1054
  else:
859
1055
  if schema_level is None:
@@ -865,11 +1061,19 @@ class SciDuck:
865
1061
  key_cols = self._schema_key_columns(schema_level)
866
1062
 
867
1063
  # Mode A: DataFrame with schema columns
868
- if isinstance(data, pd.DataFrame) and all(c in data.columns for c in key_cols):
869
- entries, data_col_name = self._entries_from_dataframe(data, key_cols, schema_level)
1064
+ if isinstance(data, pd.DataFrame) and all(
1065
+ c in data.columns for c in key_cols
1066
+ ):
1067
+ entries, data_col_name = self._entries_from_dataframe(
1068
+ data, key_cols, schema_level
1069
+ )
870
1070
 
871
1071
  # Mode C: dict with tuple keys
872
- elif isinstance(data, dict) and data and isinstance(next(iter(data.keys())), tuple):
1072
+ elif (
1073
+ isinstance(data, dict)
1074
+ and data
1075
+ and isinstance(next(iter(data.keys())), tuple)
1076
+ ):
873
1077
  entries = []
874
1078
  for key_tuple, value in data.items():
875
1079
  if len(key_tuple) != len(key_cols):
@@ -877,7 +1081,7 @@ class SciDuck:
877
1081
  f"Key tuple length {len(key_tuple)} != "
878
1082
  f"expected {len(key_cols)} for level '{schema_level}'"
879
1083
  )
880
- key_dict = dict(zip(key_cols, key_tuple))
1084
+ key_dict = dict(zip(key_cols, key_tuple, strict=False))
881
1085
  entries.append((key_dict, value))
882
1086
 
883
1087
  else:
@@ -890,12 +1094,15 @@ class SciDuck:
890
1094
 
891
1095
  # --- Determine column types from the first entry's data ---
892
1096
  sample_value = entries[0][1]
893
- data_col_types, dtype_meta = self._infer_data_columns(sample_value, data_col_name)
1097
+ data_col_types, dtype_meta = self._infer_data_columns(
1098
+ sample_value, data_col_name
1099
+ )
894
1100
 
895
1101
  # --- Ensure the variable table exists ---
896
1102
  is_dataframe = dtype_meta.get("mode") == "dataframe"
897
- self._ensure_variable_table(name, data_col_types, schema_level,
898
- is_dataframe=is_dataframe)
1103
+ self._ensure_variable_table(
1104
+ name, data_col_types, schema_level, is_dataframe=is_dataframe
1105
+ )
899
1106
 
900
1107
  # --- Insert rows (INSERT OR REPLACE for "latest wins" semantics) ---
901
1108
  col_names = ["schema_id"] + list(data_col_types.keys())
@@ -916,7 +1123,8 @@ class SciDuck:
916
1123
  storage_values = self._value_to_storage_row(value, dtype_meta)
917
1124
  row = [schema_id] + storage_values
918
1125
  self._execute(
919
- f'INSERT OR REPLACE INTO "{name}" ({col_str}) VALUES ({placeholders})', row
1126
+ f'INSERT OR REPLACE INTO "{name}" ({col_str}) VALUES ({placeholders})',
1127
+ row,
920
1128
  )
921
1129
 
922
1130
  # --- Register in _variables (one row per variable) ---
@@ -928,8 +1136,8 @@ class SciDuck:
928
1136
  )
929
1137
 
930
1138
  def _entries_from_dataframe(
931
- self, df: pd.DataFrame, key_cols: List[str], schema_level: str
932
- ) -> Tuple[List[Tuple[dict, Any]], Optional[str]]:
1139
+ self, df: pd.DataFrame, key_cols: list[str], schema_level: str
1140
+ ) -> tuple[list[tuple[dict, Any]], str | None]:
933
1141
  """
934
1142
  Convert a DataFrame (Mode A) into a list of (key_dict, row_data) entries.
935
1143
 
@@ -959,8 +1167,8 @@ class SciDuck:
959
1167
  return entries, single_col_name
960
1168
 
961
1169
  def _infer_data_columns(
962
- self, sample_value: Any, data_col_name: Optional[str] = None
963
- ) -> Tuple[dict, dict]:
1170
+ self, sample_value: Any, data_col_name: str | None = None
1171
+ ) -> tuple[dict, dict]:
964
1172
  """Delegate to module-level _infer_data_columns."""
965
1173
  return _infer_data_columns(sample_value, data_col_name)
966
1174
 
@@ -968,8 +1176,13 @@ class SciDuck:
968
1176
  """Delegate to module-level _value_to_storage_row."""
969
1177
  return _value_to_storage_row(value, dtype_meta)
970
1178
 
971
- def _ensure_variable_table(self, name: str, data_col_types: dict, schema_level: str,
972
- is_dataframe: bool = False):
1179
+ def _ensure_variable_table(
1180
+ self,
1181
+ name: str,
1182
+ data_col_types: dict,
1183
+ schema_level: str,
1184
+ is_dataframe: bool = False,
1185
+ ):
973
1186
  """Create the variable table if it doesn't exist."""
974
1187
  if self._table_exists(name):
975
1188
  return
@@ -999,7 +1212,7 @@ class SciDuck:
999
1212
  name: str,
1000
1213
  raw: bool = True,
1001
1214
  **schema_keys,
1002
- ) -> Union[pd.DataFrame, Any]:
1215
+ ) -> pd.DataFrame | Any:
1003
1216
  """
1004
1217
  Load a variable from the database.
1005
1218
 
@@ -1037,9 +1250,9 @@ class SciDuck:
1037
1250
  data_select = ", ".join(f'v."{c}"' for c in data_cols)
1038
1251
 
1039
1252
  sql = (
1040
- f'SELECT {schema_select}, {data_select} '
1253
+ f"SELECT {schema_select}, {data_select} "
1041
1254
  f'FROM "{name}" v '
1042
- f'JOIN _schema s ON v.schema_id = s.schema_id'
1255
+ f"JOIN _schema s ON v.schema_id = s.schema_id"
1043
1256
  )
1044
1257
  params: list = []
1045
1258
 
@@ -1050,7 +1263,7 @@ class SciDuck:
1050
1263
  conditions.append(f's."{col}" = ?')
1051
1264
  params.append(_schema_str(val))
1052
1265
  if conditions:
1053
- sql += ' WHERE ' + ' AND '.join(conditions)
1266
+ sql += " WHERE " + " AND ".join(conditions)
1054
1267
 
1055
1268
  df = self._fetchdf(sql, params or None)
1056
1269
 
@@ -1064,8 +1277,9 @@ class SciDuck:
1064
1277
  result = {}
1065
1278
  for c, meta in columns_meta.items():
1066
1279
  if c in df.columns:
1067
- result[c] = [_storage_to_python(df[c].iloc[i], meta)
1068
- for i in range(len(df))]
1280
+ result[c] = [
1281
+ _storage_to_python(df[c].iloc[i], meta) for i in range(len(df))
1282
+ ]
1069
1283
  df_columns = dtype_meta.get("df_columns", data_cols)
1070
1284
  return pd.DataFrame(result, columns=df_columns)
1071
1285
 
@@ -1139,18 +1353,14 @@ class SciDuck:
1139
1353
  """
1140
1354
  if self._table_exists(name):
1141
1355
  self._execute(f'DROP TABLE "{name}"')
1142
- self._execute(
1143
- "DELETE FROM _variables WHERE variable_name = ?", [name]
1144
- )
1145
- self._execute(
1146
- "DELETE FROM _variable_groups WHERE variable_name = ?", [name]
1147
- )
1356
+ self._execute("DELETE FROM _variables WHERE variable_name = ?", [name])
1357
+ self._execute("DELETE FROM _variable_groups WHERE variable_name = ?", [name])
1148
1358
 
1149
1359
  # ------------------------------------------------------------------
1150
1360
  # Groups
1151
1361
  # ------------------------------------------------------------------
1152
1362
 
1153
- def add_to_group(self, group_name: str, variable_names: Union[str, List[str]]):
1363
+ def add_to_group(self, group_name: str, variable_names: str | list[str]):
1154
1364
  """Add one or more variables to a group."""
1155
1365
  if isinstance(variable_names, str):
1156
1366
  variable_names = [variable_names]
@@ -1161,7 +1371,7 @@ class SciDuck:
1161
1371
  [group_name, vn],
1162
1372
  )
1163
1373
 
1164
- def remove_from_group(self, group_name: str, variable_names: Union[str, List[str]]):
1374
+ def remove_from_group(self, group_name: str, variable_names: str | list[str]):
1165
1375
  """Remove one or more variables from a group."""
1166
1376
  if isinstance(variable_names, str):
1167
1377
  variable_names = [variable_names]
@@ -1172,14 +1382,14 @@ class SciDuck:
1172
1382
  [group_name, vn],
1173
1383
  )
1174
1384
 
1175
- def list_groups(self) -> List[str]:
1385
+ def list_groups(self) -> list[str]:
1176
1386
  """List all group names."""
1177
1387
  rows = self._fetchall(
1178
1388
  "SELECT DISTINCT group_name FROM _variable_groups ORDER BY group_name"
1179
1389
  )
1180
1390
  return [r[0] for r in rows]
1181
1391
 
1182
- def get_group(self, group_name: str) -> List[str]:
1392
+ def get_group(self, group_name: str) -> list[str]:
1183
1393
  """Get all variable names in a group."""
1184
1394
  rows = self._fetchall(
1185
1395
  "SELECT variable_name FROM _variable_groups "
@@ -1192,12 +1402,11 @@ class SciDuck:
1192
1402
  # Schema introspection
1193
1403
  # ------------------------------------------------------------------
1194
1404
 
1195
- def distinct_schema_values(self, key: str) -> List:
1405
+ def distinct_schema_values(self, key: str) -> list:
1196
1406
  """Return all distinct non-null values for a schema column, sorted."""
1197
1407
  if key not in self.dataset_schema:
1198
1408
  raise ValueError(
1199
- f"'{key}' is not a schema column. "
1200
- f"Available: {self.dataset_schema}"
1409
+ f"'{key}' is not a schema column. Available: {self.dataset_schema}"
1201
1410
  )
1202
1411
  rows = self._fetchall(
1203
1412
  f'SELECT DISTINCT "{key}" FROM _schema '
@@ -1220,8 +1429,7 @@ class SciDuck:
1220
1429
  for k in keys:
1221
1430
  if k not in self.dataset_schema:
1222
1431
  raise ValueError(
1223
- f"'{k}' is not a schema column. "
1224
- f"Available: {self.dataset_schema}"
1432
+ f"'{k}' is not a schema column. Available: {self.dataset_schema}"
1225
1433
  )
1226
1434
  col_list = ", ".join(f'"{k}"' for k in keys)
1227
1435
  where_clause = " AND ".join(f'"{k}" IS NOT NULL' for k in keys)
@@ -1248,12 +1456,18 @@ class SciDuck:
1248
1456
  def close(self):
1249
1457
  """Close the DuckDB connection."""
1250
1458
  self.con.close()
1251
- logger.info("DuckDB lock RELEASED: %s", self.db_path)
1459
+ logger.debug("DuckDB lock RELEASED: %s", self.db_path)
1252
1460
 
1253
1461
  def reopen(self):
1254
- """Reopen the DuckDB connection after close()."""
1255
- logger.info("DuckDB lock ACQUIRED (reopen): %s", self.db_path)
1256
- self.con = duckdb.connect(str(self.db_path))
1462
+ """Reopen the DuckDB connection after close(), preserving read_only mode."""
1463
+ logger.debug(
1464
+ "DuckDB lock ACQUIRED (reopen, read_only=%s): %s",
1465
+ getattr(self, "read_only", False),
1466
+ self.db_path,
1467
+ )
1468
+ self.con = duckdb.connect(
1469
+ str(self.db_path), read_only=getattr(self, "read_only", False)
1470
+ )
1257
1471
 
1258
1472
  def __enter__(self):
1259
1473
  """Enter context manager."""
@@ -1273,4 +1487,4 @@ class SciDuck:
1273
1487
  return (
1274
1488
  f"SciDuck(path='{self.db_path}', "
1275
1489
  f"schema={self.dataset_schema}, variables={n_vars})"
1276
- )
1490
+ )
File without changes