scifor 0.1.0__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.
@@ -0,0 +1,10 @@
1
+ .venv
2
+ .DS_Store
3
+ */*/__pycache__
4
+ others_projects/sciforge
5
+ scistack-gui/extension/node_modules/
6
+ scistack-gui/frontend/node_modules/__pycache__/
7
+ *.pyc
8
+ __pycache__/
9
+ *.pyc
10
+ *.pyo
scifor-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: scifor
3
+ Version: 0.1.0
4
+ Summary: Standalone for_each batch execution utilities for data pipelines
5
+ Author: SciStack Contributors
6
+ License-Expression: MIT
7
+ Keywords: automation,batch,data-science,execution,pipeline
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Provides-Extra: dev
21
+ Requires-Dist: numpy; extra == 'dev'
22
+ Requires-Dist: pandas; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
24
+ Requires-Dist: pytest>=7.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # scifor
28
+
29
+ ## Stop Writing Loops, Start Writing Analysis
30
+
31
+ `scifor` is the batch execution engine behind SciDB. It takes a function you've already written and runs it across every combination of experimental conditions — subjects, sessions, trials, whatever your data is organized by — automatically slicing your data to match each combination and collecting the results into a clean table.
32
+
33
+ It works in both **Python** and **MATLAB**, and it works standalone — no database required, just plain tables.
34
+
35
+ ## The Problem
36
+
37
+ Scientific data is almost always organized by conditions: subjects, sessions, trials, limbs, speeds. Processing it means writing nested loops that slice, call a function, and collect results:
38
+
39
+ ```matlab
40
+ for i = 1:numel(subjects)
41
+ for j = 1:numel(sessions)
42
+ rows = tbl(tbl.subject == subjects(i) & tbl.session == sessions(j), :);
43
+ result = my_analysis(rows.emg);
44
+ % ...now figure out where to put the result
45
+ end
46
+ end
47
+ ```
48
+
49
+ This gets worse as the number of conditions grows, and the loop logic buries the actual analysis. Every scientist writes some version of this, and it's never the interesting part.
50
+
51
+ ## How scifor Works
52
+
53
+ You tell scifor two things:
54
+
55
+ 1. **Schema** — which columns in your tables represent experimental conditions (the things you iterate over)
56
+ 2. **Inputs** — which tables to slice, and which values are just constants
57
+
58
+ Then scifor handles the rest: it loops over every combination, filters each table to the matching rows, calls your function, and collects the results.
59
+
60
+ **MATLAB**
61
+
62
+ ```matlab
63
+ scifor.set_schema(["subject", "session"]);
64
+
65
+ results = scifor.for_each(@my_analysis, ...
66
+ struct('emg', data_table, 'cutoff_hz', 20), ...
67
+ subject=[1, 2, 3], session=["pre", "post"]);
68
+ ```
69
+
70
+ **Python**
71
+
72
+ ```python
73
+ from scifor import set_schema, for_each
74
+
75
+ set_schema(["subject", "session"])
76
+
77
+ results = for_each(
78
+ my_analysis,
79
+ inputs={"emg": data_table, "cutoff_hz": 20},
80
+ subject=[1, 2, 3],
81
+ session=["pre", "post"],
82
+ )
83
+ ```
84
+
85
+ For each of the 6 combinations (3 subjects x 2 sessions), scifor filters `data_table` to the matching rows, passes the `emg` column to `my_analysis` along with the constant `cutoff_hz=20`, and collects the return value. The result is a table with `subject`, `session`, and `output` columns — one row per combination.
86
+
87
+ Your function doesn't need to know about looping, filtering, or metadata. It just receives data and returns a result.
88
+
89
+ ## What You Get Back
90
+
91
+ `for_each` returns a MATLAB table (or pandas DataFrame in Python) with one row per combination. Metadata columns come first, then the output:
92
+
93
+ | subject | session | output |
94
+ |---------|---------|--------|
95
+ | 1 | pre | 0.82 |
96
+ | 1 | post | 1.47 |
97
+ | 2 | pre | 0.91 |
98
+ | 2 | post | 1.38 |
99
+ | 3 | pre | 0.76 |
100
+ | 3 | post | 1.22 |
101
+
102
+ If your function returns a table, its columns are flattened into the result alongside the metadata. If your function returns multiple outputs, you get multiple result tables.
103
+
104
+ ## Beyond the Basics
105
+
106
+ The examples above cover the most common case, but real pipelines have real-world complications. scifor has tools for each of them:
107
+
108
+ - **Fixed inputs** — Pin one input to a specific condition while the others iterate. The classic case: comparing every session against a fixed baseline.
109
+
110
+ - **Merging tables** — When your function needs columns from two separate tables (say, kinematics and force data), combine them into a single input per combination.
111
+
112
+ - **Column selection** — Extract just the columns you need from a multi-column table before your function sees it.
113
+
114
+ - **File paths from metadata** — When your data lives in files organized by condition (e.g., `data/subject_1/trial_3.mat`), generate the right file path for each combination automatically.
115
+
116
+ - **Row filtering** — Apply column-based filters (like "only right-side trials" or "speed > 1.5") on top of the metadata filtering.
117
+
118
+ - **Dry run** — Preview which combinations would be processed and what data would be passed, without actually running anything.
119
+
120
+ - **Distribute** — When a function returns a vector of values that should each become their own row at a deeper schema level (e.g., splitting a trial into individual gait cycles), scifor can expand the output automatically.
121
+
122
+ See the [API reference](../docs/api/for-each.md) and [batch processing guide](../docs/guide/for_each.md) for details on all of these.
123
+
124
+ ## Relationship to SciDB
125
+
126
+ scifor is the engine that powers `scidb.for_each()`. When used through SciDB, inputs are loaded from the database and outputs are saved back automatically. When used standalone (as `scifor.for_each()`), it works with plain MATLAB tables or pandas DataFrames — no database needed.
127
+
128
+ If you're already using SciDB, you're already using scifor under the hood. If you just want the loop orchestration without the database, use the `scifor` namespace directly.
scifor-0.1.0/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # scifor
2
+
3
+ ## Stop Writing Loops, Start Writing Analysis
4
+
5
+ `scifor` is the batch execution engine behind SciDB. It takes a function you've already written and runs it across every combination of experimental conditions — subjects, sessions, trials, whatever your data is organized by — automatically slicing your data to match each combination and collecting the results into a clean table.
6
+
7
+ It works in both **Python** and **MATLAB**, and it works standalone — no database required, just plain tables.
8
+
9
+ ## The Problem
10
+
11
+ Scientific data is almost always organized by conditions: subjects, sessions, trials, limbs, speeds. Processing it means writing nested loops that slice, call a function, and collect results:
12
+
13
+ ```matlab
14
+ for i = 1:numel(subjects)
15
+ for j = 1:numel(sessions)
16
+ rows = tbl(tbl.subject == subjects(i) & tbl.session == sessions(j), :);
17
+ result = my_analysis(rows.emg);
18
+ % ...now figure out where to put the result
19
+ end
20
+ end
21
+ ```
22
+
23
+ This gets worse as the number of conditions grows, and the loop logic buries the actual analysis. Every scientist writes some version of this, and it's never the interesting part.
24
+
25
+ ## How scifor Works
26
+
27
+ You tell scifor two things:
28
+
29
+ 1. **Schema** — which columns in your tables represent experimental conditions (the things you iterate over)
30
+ 2. **Inputs** — which tables to slice, and which values are just constants
31
+
32
+ Then scifor handles the rest: it loops over every combination, filters each table to the matching rows, calls your function, and collects the results.
33
+
34
+ **MATLAB**
35
+
36
+ ```matlab
37
+ scifor.set_schema(["subject", "session"]);
38
+
39
+ results = scifor.for_each(@my_analysis, ...
40
+ struct('emg', data_table, 'cutoff_hz', 20), ...
41
+ subject=[1, 2, 3], session=["pre", "post"]);
42
+ ```
43
+
44
+ **Python**
45
+
46
+ ```python
47
+ from scifor import set_schema, for_each
48
+
49
+ set_schema(["subject", "session"])
50
+
51
+ results = for_each(
52
+ my_analysis,
53
+ inputs={"emg": data_table, "cutoff_hz": 20},
54
+ subject=[1, 2, 3],
55
+ session=["pre", "post"],
56
+ )
57
+ ```
58
+
59
+ For each of the 6 combinations (3 subjects x 2 sessions), scifor filters `data_table` to the matching rows, passes the `emg` column to `my_analysis` along with the constant `cutoff_hz=20`, and collects the return value. The result is a table with `subject`, `session`, and `output` columns — one row per combination.
60
+
61
+ Your function doesn't need to know about looping, filtering, or metadata. It just receives data and returns a result.
62
+
63
+ ## What You Get Back
64
+
65
+ `for_each` returns a MATLAB table (or pandas DataFrame in Python) with one row per combination. Metadata columns come first, then the output:
66
+
67
+ | subject | session | output |
68
+ |---------|---------|--------|
69
+ | 1 | pre | 0.82 |
70
+ | 1 | post | 1.47 |
71
+ | 2 | pre | 0.91 |
72
+ | 2 | post | 1.38 |
73
+ | 3 | pre | 0.76 |
74
+ | 3 | post | 1.22 |
75
+
76
+ If your function returns a table, its columns are flattened into the result alongside the metadata. If your function returns multiple outputs, you get multiple result tables.
77
+
78
+ ## Beyond the Basics
79
+
80
+ The examples above cover the most common case, but real pipelines have real-world complications. scifor has tools for each of them:
81
+
82
+ - **Fixed inputs** — Pin one input to a specific condition while the others iterate. The classic case: comparing every session against a fixed baseline.
83
+
84
+ - **Merging tables** — When your function needs columns from two separate tables (say, kinematics and force data), combine them into a single input per combination.
85
+
86
+ - **Column selection** — Extract just the columns you need from a multi-column table before your function sees it.
87
+
88
+ - **File paths from metadata** — When your data lives in files organized by condition (e.g., `data/subject_1/trial_3.mat`), generate the right file path for each combination automatically.
89
+
90
+ - **Row filtering** — Apply column-based filters (like "only right-side trials" or "speed > 1.5") on top of the metadata filtering.
91
+
92
+ - **Dry run** — Preview which combinations would be processed and what data would be passed, without actually running anything.
93
+
94
+ - **Distribute** — When a function returns a vector of values that should each become their own row at a deeper schema level (e.g., splitting a trial into individual gait cycles), scifor can expand the output automatically.
95
+
96
+ See the [API reference](../docs/api/for-each.md) and [batch processing guide](../docs/guide/for_each.md) for details on all of these.
97
+
98
+ ## Relationship to SciDB
99
+
100
+ scifor is the engine that powers `scidb.for_each()`. When used through SciDB, inputs are loaded from the database and outputs are saved back automatically. When used standalone (as `scifor.for_each()`), it works with plain MATLAB tables or pandas DataFrames — no database needed.
101
+
102
+ If you're already using SciDB, you're already using scifor under the hood. If you just want the loop orchestration without the database, use the `scifor` namespace directly.
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "scifor"
7
+ version = "0.1.0"
8
+ description = "Standalone for_each batch execution utilities for data pipelines"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "SciStack Contributors" }
14
+ ]
15
+ keywords = [
16
+ "batch",
17
+ "pipeline",
18
+ "execution",
19
+ "data-science",
20
+ "automation",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Developers",
25
+ "Intended Audience :: Science/Research",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Topic :: Scientific/Engineering",
33
+ "Typing :: Typed",
34
+ ]
35
+ dependencies = []
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "pytest>=7.0",
40
+ "pytest-cov>=4.0",
41
+ "pandas",
42
+ "numpy",
43
+ ]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "/src",
48
+ ]
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/scifor"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ pythonpath = ["src"]
@@ -0,0 +1,56 @@
1
+ """scifor: Standalone for_each batch execution for data pipelines.
2
+
3
+ Works with plain DataFrames. No database required.
4
+
5
+ Example:
6
+ import pandas as pd
7
+ from scifor import set_schema, for_each, Col
8
+
9
+ set_schema(["subject", "session"])
10
+
11
+ raw_df = pd.DataFrame({
12
+ "subject": [1, 1, 2, 2],
13
+ "session": ["pre", "post", "pre", "post"],
14
+ "emg": [0.1, 0.2, 0.3, 0.4],
15
+ })
16
+
17
+ result = for_each(
18
+ lambda signal: signal.mean(),
19
+ inputs={"signal": raw_df},
20
+ subject=[1, 2],
21
+ session=["pre", "post"],
22
+ )
23
+ """
24
+
25
+ from .schema import set_schema, get_schema
26
+ from .foreach import for_each, ColumnFunctionError, ForColumnsError
27
+ from .fixed import Fixed
28
+ from .merge import Merge
29
+ from .column_selection import ColumnSelection
30
+ from .colname import ColName
31
+ from .pathinput import PathInput
32
+ from .pathoutput import PathOutput
33
+ from .filters import Col, ColFilter, CompoundFilter, NotFilter
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ # Schema
38
+ "set_schema",
39
+ "get_schema",
40
+ # Batch execution
41
+ "for_each",
42
+ "ColumnFunctionError",
43
+ "ForColumnsError",
44
+ # Input wrappers
45
+ "Fixed",
46
+ "Merge",
47
+ "ColumnSelection",
48
+ "ColName",
49
+ "PathInput",
50
+ "PathOutput",
51
+ # Filters
52
+ "Col",
53
+ "ColFilter",
54
+ "CompoundFilter",
55
+ "NotFilter",
56
+ ]
@@ -0,0 +1,60 @@
1
+ """ColName wrapper — resolves to the single non-schema data column name of a DataFrame."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ColName:
7
+ """
8
+ Marker that resolves to a data column name string at for_each time.
9
+
10
+ Two forms:
11
+
12
+ 1. ``ColName(df)`` — *static*. Resolves once, up front, to the single
13
+ non-schema data column name of ``df``. Use when a function needs to know
14
+ the name of the one data column in a DataFrame but should stay
15
+ framework-agnostic. Raises ValueError if ``df`` has 0 or 2+ non-schema
16
+ data columns.
17
+
18
+ 2. ``ColName()`` — *deferred*. Resolves per-column inside a ``for_columns``
19
+ iteration to the name of the column currently being fed to the function.
20
+ Requires at least one iterate input (``for_columns`` /
21
+ ``ColumnSelection(..., iterate=True)``); using it without one is an error.
22
+
23
+ As a convenience, passing the bare class ``ColName`` (no parentheses) is
24
+ accepted and treated as the deferred ``ColName()`` form — the only form it
25
+ can mean, since there is no DataFrame to attach.
26
+
27
+ Example (static):
28
+ set_schema(["subject", "session"])
29
+ result = for_each(
30
+ analyze,
31
+ inputs={"table": raw_df, "col_name": ColName(raw_df)},
32
+ subject=[1, 2],
33
+ session=["pre", "post"],
34
+ )
35
+
36
+ # The function is pure — no framework imports:
37
+ def analyze(table, col_name):
38
+ return table[col_name].mean()
39
+
40
+ Example (deferred, current for_columns column):
41
+ for_each(
42
+ analyze,
43
+ inputs={"df": means_df.for_columns(), "col_name": ColName()},
44
+ subject=[],
45
+ )
46
+ """
47
+
48
+ def __init__(self, data: Any = None):
49
+ """
50
+ Args:
51
+ data: A pandas DataFrame whose single data column name will be
52
+ resolved (static form). Omit it for the deferred form, which
53
+ resolves to the current ``for_columns`` column.
54
+ """
55
+ self.data = data
56
+
57
+ @property
58
+ def is_deferred(self) -> bool:
59
+ """True for the no-arg form (resolves to the current for_columns column)."""
60
+ return self.data is None
@@ -0,0 +1,96 @@
1
+ """Column selection wrapper for DataFrame inputs in for_each."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ColumnSelection:
7
+ """
8
+ Wraps a DataFrame with column selection for use in for_each() inputs.
9
+
10
+ After filtering the DataFrame for the current combo, extracts only the
11
+ specified columns. Single column -> returns numpy array of values.
12
+ Multiple columns -> returns a sub-DataFrame.
13
+
14
+ Example:
15
+ for_each(
16
+ fn,
17
+ inputs={"speed": ColumnSelection(data_df, ["speed"])},
18
+ subject=[1, 2, 3],
19
+ )
20
+
21
+ When ``iterate=True`` the selection means "run fn once per column" rather
22
+ than "pass these columns as one argument." The per-combo loop feeds each
23
+ column (as a single-column array) to fn in turn and reassembles the
24
+ per-column results into one wide row. All iterate selections in a single
25
+ for_each() call must share the same column set (zipped by name).
26
+
27
+ An **empty** ``columns`` (the default, e.g. ``ColumnSelection(df)``) means
28
+ "all data columns" — every column that is not a schema key — resolved at
29
+ for_each time from the DataFrame. This mirrors how an empty iteration list
30
+ (``subject=[]``) means "all values from the data". (``None`` is accepted as
31
+ an alias for the empty/all sentinel for backward compatibility.)
32
+
33
+ ``excl_columns`` drops named columns from whatever ``columns`` resolves to
34
+ (explicit list or the all-columns expansion). This is the natural companion
35
+ to the all-columns/iterate mode: take the non-numeric (or otherwise
36
+ unwanted) columns surfaced in a failure and exclude them, so they are
37
+ skipped during iteration and absent from the aggregated result.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ data: Any,
43
+ columns: "list[str] | None" = [],
44
+ iterate: bool = False,
45
+ excl_columns: "list[str] | None" = None,
46
+ ):
47
+ """
48
+ Args:
49
+ data: A pandas DataFrame.
50
+ columns: List of column names to extract after filtering. An empty
51
+ list ``[]`` (the default) means "all data columns", resolved at
52
+ for_each time. ``None`` is accepted as an alias for ``[]``.
53
+ iterate: If True, iterate over the columns (one fn call each) and
54
+ reassemble; if False (default), pass the column(s) as a single
55
+ argument.
56
+ excl_columns: Column names to remove from the resolved selection
57
+ (after the all-columns expansion). Useful for dropping the
58
+ non-data columns reported by a ``ColumnFunctionError`` without
59
+ having to enumerate every column you *do* want.
60
+ """
61
+ self.data = data
62
+ # Normalize the all-columns sentinel to an empty list (copying any
63
+ # provided list so a shared default object is never mutated).
64
+ self.columns = list(columns) if columns else []
65
+ self.iterate = iterate
66
+ self.excl_columns = list(excl_columns) if excl_columns else []
67
+
68
+ @property
69
+ def __name__(self) -> str:
70
+ """Return a display name for format_inputs and error messages."""
71
+ data_name = _display_name(self.data)
72
+ suffix = ", iterate" if self.iterate else ""
73
+ if self.excl_columns:
74
+ excl = ", ".join(f'"{c}"' for c in self.excl_columns)
75
+ suffix = f"{suffix}, excl=[{excl}]"
76
+ if not self.columns:
77
+ return f'{data_name}[<all columns>{suffix}]'
78
+ if len(self.columns) == 1 and not self.iterate and not self.excl_columns:
79
+ return f'{data_name}["{self.columns[0]}"]'
80
+ cols = ", ".join(f'"{c}"' for c in self.columns)
81
+ return f'{data_name}[{cols}{suffix}]'
82
+
83
+ def __hash__(self):
84
+ return hash((id(self.data), tuple(self.columns), self.iterate,
85
+ tuple(self.excl_columns)))
86
+
87
+
88
+ def _display_name(obj: Any) -> str:
89
+ """Get a display name for an object."""
90
+ try:
91
+ import pandas as pd
92
+ if isinstance(obj, pd.DataFrame):
93
+ return f"DataFrame{list(obj.columns)}"
94
+ except ImportError:
95
+ pass
96
+ return getattr(obj, '__name__', type(obj).__name__)
@@ -0,0 +1,172 @@
1
+ """Flat-table export for ``scifor.Merge``: inner-join constituents into one frame.
2
+
3
+ ``merge_to_dataframe`` builds the joined pandas DataFrame; ``export_merge_csv`` is
4
+ a thin wrapper that writes it to a ``.csv``. Both back ``Merge.as_df`` and
5
+ ``Merge.to_csv`` respectively.
6
+
7
+ Unlike the ``for_each`` merge path (``_prepare_merge``/``_merge_parts`` in
8
+ ``foreach.py``), which combo-filters each constituent then positionally
9
+ concatenates after *dropping* the schema columns, this performs a real
10
+ ``pd.merge(how="inner")`` across the constituents and keeps a single copy of the
11
+ shared schema columns. There is no per-combo iteration here — the whole
12
+ constituent DataFrames are joined at once.
13
+
14
+ Non-schema columns are assumed not to overlap between constituents (caller
15
+ guarantee), so the columns common to any two constituents are exactly the shared
16
+ schema columns, which become the join keys.
17
+ """
18
+
19
+ from typing import Any
20
+
21
+
22
+ def merge_to_dataframe(
23
+ merge: Any,
24
+ where=None,
25
+ _log_fn: "Any" = None,
26
+ **metadata: Any,
27
+ ) -> "Any":
28
+ """Inner-join a ``Merge``'s constituents and return the joined DataFrame.
29
+
30
+ Args:
31
+ merge: A ``scifor.Merge`` instance.
32
+ where: Optional scifor ColName/Col filter applied once to the joined
33
+ table (so it may reference columns from any constituent).
34
+ _log_fn: Optional ``Callable[[str], None]`` for diagnostics (NOTE 2).
35
+ **metadata: Row filters applied per constituent on any matching schema
36
+ column. A scalar matches by equality; a list/tuple/set matches by
37
+ membership. Keys absent from a given constituent are skipped.
38
+
39
+ Returns:
40
+ The inner-joined pandas DataFrame with one copy of the shared schema
41
+ columns plus every selected data column.
42
+ """
43
+ import pandas as pd
44
+
45
+ from .foreach import (
46
+ _resolve_data_spec,
47
+ _apply_where_filter,
48
+ _all_data_columns,
49
+ _apply_exclusions,
50
+ _excluded_columns,
51
+ )
52
+ from .schema import get_schema
53
+
54
+ def _log(msg: str) -> None:
55
+ if _log_fn is not None:
56
+ _log_fn(f"[scifor] {msg}")
57
+
58
+ schema_keys = get_schema()
59
+ _log(f"merge: schema keys = {schema_keys or '(unset)'}")
60
+
61
+ parts: list[tuple[str, "pd.DataFrame"]] = []
62
+ for i, spec in enumerate(merge.tables):
63
+ label = f"merge[{i}]"
64
+
65
+ df, effective_metadata, column_selection = _resolve_data_spec(spec, metadata)
66
+ excl = _excluded_columns(spec)
67
+
68
+ filtered = _filter_rows_by_metadata(df, effective_metadata)
69
+
70
+ if column_selection is not None:
71
+ # An empty selection means "all data columns".
72
+ cols = column_selection or _all_data_columns(filtered, schema_keys)
73
+ cols = _apply_exclusions(cols, excl)
74
+ missing = [c for c in cols if c not in filtered.columns]
75
+ if missing:
76
+ raise KeyError(
77
+ f"Column(s) {missing} not found in {label}. "
78
+ f"Available: {list(filtered.columns)}"
79
+ )
80
+ else:
81
+ cols = _all_data_columns(filtered, schema_keys)
82
+
83
+ # Keep schema columns present (needed as join keys) plus the selected
84
+ # data columns, preserving original column order.
85
+ schema_present = [c for c in filtered.columns if c in schema_keys]
86
+ data_cols = [c for c in cols if c not in schema_present]
87
+ keep = schema_present + data_cols
88
+ part_df = filtered[keep].reset_index(drop=True)
89
+
90
+ _log(
91
+ f"merge: {label} -> {part_df.shape[0]} row(s) x {part_df.shape[1]} "
92
+ f"col(s); schema={schema_present}, data={data_cols}"
93
+ )
94
+ parts.append((label, part_df))
95
+
96
+ # Fold an inner join across the constituents on their shared schema columns.
97
+ acc_label, acc = parts[0]
98
+ for label, part in parts[1:]:
99
+ join_keys = [c for c in acc.columns if c in part.columns]
100
+ if schema_keys:
101
+ join_keys = [c for c in join_keys if c in schema_keys]
102
+ if not join_keys:
103
+ raise ValueError(
104
+ f"Cannot inner-join {acc_label} and {label}: no shared schema "
105
+ f"column to join on. {acc_label} columns: {list(acc.columns)}; "
106
+ f"{label} columns: {list(part.columns)}. Schema keys: "
107
+ f"{schema_keys or '(unset — call set_schema())'}."
108
+ )
109
+ _log(f"merge: joining {label} into {acc_label} on {join_keys}")
110
+ acc = pd.merge(acc, part, on=join_keys, how="inner")
111
+ acc_label = f"{acc_label}+{label}"
112
+
113
+ # The where= predicate spans the merged row, so apply it once to the joined
114
+ # table (a constituent need not contain every column the filter references).
115
+ if where is not None:
116
+ before = len(acc)
117
+ acc = _apply_where_filter(acc, where)
118
+ _log(f"merge: where= filter kept {len(acc)}/{before} row(s)")
119
+
120
+ _log(f"merge: result {acc.shape[0]} row(s) x {acc.shape[1]} col(s)")
121
+ return acc
122
+
123
+
124
+ def export_merge_csv(
125
+ merge: Any,
126
+ filename: str,
127
+ where=None,
128
+ _log_fn: "Any" = None,
129
+ **metadata: Any,
130
+ ) -> None:
131
+ """Inner-join a ``Merge``'s constituents and write the result to ``filename``.
132
+
133
+ Thin wrapper over ``merge_to_dataframe``; ``filename`` must end with ``.csv``.
134
+ """
135
+ if not isinstance(filename, str) or not filename.endswith(".csv"):
136
+ raise ValueError(
137
+ f"to_csv() filename must be a string ending with '.csv', got {filename!r}."
138
+ )
139
+
140
+ df = merge_to_dataframe(merge, where=where, _log_fn=_log_fn, **metadata)
141
+
142
+ if _log_fn is not None:
143
+ _log_fn(
144
+ f"[scifor] to_csv: writing {df.shape[0]} row(s) x {df.shape[1]} "
145
+ f"col(s) to {filename!r}"
146
+ )
147
+ df.to_csv(filename, index=False)
148
+
149
+
150
+ def _filter_rows_by_metadata(
151
+ df: "Any", metadata: dict
152
+ ) -> "Any":
153
+ """Filter df rows by metadata for any matching column (scalar or list).
154
+
155
+ Mirrors ``_filter_df_for_combo`` but accepts list/tuple/set values (matched
156
+ by membership) in addition to scalars (matched by equality). Keys not
157
+ present as columns in ``df`` are skipped.
158
+ """
159
+ import pandas as pd
160
+
161
+ if len(df.columns) == 0:
162
+ return df.reset_index(drop=True)
163
+
164
+ mask = pd.Series([True] * len(df), index=df.index)
165
+ for key, val in metadata.items():
166
+ if key not in df.columns:
167
+ continue
168
+ if isinstance(val, (list, tuple, set)):
169
+ mask = mask & df[key].isin(list(val))
170
+ else:
171
+ mask = mask & (df[key] == val)
172
+ return df[mask].reset_index(drop=True)