xplainable-preprocessing 0.1.0__tar.gz → 0.2.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.
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/PKG-INFO +1 -1
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/pyproject.toml +1 -1
- xplainable_preprocessing-0.2.0/scripts/release.sh +292 -0
- xplainable_preprocessing-0.2.0/src/xplainable_preprocessing/schema.py +372 -0
- xplainable_preprocessing-0.1.0/src/xplainable_preprocessing/schema.py +0 -71
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/docs/dag-pipeline-proposal.md +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/docs/feature-pipeline-architectures.md +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/docs/feature-store-proposal.md +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/__init__.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/compiler.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/pipeline.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/preview.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/registry.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/sandbox.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/serialization.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/__init__.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/category_condense.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/datetime_extract.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/drop_columns.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/expression.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/fill_missing.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/grouped_lag.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/rename_columns.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/rolling_agg.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/text_clean.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/type_cast.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/__init__.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_compiler.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_preview.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_sandbox.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_schema.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_serialization.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_transformers/__init__.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_transformers/test_all_transformers.py +0 -0
- {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_transformers/test_expression.py +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Simple release script for xplainable-preprocessing
|
|
3
|
+
|
|
4
|
+
set -e
|
|
5
|
+
|
|
6
|
+
# Colors for output
|
|
7
|
+
RED='\033[0;31m'
|
|
8
|
+
GREEN='\033[0;32m'
|
|
9
|
+
YELLOW='\033[1;33m'
|
|
10
|
+
BLUE='\033[0;34m'
|
|
11
|
+
NC='\033[0m' # No Color
|
|
12
|
+
|
|
13
|
+
# Functions
|
|
14
|
+
print_header() {
|
|
15
|
+
echo -e "${BLUE}$1${NC}"
|
|
16
|
+
echo "=================================="
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
print_success() {
|
|
20
|
+
echo -e "${GREEN}✅ $1${NC}"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
print_warning() {
|
|
24
|
+
echo -e "${YELLOW}⚠️ $1${NC}"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
print_error() {
|
|
28
|
+
echo -e "${RED}❌ $1${NC}"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Parse arguments
|
|
32
|
+
VERSION=""
|
|
33
|
+
INCREMENT=""
|
|
34
|
+
DRY_RUN=false
|
|
35
|
+
PUBLISH_PYPI=false
|
|
36
|
+
|
|
37
|
+
while [[ $# -gt 0 ]]; do
|
|
38
|
+
case $1 in
|
|
39
|
+
-v|--version)
|
|
40
|
+
VERSION="$2"
|
|
41
|
+
shift 2
|
|
42
|
+
;;
|
|
43
|
+
-i|--increment)
|
|
44
|
+
INCREMENT="$2"
|
|
45
|
+
shift 2
|
|
46
|
+
;;
|
|
47
|
+
--dry-run)
|
|
48
|
+
DRY_RUN=true
|
|
49
|
+
shift
|
|
50
|
+
;;
|
|
51
|
+
--publish)
|
|
52
|
+
PUBLISH_PYPI=true
|
|
53
|
+
shift
|
|
54
|
+
;;
|
|
55
|
+
-h|--help)
|
|
56
|
+
echo "Usage: $0 [OPTIONS]"
|
|
57
|
+
echo ""
|
|
58
|
+
echo "Options:"
|
|
59
|
+
echo " -v, --version VERSION Specific version (e.g., 1.2.3)"
|
|
60
|
+
echo " -i, --increment TYPE Auto-increment: major, minor, or patch"
|
|
61
|
+
echo " --dry-run Show what would be done"
|
|
62
|
+
echo " --publish Publish to PyPI after building"
|
|
63
|
+
echo " -h, --help Show this help"
|
|
64
|
+
echo ""
|
|
65
|
+
echo "Examples:"
|
|
66
|
+
echo " $0 --version 0.2.0"
|
|
67
|
+
echo " $0 --increment patch"
|
|
68
|
+
echo " $0 --increment minor --publish"
|
|
69
|
+
echo " $0 --increment minor --dry-run"
|
|
70
|
+
exit 0
|
|
71
|
+
;;
|
|
72
|
+
*)
|
|
73
|
+
print_error "Unknown option: $1"
|
|
74
|
+
exit 1
|
|
75
|
+
;;
|
|
76
|
+
esac
|
|
77
|
+
done
|
|
78
|
+
|
|
79
|
+
# Validate arguments
|
|
80
|
+
if [[ -z "$VERSION" && -z "$INCREMENT" ]]; then
|
|
81
|
+
print_error "Must specify either --version or --increment"
|
|
82
|
+
echo "Run $0 --help for usage"
|
|
83
|
+
exit 1
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
if [[ -n "$VERSION" && -n "$INCREMENT" ]]; then
|
|
87
|
+
print_error "Cannot specify both --version and --increment"
|
|
88
|
+
exit 1
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
print_header "🚀 xplainable-preprocessing Release"
|
|
92
|
+
|
|
93
|
+
# Check if we're in the right directory
|
|
94
|
+
if [[ ! -f "pyproject.toml" ]]; then
|
|
95
|
+
print_error "Not in project root (no pyproject.toml found)"
|
|
96
|
+
exit 1
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
# Check git status
|
|
100
|
+
print_header "📋 Pre-release Checks"
|
|
101
|
+
|
|
102
|
+
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
|
103
|
+
print_error "Not in a git repository"
|
|
104
|
+
exit 1
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
if [[ -n $(git status --porcelain) ]]; then
|
|
108
|
+
print_error "Uncommitted changes detected"
|
|
109
|
+
git status --short
|
|
110
|
+
exit 1
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
CURRENT_BRANCH=$(git branch --show-current)
|
|
114
|
+
if [[ "$CURRENT_BRANCH" != "main" && "$CURRENT_BRANCH" != "master" ]]; then
|
|
115
|
+
print_warning "Currently on branch '$CURRENT_BRANCH'"
|
|
116
|
+
read -p "Continue anyway? (y/N): " -n 1 -r
|
|
117
|
+
echo
|
|
118
|
+
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
119
|
+
exit 1
|
|
120
|
+
fi
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
print_success "Git repository is clean"
|
|
124
|
+
|
|
125
|
+
# Run tests before release
|
|
126
|
+
print_header "🧪 Running Tests"
|
|
127
|
+
if python -m pytest tests/ -q; then
|
|
128
|
+
print_success "All tests passed"
|
|
129
|
+
else
|
|
130
|
+
print_error "Tests failed — aborting release"
|
|
131
|
+
exit 1
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# Get current version from pyproject.toml
|
|
135
|
+
CURRENT_VERSION=$(grep '^version =' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
|
136
|
+
|
|
137
|
+
if [[ -z "$CURRENT_VERSION" ]]; then
|
|
138
|
+
print_error "Could not determine current version"
|
|
139
|
+
exit 1
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
print_success "Current version: $CURRENT_VERSION"
|
|
143
|
+
|
|
144
|
+
# Determine new version
|
|
145
|
+
if [[ -n "$VERSION" ]]; then
|
|
146
|
+
NEW_VERSION="${VERSION#v}" # Remove 'v' prefix if present
|
|
147
|
+
else
|
|
148
|
+
# Auto-increment
|
|
149
|
+
IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION"
|
|
150
|
+
MAJOR=${VERSION_PARTS[0]}
|
|
151
|
+
MINOR=${VERSION_PARTS[1]}
|
|
152
|
+
PATCH=${VERSION_PARTS[2]}
|
|
153
|
+
|
|
154
|
+
case $INCREMENT in
|
|
155
|
+
major)
|
|
156
|
+
MAJOR=$((MAJOR + 1))
|
|
157
|
+
MINOR=0
|
|
158
|
+
PATCH=0
|
|
159
|
+
;;
|
|
160
|
+
minor)
|
|
161
|
+
MINOR=$((MINOR + 1))
|
|
162
|
+
PATCH=0
|
|
163
|
+
;;
|
|
164
|
+
patch)
|
|
165
|
+
PATCH=$((PATCH + 1))
|
|
166
|
+
;;
|
|
167
|
+
*)
|
|
168
|
+
print_error "Invalid increment type: $INCREMENT"
|
|
169
|
+
exit 1
|
|
170
|
+
;;
|
|
171
|
+
esac
|
|
172
|
+
|
|
173
|
+
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
TAG="v$NEW_VERSION"
|
|
177
|
+
|
|
178
|
+
print_header "📦 Release Plan"
|
|
179
|
+
echo "Current: $CURRENT_VERSION"
|
|
180
|
+
echo "New: $NEW_VERSION"
|
|
181
|
+
echo "Tag: $TAG"
|
|
182
|
+
|
|
183
|
+
if [[ "$DRY_RUN" == true ]]; then
|
|
184
|
+
print_header "🔍 DRY RUN - Would perform:"
|
|
185
|
+
echo "1. Clean build artifacts"
|
|
186
|
+
echo "2. Update version in pyproject.toml"
|
|
187
|
+
echo "3. Commit version changes"
|
|
188
|
+
echo "4. Create git tag $TAG"
|
|
189
|
+
echo "5. Build package (wheel and sdist)"
|
|
190
|
+
echo "6. Push changes and tag"
|
|
191
|
+
if [[ "$PUBLISH_PYPI" == true ]]; then
|
|
192
|
+
echo "7. Publish package to PyPI"
|
|
193
|
+
echo "8. Create GitHub release (if gh CLI available)"
|
|
194
|
+
else
|
|
195
|
+
echo "7. Create GitHub release (if gh CLI available)"
|
|
196
|
+
fi
|
|
197
|
+
exit 0
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
# Confirm
|
|
201
|
+
read -p "Create release v$NEW_VERSION? (y/N): " -n 1 -r
|
|
202
|
+
echo
|
|
203
|
+
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
204
|
+
print_error "Release cancelled"
|
|
205
|
+
exit 1
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
print_header "🔨 Creating Release"
|
|
209
|
+
|
|
210
|
+
# Clean up build artifacts
|
|
211
|
+
print_success "Cleaning up build artifacts..."
|
|
212
|
+
rm -rf build/ dist/ *.egg-info/ src/*.egg-info/ || true
|
|
213
|
+
|
|
214
|
+
# Update version in pyproject.toml
|
|
215
|
+
print_success "Updating version..."
|
|
216
|
+
sed -i.bak "s/^version = .*/version = \"$NEW_VERSION\"/" pyproject.toml && rm pyproject.toml.bak
|
|
217
|
+
print_success "Updated pyproject.toml"
|
|
218
|
+
|
|
219
|
+
# Commit changes
|
|
220
|
+
print_success "Committing version changes..."
|
|
221
|
+
git add .
|
|
222
|
+
git commit -m "chore: bump version to $NEW_VERSION"
|
|
223
|
+
|
|
224
|
+
# Create tag
|
|
225
|
+
print_success "Creating tag $TAG..."
|
|
226
|
+
RELEASE_DATE=$(date +%Y-%m-%d)
|
|
227
|
+
git tag -a "$TAG" -m "Release $TAG — $RELEASE_DATE"
|
|
228
|
+
|
|
229
|
+
# Build package
|
|
230
|
+
print_success "Building package..."
|
|
231
|
+
python -m build
|
|
232
|
+
|
|
233
|
+
# Push changes
|
|
234
|
+
print_success "Pushing changes..."
|
|
235
|
+
git push
|
|
236
|
+
git push --tags
|
|
237
|
+
|
|
238
|
+
# Publish to PyPI if requested
|
|
239
|
+
if [[ "$PUBLISH_PYPI" == true ]]; then
|
|
240
|
+
print_success "Publishing to PyPI..."
|
|
241
|
+
|
|
242
|
+
if ! command -v twine &> /dev/null; then
|
|
243
|
+
print_error "twine not found"
|
|
244
|
+
echo "Install with: pip install twine"
|
|
245
|
+
exit 1
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
if [[ -z "$TWINE_PASSWORD" && ! -f ~/.pypirc ]]; then
|
|
249
|
+
print_error "PyPI credentials not configured"
|
|
250
|
+
echo "Set up credentials with one of:"
|
|
251
|
+
echo " 1. Environment: export TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi-..."
|
|
252
|
+
echo " 2. Config file: ~/.pypirc"
|
|
253
|
+
exit 1
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
if twine upload dist/*; then
|
|
257
|
+
print_success "Package published to PyPI!"
|
|
258
|
+
echo "🔗 View at: https://pypi.org/project/xplainable-preprocessing/$NEW_VERSION/"
|
|
259
|
+
else
|
|
260
|
+
print_error "PyPI upload failed"
|
|
261
|
+
exit 1
|
|
262
|
+
fi
|
|
263
|
+
fi
|
|
264
|
+
|
|
265
|
+
# Create GitHub release if gh CLI is available
|
|
266
|
+
if command -v gh &> /dev/null; then
|
|
267
|
+
if gh auth status &> /dev/null; then
|
|
268
|
+
print_success "Creating GitHub release..."
|
|
269
|
+
|
|
270
|
+
gh release create "$TAG" \
|
|
271
|
+
--title "v$NEW_VERSION" \
|
|
272
|
+
--notes "## xplainable-preprocessing v$NEW_VERSION — $RELEASE_DATE
|
|
273
|
+
|
|
274
|
+
### Installation
|
|
275
|
+
|
|
276
|
+
\`\`\`bash
|
|
277
|
+
pip install xplainable-preprocessing==$NEW_VERSION
|
|
278
|
+
\`\`\`" \
|
|
279
|
+
--latest
|
|
280
|
+
|
|
281
|
+
print_success "GitHub release created!"
|
|
282
|
+
else
|
|
283
|
+
print_warning "GitHub CLI not authenticated, skipping release creation"
|
|
284
|
+
fi
|
|
285
|
+
else
|
|
286
|
+
print_warning "GitHub CLI not found, skipping release creation"
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
print_header "🎉 RELEASE COMPLETE!"
|
|
290
|
+
echo "Version: $NEW_VERSION"
|
|
291
|
+
echo "Tag: $TAG"
|
|
292
|
+
echo "PyPI: https://pypi.org/project/xplainable-preprocessing/$NEW_VERSION/"
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Pydantic models for pipeline specification with mutation support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StepSpec(BaseModel):
|
|
11
|
+
"""A single preprocessing step in the pipeline.
|
|
12
|
+
|
|
13
|
+
The ``outputs``, ``modifies``, and ``drops`` fields form the step's
|
|
14
|
+
**column contract**. They are populated after the first fit via
|
|
15
|
+
``PipelineSpec.enrich_from_deltas()`` and enable cascade analysis
|
|
16
|
+
for step mutations (remove, reorder) without recompiling the pipeline.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
type: str
|
|
21
|
+
columns: Optional[List[str]] = None
|
|
22
|
+
params: Dict = {}
|
|
23
|
+
description: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
# Column contract — populated after first fit via enrich_from_deltas()
|
|
26
|
+
outputs: Optional[List[str]] = None # columns this step CREATES
|
|
27
|
+
modifies: Optional[List[str]] = None # columns this step CHANGES in place
|
|
28
|
+
drops: Optional[List[str]] = None # columns this step REMOVES
|
|
29
|
+
|
|
30
|
+
@field_validator("id")
|
|
31
|
+
@classmethod
|
|
32
|
+
def id_must_be_non_empty(cls, v: str) -> str:
|
|
33
|
+
if not v.strip():
|
|
34
|
+
raise ValueError("Step id must be non-empty")
|
|
35
|
+
return v
|
|
36
|
+
|
|
37
|
+
@field_validator("type")
|
|
38
|
+
@classmethod
|
|
39
|
+
def type_must_be_non_empty(cls, v: str) -> str:
|
|
40
|
+
if not v.strip():
|
|
41
|
+
raise ValueError("Step type must be non-empty")
|
|
42
|
+
return v
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RemovalAnalysis(BaseModel):
|
|
46
|
+
"""Result of analyzing what happens when a step is removed."""
|
|
47
|
+
|
|
48
|
+
step_id: str
|
|
49
|
+
cascade_remove: List[str] = [] # step IDs that must also be removed
|
|
50
|
+
columns_lost: List[str] = [] # columns that would disappear
|
|
51
|
+
columns_restored: List[str] = [] # columns that were dropped and would reappear
|
|
52
|
+
safe: bool = True # True if no downstream steps are affected
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PipelineSpec(BaseModel):
|
|
56
|
+
"""Full pipeline specification containing ordered steps.
|
|
57
|
+
|
|
58
|
+
Supports mutation methods (``remove_step``, ``insert_step``,
|
|
59
|
+
``reorder_step``) that return new ``PipelineSpec`` instances.
|
|
60
|
+
Cascade analysis via ``analyze_removal`` works from the column
|
|
61
|
+
contract without needing to recompile the pipeline.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
version: str = "2.0"
|
|
65
|
+
steps: List[StepSpec] = []
|
|
66
|
+
|
|
67
|
+
@field_validator("steps")
|
|
68
|
+
@classmethod
|
|
69
|
+
def step_ids_must_be_unique(cls, v: List[StepSpec]) -> List[StepSpec]:
|
|
70
|
+
ids = [step.id for step in v]
|
|
71
|
+
if len(ids) != len(set(ids)):
|
|
72
|
+
duplicates = [id_ for id_ in ids if ids.count(id_) > 1]
|
|
73
|
+
raise ValueError(f"Duplicate step ids: {set(duplicates)}")
|
|
74
|
+
return v
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Helpers
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def get_step(self, step_id: str) -> Optional[StepSpec]:
|
|
81
|
+
"""Return a step by ID, or None."""
|
|
82
|
+
for step in self.steps:
|
|
83
|
+
if step.id == step_id:
|
|
84
|
+
return step
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def step_index(self, step_id: str) -> int:
|
|
88
|
+
"""Return the index of a step by ID. Raises ValueError if not found."""
|
|
89
|
+
for i, step in enumerate(self.steps):
|
|
90
|
+
if step.id == step_id:
|
|
91
|
+
return i
|
|
92
|
+
raise ValueError(f"Step '{step_id}' not found in spec")
|
|
93
|
+
|
|
94
|
+
def steps_after(self, step_id: str) -> List[StepSpec]:
|
|
95
|
+
"""Return all steps that come after the given step."""
|
|
96
|
+
idx = self.step_index(step_id)
|
|
97
|
+
return self.steps[idx + 1:]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_enriched(self) -> bool:
|
|
101
|
+
"""True if column contracts have been populated on all steps."""
|
|
102
|
+
return all(
|
|
103
|
+
step.outputs is not None
|
|
104
|
+
for step in self.steps
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Enrichment — write column contracts from deltas
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def enrich_from_deltas(self, step_deltas: list[dict]) -> "PipelineSpec":
|
|
112
|
+
"""Return a new spec with column contracts populated from step deltas.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
step_deltas : list[dict]
|
|
117
|
+
Output of ``preview.compute_step_deltas()``. Each entry has
|
|
118
|
+
``step_id`` and ``delta: {added, updated, dropped}``.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
PipelineSpec
|
|
123
|
+
A new spec instance with ``outputs``/``modifies``/``drops``
|
|
124
|
+
populated on each step.
|
|
125
|
+
"""
|
|
126
|
+
delta_map = {d["step_id"]: d for d in step_deltas}
|
|
127
|
+
enriched_steps = []
|
|
128
|
+
for step in self.steps:
|
|
129
|
+
entry = delta_map.get(step.id, {})
|
|
130
|
+
delta = entry.get("delta", {})
|
|
131
|
+
updates = {
|
|
132
|
+
"outputs": delta.get("added", []),
|
|
133
|
+
"modifies": delta.get("updated", []),
|
|
134
|
+
"drops": delta.get("dropped", []),
|
|
135
|
+
}
|
|
136
|
+
# columns (inputs) is left as-is — only the LLM-declared value
|
|
137
|
+
# is used for dependency resolution. Outputs/modifies/drops are
|
|
138
|
+
# sufficient for cascade analysis.
|
|
139
|
+
enriched_steps.append(step.model_copy(update=updates))
|
|
140
|
+
return PipelineSpec(version=self.version, steps=enriched_steps)
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
# Cascade analysis
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def analyze_removal(self, step_id: str) -> RemovalAnalysis:
|
|
147
|
+
"""Analyze what happens if a step is removed.
|
|
148
|
+
|
|
149
|
+
Uses the column contract (``outputs``/``drops``) to determine
|
|
150
|
+
which downstream steps would lose their input columns and must
|
|
151
|
+
also be removed (cascade).
|
|
152
|
+
|
|
153
|
+
Works without recompiling the pipeline — purely from the spec
|
|
154
|
+
metadata. Requires the spec to be enriched first.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
step_id : str
|
|
159
|
+
The ID of the step to analyze removing.
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
RemovalAnalysis
|
|
164
|
+
"""
|
|
165
|
+
step = self.get_step(step_id)
|
|
166
|
+
if step is None:
|
|
167
|
+
raise ValueError(f"Step '{step_id}' not found")
|
|
168
|
+
|
|
169
|
+
# Columns that would disappear if this step is removed
|
|
170
|
+
lost_columns = set(step.outputs or [])
|
|
171
|
+
|
|
172
|
+
# Walk downstream and find cascade
|
|
173
|
+
cascade = []
|
|
174
|
+
for other in self.steps_after(step_id):
|
|
175
|
+
inputs = set(other.columns or [])
|
|
176
|
+
if inputs & lost_columns:
|
|
177
|
+
cascade.append(other.id)
|
|
178
|
+
# This step's outputs are also lost in the cascade
|
|
179
|
+
lost_columns.update(other.outputs or [])
|
|
180
|
+
|
|
181
|
+
# Columns that this step drops would be restored
|
|
182
|
+
restored = list(step.drops or [])
|
|
183
|
+
|
|
184
|
+
return RemovalAnalysis(
|
|
185
|
+
step_id=step_id,
|
|
186
|
+
cascade_remove=cascade,
|
|
187
|
+
columns_lost=sorted(lost_columns),
|
|
188
|
+
columns_restored=sorted(restored),
|
|
189
|
+
safe=len(cascade) == 0,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# Mutations — all return NEW PipelineSpec instances (immutable pattern)
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def remove_step(self, step_id: str, cascade: bool = True) -> "PipelineSpec":
|
|
197
|
+
"""Return a new spec with the step (and optionally its cascade) removed.
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----------
|
|
201
|
+
step_id : str
|
|
202
|
+
The step to remove.
|
|
203
|
+
cascade : bool
|
|
204
|
+
If True, also remove downstream steps that depend on columns
|
|
205
|
+
created by this step. If False, only remove this single step
|
|
206
|
+
(caller is responsible for ensuring validity).
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
PipelineSpec
|
|
211
|
+
"""
|
|
212
|
+
analysis = self.analyze_removal(step_id)
|
|
213
|
+
remove_ids = {step_id}
|
|
214
|
+
if cascade:
|
|
215
|
+
remove_ids.update(analysis.cascade_remove)
|
|
216
|
+
return PipelineSpec(
|
|
217
|
+
version=self.version,
|
|
218
|
+
steps=[s for s in self.steps if s.id not in remove_ids],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def insert_step(
|
|
222
|
+
self,
|
|
223
|
+
step: StepSpec,
|
|
224
|
+
after: Optional[str] = None,
|
|
225
|
+
) -> "PipelineSpec":
|
|
226
|
+
"""Return a new spec with a step inserted.
|
|
227
|
+
|
|
228
|
+
Parameters
|
|
229
|
+
----------
|
|
230
|
+
step : StepSpec
|
|
231
|
+
The step to insert.
|
|
232
|
+
after : str or None
|
|
233
|
+
Insert after this step ID. If None, append to the end.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
PipelineSpec
|
|
238
|
+
"""
|
|
239
|
+
new_steps = list(self.steps)
|
|
240
|
+
if after is None:
|
|
241
|
+
new_steps.append(step)
|
|
242
|
+
else:
|
|
243
|
+
idx = self.step_index(after)
|
|
244
|
+
new_steps.insert(idx + 1, step)
|
|
245
|
+
return PipelineSpec(version=self.version, steps=new_steps)
|
|
246
|
+
|
|
247
|
+
def reorder_step(self, step_id: str, new_index: int) -> "PipelineSpec":
|
|
248
|
+
"""Return a new spec with a step moved to a new position.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
step_id : str
|
|
253
|
+
The step to move.
|
|
254
|
+
new_index : int
|
|
255
|
+
The target index (0-based).
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
PipelineSpec
|
|
260
|
+
"""
|
|
261
|
+
idx = self.step_index(step_id)
|
|
262
|
+
step = self.steps[idx]
|
|
263
|
+
remaining = [s for s in self.steps if s.id != step_id]
|
|
264
|
+
clamped = max(0, min(new_index, len(remaining)))
|
|
265
|
+
remaining.insert(clamped, step)
|
|
266
|
+
return PipelineSpec(version=self.version, steps=remaining)
|
|
267
|
+
|
|
268
|
+
def update_step_params(self, step_id: str, params: Dict) -> "PipelineSpec":
|
|
269
|
+
"""Return a new spec with a step's params updated.
|
|
270
|
+
|
|
271
|
+
Parameters
|
|
272
|
+
----------
|
|
273
|
+
step_id : str
|
|
274
|
+
The step to update.
|
|
275
|
+
params : dict
|
|
276
|
+
New params to merge into the step's existing params.
|
|
277
|
+
|
|
278
|
+
Returns
|
|
279
|
+
-------
|
|
280
|
+
PipelineSpec
|
|
281
|
+
"""
|
|
282
|
+
new_steps = []
|
|
283
|
+
for step in self.steps:
|
|
284
|
+
if step.id == step_id:
|
|
285
|
+
merged = {**step.params, **params}
|
|
286
|
+
new_steps.append(step.model_copy(update={"params": merged}))
|
|
287
|
+
else:
|
|
288
|
+
new_steps.append(step)
|
|
289
|
+
return PipelineSpec(version=self.version, steps=new_steps)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def optimize(self) -> "PipelineSpec":
|
|
293
|
+
"""Return a new spec with steps in optimal execution order.
|
|
294
|
+
|
|
295
|
+
Topologically sorts steps so that any step producing a column
|
|
296
|
+
comes before steps that consume it. Independent steps retain
|
|
297
|
+
their relative order.
|
|
298
|
+
|
|
299
|
+
Requires column contracts (``outputs``) and declared ``columns``
|
|
300
|
+
(inputs) on each step. If the spec is not enriched, returns
|
|
301
|
+
a copy with the original order.
|
|
302
|
+
"""
|
|
303
|
+
if not self.is_enriched:
|
|
304
|
+
return self.model_copy()
|
|
305
|
+
|
|
306
|
+
from collections import defaultdict
|
|
307
|
+
|
|
308
|
+
step_map = {s.id: s for s in self.steps}
|
|
309
|
+
ids = [s.id for s in self.steps]
|
|
310
|
+
|
|
311
|
+
# Build producer map: column → step that creates it
|
|
312
|
+
producers: dict[str, str] = {}
|
|
313
|
+
for s in self.steps:
|
|
314
|
+
for col in s.outputs or []:
|
|
315
|
+
if col not in producers:
|
|
316
|
+
producers[col] = s.id
|
|
317
|
+
|
|
318
|
+
# Build adjacency from declared inputs
|
|
319
|
+
adj: dict[str, set[str]] = defaultdict(set)
|
|
320
|
+
in_deg: dict[str, int] = {sid: 0 for sid in ids}
|
|
321
|
+
for s in self.steps:
|
|
322
|
+
for col in s.columns or []:
|
|
323
|
+
prod = producers.get(col)
|
|
324
|
+
if prod and prod != s.id and prod in step_map:
|
|
325
|
+
if prod not in adj or s.id not in adj[prod]:
|
|
326
|
+
adj[prod].add(s.id)
|
|
327
|
+
in_deg[s.id] += 1
|
|
328
|
+
|
|
329
|
+
# Kahn's algorithm — stable (preserves original order within tiers)
|
|
330
|
+
ordered: list[str] = []
|
|
331
|
+
remaining = set(ids)
|
|
332
|
+
while remaining:
|
|
333
|
+
ready = [sid for sid in ids if sid in remaining and in_deg.get(sid, 0) == 0]
|
|
334
|
+
if not ready:
|
|
335
|
+
ordered.extend(sorted(remaining))
|
|
336
|
+
break
|
|
337
|
+
ordered.extend(ready)
|
|
338
|
+
for sid in ready:
|
|
339
|
+
remaining.discard(sid)
|
|
340
|
+
for nb in adj.get(sid, set()):
|
|
341
|
+
in_deg[nb] = max(in_deg.get(nb, 1) - 1, 0)
|
|
342
|
+
|
|
343
|
+
return PipelineSpec(
|
|
344
|
+
version=self.version,
|
|
345
|
+
steps=[step_map[sid] for sid in ordered if sid in step_map],
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def validate_spec(spec: PipelineSpec) -> None:
|
|
350
|
+
"""Validate a PipelineSpec. Raises ValueError if invalid.
|
|
351
|
+
|
|
352
|
+
Checks beyond Pydantic validation:
|
|
353
|
+
- All non-custom types must exist in the registry
|
|
354
|
+
- Custom steps must have 'code' and 'class_name' in params
|
|
355
|
+
"""
|
|
356
|
+
from xplainable_preprocessing.registry import REGISTRY
|
|
357
|
+
|
|
358
|
+
for step in spec.steps:
|
|
359
|
+
if step.type == "custom":
|
|
360
|
+
if "code" not in step.params:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
f"Step '{step.id}': custom type requires 'code' in params"
|
|
363
|
+
)
|
|
364
|
+
if "class_name" not in step.params:
|
|
365
|
+
raise ValueError(
|
|
366
|
+
f"Step '{step.id}': custom type requires 'class_name' in params"
|
|
367
|
+
)
|
|
368
|
+
elif step.type not in REGISTRY:
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"Step '{step.id}': unknown type '{step.type}'. "
|
|
371
|
+
f"Available: {sorted(REGISTRY.keys())}"
|
|
372
|
+
)
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
"""Pydantic models for pipeline specification."""
|
|
2
|
-
|
|
3
|
-
from typing import Dict, List, Optional
|
|
4
|
-
|
|
5
|
-
from pydantic import BaseModel, field_validator
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class StepSpec(BaseModel):
|
|
9
|
-
"""A single preprocessing step in the pipeline."""
|
|
10
|
-
|
|
11
|
-
id: str
|
|
12
|
-
type: str
|
|
13
|
-
columns: Optional[List[str]] = None
|
|
14
|
-
params: Dict = {}
|
|
15
|
-
description: Optional[str] = None
|
|
16
|
-
|
|
17
|
-
@field_validator("id")
|
|
18
|
-
@classmethod
|
|
19
|
-
def id_must_be_non_empty(cls, v: str) -> str:
|
|
20
|
-
if not v.strip():
|
|
21
|
-
raise ValueError("Step id must be non-empty")
|
|
22
|
-
return v
|
|
23
|
-
|
|
24
|
-
@field_validator("type")
|
|
25
|
-
@classmethod
|
|
26
|
-
def type_must_be_non_empty(cls, v: str) -> str:
|
|
27
|
-
if not v.strip():
|
|
28
|
-
raise ValueError("Step type must be non-empty")
|
|
29
|
-
return v
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class PipelineSpec(BaseModel):
|
|
33
|
-
"""Full pipeline specification containing ordered steps."""
|
|
34
|
-
|
|
35
|
-
version: str = "2.0"
|
|
36
|
-
steps: List[StepSpec] = []
|
|
37
|
-
|
|
38
|
-
@field_validator("steps")
|
|
39
|
-
@classmethod
|
|
40
|
-
def step_ids_must_be_unique(cls, v: List[StepSpec]) -> List[StepSpec]:
|
|
41
|
-
ids = [step.id for step in v]
|
|
42
|
-
if len(ids) != len(set(ids)):
|
|
43
|
-
duplicates = [id_ for id_ in ids if ids.count(id_) > 1]
|
|
44
|
-
raise ValueError(f"Duplicate step ids: {set(duplicates)}")
|
|
45
|
-
return v
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def validate_spec(spec: PipelineSpec) -> None:
|
|
49
|
-
"""Validate a PipelineSpec. Raises ValueError if invalid.
|
|
50
|
-
|
|
51
|
-
Checks beyond Pydantic validation:
|
|
52
|
-
- All non-custom types must exist in the registry
|
|
53
|
-
- Custom steps must have 'code' and 'class_name' in params
|
|
54
|
-
"""
|
|
55
|
-
from xplainable_preprocessing.registry import REGISTRY
|
|
56
|
-
|
|
57
|
-
for step in spec.steps:
|
|
58
|
-
if step.type == "custom":
|
|
59
|
-
if "code" not in step.params:
|
|
60
|
-
raise ValueError(
|
|
61
|
-
f"Step '{step.id}': custom type requires 'code' in params"
|
|
62
|
-
)
|
|
63
|
-
if "class_name" not in step.params:
|
|
64
|
-
raise ValueError(
|
|
65
|
-
f"Step '{step.id}': custom type requires 'class_name' in params"
|
|
66
|
-
)
|
|
67
|
-
elif step.type not in REGISTRY:
|
|
68
|
-
raise ValueError(
|
|
69
|
-
f"Step '{step.id}': unknown type '{step.type}'. "
|
|
70
|
-
f"Available: {sorted(REGISTRY.keys())}"
|
|
71
|
-
)
|
{xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/docs/dag-pipeline-proposal.md
RENAMED
|
File without changes
|
|
File without changes
|
{xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/docs/feature-store-proposal.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|