mangleframes 0.1.6__tar.gz → 0.1.7__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.
Files changed (27) hide show
  1. {mangleframes-0.1.6 → mangleframes-0.1.7}/Cargo.lock +3 -0
  2. {mangleframes-0.1.6 → mangleframes-0.1.7}/PKG-INFO +2 -1
  3. {mangleframes-0.1.6 → mangleframes-0.1.7}/pyproject.toml +2 -1
  4. {mangleframes-0.1.6 → mangleframes-0.1.7}/python/mangleframes/__init__.py +1 -1
  5. {mangleframes-0.1.6 → mangleframes-0.1.7}/python/mangleframes/protocol.py +180 -0
  6. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/Cargo.toml +1 -0
  7. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/history_analysis.rs +145 -0
  8. mangleframes-0.1.7/viewer/src/history_handlers.rs +288 -0
  9. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/socket_client.rs +25 -0
  10. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/static/app.js +294 -79
  11. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/static/index.html +29 -4
  12. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/static/style.css +216 -14
  13. mangleframes-0.1.6/viewer/src/history_handlers.rs +0 -148
  14. {mangleframes-0.1.6 → mangleframes-0.1.7}/Cargo.toml +0 -0
  15. {mangleframes-0.1.6 → mangleframes-0.1.7}/python/mangleframes/launcher.py +0 -0
  16. {mangleframes-0.1.6 → mangleframes-0.1.7}/python/mangleframes/server.py +0 -0
  17. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/arrow_reader.rs +0 -0
  18. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/export.rs +0 -0
  19. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/handlers.rs +0 -0
  20. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/join_analysis.rs +0 -0
  21. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/join_handlers.rs +0 -0
  22. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/main.rs +0 -0
  23. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/perf.rs +0 -0
  24. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/query_engine.rs +0 -0
  25. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/stats.rs +0 -0
  26. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/web_server.rs +0 -0
  27. {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/websocket.rs +0 -0
@@ -604,7 +604,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
604
604
  checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
605
605
  dependencies = [
606
606
  "iana-time-zone",
607
+ "js-sys",
607
608
  "num-traits",
609
+ "wasm-bindgen",
608
610
  "windows-link",
609
611
  ]
610
612
 
@@ -1955,6 +1957,7 @@ dependencies = [
1955
1957
  "arrow-ipc",
1956
1958
  "arrow-json",
1957
1959
  "axum",
1960
+ "chrono",
1958
1961
  "clap",
1959
1962
  "datafusion",
1960
1963
  "futures-util",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mangleframes
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -8,6 +8,7 @@ Requires-Dist: databricks-connect>=16.1.7
8
8
  Requires-Dist: loguru>=0.7.3
9
9
  Requires-Dist: maturin>=1.11.2
10
10
  Requires-Dist: pyarrow>=11.0.0
11
+ Requires-Dist: python-dateutil>=2.8.0
11
12
  Requires-Dist: pytest>=7.0 ; extra == 'dev'
12
13
  Requires-Dist: maturin>=1.4 ; extra == 'dev'
13
14
  Requires-Dist: pyspark>=3.4.0 ; extra == 'spark'
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "mangleframes"
7
- version = "0.1.6"
7
+ version = "0.1.7"
8
8
  description = "PySpark DataFrame viewer with modern web UI"
9
9
  requires-python = ">=3.11"
10
10
  license = { text = "MIT" }
@@ -18,6 +18,7 @@ dependencies = [
18
18
  "loguru>=0.7.3",
19
19
  "maturin>=1.11.2",
20
20
  "pyarrow>=11.0.0",
21
+ "python-dateutil>=2.8.0",
21
22
  ]
22
23
 
23
24
  [project.optional-dependencies]
@@ -16,7 +16,7 @@ from .server import DataFrameServer
16
16
  if TYPE_CHECKING:
17
17
  from pyspark.sql import DataFrame
18
18
 
19
- __version__ = "0.1.6"
19
+ __version__ = "0.1.7"
20
20
  __all__ = ["register", "unregister", "show", "cleanup"]
21
21
 
22
22
  _registry: dict[str, DataFrame] = {}
@@ -298,6 +298,172 @@ def handle_join_temporal(
298
298
  })
299
299
 
300
300
 
301
+ def _generate_expected_buckets(min_date: str, max_date: str, granularity: str) -> list[str]:
302
+ """Generate all expected bucket keys between min and max dates."""
303
+ from datetime import datetime, timedelta
304
+ from dateutil.relativedelta import relativedelta
305
+
306
+ start = datetime.strptime(min_date[:10], "%Y-%m-%d")
307
+ end = datetime.strptime(max_date[:10], "%Y-%m-%d")
308
+ buckets = []
309
+
310
+ if granularity == "day":
311
+ current = start
312
+ while current <= end:
313
+ buckets.append(current.strftime("%Y-%m-%d"))
314
+ current += timedelta(days=1)
315
+ elif granularity == "week":
316
+ current = start - timedelta(days=start.weekday())
317
+ end_aligned = end - timedelta(days=end.weekday())
318
+ while current <= end_aligned:
319
+ buckets.append(current.strftime("%Y-%m-%d"))
320
+ current += timedelta(weeks=1)
321
+ elif granularity == "month":
322
+ current = start.replace(day=1)
323
+ end_aligned = end.replace(day=1)
324
+ while current <= end_aligned:
325
+ buckets.append(current.strftime("%Y-%m-%d"))
326
+ current += relativedelta(months=1)
327
+
328
+ return buckets
329
+
330
+
331
+ def _find_gaps(expected: list[str], actual: set[str]) -> list[dict]:
332
+ """Find contiguous ranges of missing buckets."""
333
+ gaps = []
334
+ gap_start = None
335
+ gap_count = 0
336
+
337
+ for bucket in expected:
338
+ if bucket not in actual:
339
+ if gap_start is None:
340
+ gap_start = bucket
341
+ gap_count = 1
342
+ else:
343
+ gap_count += 1
344
+ else:
345
+ if gap_start is not None:
346
+ gaps.append({
347
+ "start": gap_start,
348
+ "end": expected[expected.index(gap_start) + gap_count - 1],
349
+ "periods": gap_count
350
+ })
351
+ gap_start = None
352
+ gap_count = 0
353
+
354
+ if gap_start is not None:
355
+ gaps.append({
356
+ "start": gap_start,
357
+ "end": expected[expected.index(gap_start) + gap_count - 1],
358
+ "periods": gap_count
359
+ })
360
+
361
+ return gaps
362
+
363
+
364
+ def handle_temporal_range(
365
+ registry: dict[str, DataFrame], name: str, column: str, granularity: str
366
+ ) -> bytes:
367
+ """Return detailed temporal range statistics for a frame."""
368
+ if name not in registry:
369
+ return encode_error(f"DataFrame '{name}' not found")
370
+
371
+ df = registry[name]
372
+ if column not in [f.name for f in df.schema.fields]:
373
+ return encode_error(f"Column '{column}' not found in '{name}'")
374
+
375
+ field = next(f for f in df.schema.fields if f.name == column)
376
+ if not _is_temporal_type(str(field.dataType)):
377
+ return encode_error(f"Column '{column}' is not a temporal type")
378
+
379
+ if granularity not in ("day", "week", "month"):
380
+ return encode_error(f"Invalid granularity: {granularity}")
381
+
382
+ start_time = time.perf_counter()
383
+
384
+ agg_result = df.agg(
385
+ F.min(column).alias("min_date"),
386
+ F.max(column).alias("max_date"),
387
+ F.count(F.lit(1)).alias("total_rows"),
388
+ F.sum(F.when(F.col(column).isNull(), 1).otherwise(0)).alias("null_dates"),
389
+ F.countDistinct(column).alias("distinct_dates")
390
+ ).collect()[0]
391
+
392
+ min_date = agg_result["min_date"]
393
+ max_date = agg_result["max_date"]
394
+ total_rows = agg_result["total_rows"]
395
+ null_dates = agg_result["null_dates"] or 0
396
+ distinct_dates = agg_result["distinct_dates"]
397
+
398
+ internal_gaps = []
399
+ if min_date and max_date:
400
+ truncated = df.withColumn("__bucket", F.date_trunc(granularity, F.col(column)))
401
+ bucket_rows = truncated.select("__bucket").distinct().collect()
402
+ actual_buckets = {
403
+ str(row["__bucket"])[:10] for row in bucket_rows if row["__bucket"]
404
+ }
405
+ expected = _generate_expected_buckets(str(min_date), str(max_date), granularity)
406
+ internal_gaps = _find_gaps(expected, actual_buckets)
407
+
408
+ compute_ms = int((time.perf_counter() - start_time) * 1000)
409
+
410
+ return encode_json_response({
411
+ "frame": name,
412
+ "column": column,
413
+ "granularity": granularity,
414
+ "min_date": str(min_date)[:10] if min_date else None,
415
+ "max_date": str(max_date)[:10] if max_date else None,
416
+ "total_rows": total_rows,
417
+ "null_dates": null_dates,
418
+ "distinct_dates": distinct_dates,
419
+ "internal_gaps": internal_gaps,
420
+ "compute_ms": compute_ms
421
+ })
422
+
423
+
424
+ def handle_temporal_loss(
425
+ registry: dict[str, DataFrame],
426
+ name: str, column: str,
427
+ overlap_start: str, overlap_end: str
428
+ ) -> bytes:
429
+ """Return row counts outside the overlap zone."""
430
+ if name not in registry:
431
+ return encode_error(f"DataFrame '{name}' not found")
432
+
433
+ df = registry[name]
434
+ if column not in [f.name for f in df.schema.fields]:
435
+ return encode_error(f"Column '{column}' not found in '{name}'")
436
+
437
+ field = next(f for f in df.schema.fields if f.name == column)
438
+ if not _is_temporal_type(str(field.dataType)):
439
+ return encode_error(f"Column '{column}' is not a temporal type")
440
+
441
+ start_time = time.perf_counter()
442
+
443
+ result = df.agg(
444
+ F.count(F.lit(1)).alias("total"),
445
+ F.sum(F.when(F.col(column) < overlap_start, 1).otherwise(0)).alias("before"),
446
+ F.sum(F.when(F.col(column) > overlap_end, 1).otherwise(0)).alias("after")
447
+ ).collect()[0]
448
+
449
+ total = result["total"]
450
+ rows_before = result["before"] or 0
451
+ rows_after = result["after"] or 0
452
+ total_lost = rows_before + rows_after
453
+ pct_lost = (total_lost / total * 100) if total > 0 else 0.0
454
+
455
+ compute_ms = int((time.perf_counter() - start_time) * 1000)
456
+
457
+ return encode_json_response({
458
+ "frame": name,
459
+ "rows_before": rows_before,
460
+ "rows_after": rows_after,
461
+ "total_lost": total_lost,
462
+ "pct_lost": round(pct_lost, 2),
463
+ "compute_ms": compute_ms
464
+ })
465
+
466
+
301
467
  def handle_join_overlap(
302
468
  registry: dict[str, DataFrame],
303
469
  frame1: str, frame2: str,
@@ -418,4 +584,18 @@ def dispatch_command(
418
584
  return encode_error("At least one column required per frame")
419
585
  return handle_join_overlap(registry, frame1, frame2, cols1, cols2)
420
586
 
587
+ if command.startswith("TEMPORAL_RANGE:"):
588
+ parts = command[15:].split(":")
589
+ if len(parts) != 3:
590
+ return encode_error("Invalid TEMPORAL_RANGE format. Use TEMPORAL_RANGE:name:column:granularity")
591
+ name, column, granularity = parts
592
+ return handle_temporal_range(registry, name, column, granularity)
593
+
594
+ if command.startswith("TEMPORAL_LOSS:"):
595
+ parts = command[14:].split(":")
596
+ if len(parts) != 4:
597
+ return encode_error("Invalid TEMPORAL_LOSS format. Use TEMPORAL_LOSS:name:column:start:end")
598
+ name, column, overlap_start, overlap_end = parts
599
+ return handle_temporal_loss(registry, name, column, overlap_start, overlap_end)
600
+
421
601
  return encode_error(f"Unknown command: {command}")
@@ -27,6 +27,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
27
27
  thiserror = "2"
28
28
  anyhow = "1"
29
29
  futures-util = "0.3"
30
+ chrono = "0.4"
30
31
 
31
32
  [profile.release]
32
33
  lto = true
@@ -61,6 +61,46 @@ pub struct JoinPrediction {
61
61
  pub coverage_pct: f64,
62
62
  }
63
63
 
64
+ #[derive(Debug, Clone, Serialize, Deserialize)]
65
+ pub struct DateGap {
66
+ pub start: String,
67
+ pub end: String,
68
+ pub periods: usize,
69
+ }
70
+
71
+ #[derive(Debug, Clone, Serialize, Deserialize)]
72
+ pub struct TemporalRangeStats {
73
+ pub frame: String,
74
+ pub column: String,
75
+ pub granularity: String,
76
+ pub min_date: Option<String>,
77
+ pub max_date: Option<String>,
78
+ pub total_rows: usize,
79
+ pub null_dates: usize,
80
+ pub distinct_dates: usize,
81
+ pub internal_gaps: Vec<DateGap>,
82
+ }
83
+
84
+ #[derive(Debug, Clone, Serialize, Deserialize)]
85
+ pub struct OverlapZone {
86
+ pub start: String,
87
+ pub end: String,
88
+ pub span: String,
89
+ pub days: i64,
90
+ pub valid: bool,
91
+ }
92
+
93
+ #[derive(Debug, Clone, Serialize, Deserialize)]
94
+ pub struct FrameDataLoss {
95
+ pub frame: String,
96
+ pub rows_before_overlap: usize,
97
+ pub rows_after_overlap: usize,
98
+ pub total_lost: usize,
99
+ pub pct_lost: f64,
100
+ pub range_lost_before: Option<String>,
101
+ pub range_lost_after: Option<String>,
102
+ }
103
+
64
104
  #[derive(Debug, Clone, Serialize, Deserialize)]
65
105
  pub struct CoverageResult {
66
106
  pub frames: Vec<String>,
@@ -69,12 +109,17 @@ pub struct CoverageResult {
69
109
  pub pairwise_overlaps: Vec<PairwiseOverlap>,
70
110
  pub timeline: Vec<TimelineBucket>,
71
111
  pub predictions: Vec<JoinPrediction>,
112
+ pub temporal_ranges: Vec<TemporalRangeStats>,
113
+ pub overlap_zone: Option<OverlapZone>,
114
+ pub data_loss: Vec<FrameDataLoss>,
72
115
  }
73
116
 
74
117
  pub struct HistoryAnalyzer {
75
118
  key_stats: HashMap<String, FrameKeyStats>,
76
119
  temporal_stats: HashMap<String, FrameTemporalStats>,
77
120
  overlaps: Vec<PairwiseOverlap>,
121
+ temporal_ranges: HashMap<String, TemporalRangeStats>,
122
+ data_loss: HashMap<String, FrameDataLoss>,
78
123
  }
79
124
 
80
125
  impl HistoryAnalyzer {
@@ -83,6 +128,8 @@ impl HistoryAnalyzer {
83
128
  key_stats: HashMap::new(),
84
129
  temporal_stats: HashMap::new(),
85
130
  overlaps: Vec::new(),
131
+ temporal_ranges: HashMap::new(),
132
+ data_loss: HashMap::new(),
86
133
  }
87
134
  }
88
135
 
@@ -98,6 +145,14 @@ impl HistoryAnalyzer {
98
145
  self.overlaps.push(overlap);
99
146
  }
100
147
 
148
+ pub fn add_temporal_range(&mut self, stats: TemporalRangeStats) {
149
+ self.temporal_ranges.insert(stats.frame.clone(), stats);
150
+ }
151
+
152
+ pub fn add_data_loss(&mut self, loss: FrameDataLoss) {
153
+ self.data_loss.insert(loss.frame.clone(), loss);
154
+ }
155
+
101
156
  pub fn compute_coverage(&self, frames: &[String]) -> Result<CoverageResult, HistoryError> {
102
157
  if frames.is_empty() {
103
158
  return Err(HistoryError::NoFrames);
@@ -123,6 +178,18 @@ impl HistoryAnalyzer {
123
178
  let timeline = self.build_timeline(frames);
124
179
  let predictions = self.compute_predictions(frames, &pairwise_overlaps);
125
180
 
181
+ let temporal_ranges: Vec<TemporalRangeStats> = frames
182
+ .iter()
183
+ .filter_map(|f| self.temporal_ranges.get(f).cloned())
184
+ .collect();
185
+
186
+ let overlap_zone = self.compute_overlap_zone(&temporal_ranges);
187
+
188
+ let data_loss: Vec<FrameDataLoss> = frames
189
+ .iter()
190
+ .filter_map(|f| self.data_loss.get(f).cloned())
191
+ .collect();
192
+
126
193
  Ok(CoverageResult {
127
194
  frames: frames.to_vec(),
128
195
  key_stats,
@@ -130,9 +197,87 @@ impl HistoryAnalyzer {
130
197
  pairwise_overlaps,
131
198
  timeline,
132
199
  predictions,
200
+ temporal_ranges,
201
+ overlap_zone,
202
+ data_loss,
133
203
  })
134
204
  }
135
205
 
206
+ fn compute_overlap_zone(&self, ranges: &[TemporalRangeStats]) -> Option<OverlapZone> {
207
+ if ranges.is_empty() {
208
+ return None;
209
+ }
210
+
211
+ let min_dates: Vec<&str> = ranges
212
+ .iter()
213
+ .filter_map(|r| r.min_date.as_deref())
214
+ .collect();
215
+
216
+ let max_dates: Vec<&str> = ranges
217
+ .iter()
218
+ .filter_map(|r| r.max_date.as_deref())
219
+ .collect();
220
+
221
+ if min_dates.is_empty() || max_dates.is_empty() {
222
+ return None;
223
+ }
224
+
225
+ let overlap_start = min_dates.iter().max()?.to_string();
226
+ let overlap_end = max_dates.iter().min()?.to_string();
227
+
228
+ if overlap_start > overlap_end {
229
+ return Some(OverlapZone {
230
+ start: overlap_start,
231
+ end: overlap_end,
232
+ span: "No overlap".to_string(),
233
+ days: 0,
234
+ valid: false,
235
+ });
236
+ }
237
+
238
+ let days = Self::compute_days_between(&overlap_start, &overlap_end);
239
+ let span = Self::format_date_span(days);
240
+
241
+ Some(OverlapZone {
242
+ start: overlap_start,
243
+ end: overlap_end,
244
+ span,
245
+ days,
246
+ valid: true,
247
+ })
248
+ }
249
+
250
+ fn compute_days_between(start: &str, end: &str) -> i64 {
251
+ use chrono::NaiveDate;
252
+ let start_date = NaiveDate::parse_from_str(start, "%Y-%m-%d").ok();
253
+ let end_date = NaiveDate::parse_from_str(end, "%Y-%m-%d").ok();
254
+ match (start_date, end_date) {
255
+ (Some(s), Some(e)) => (e - s).num_days(),
256
+ _ => 0,
257
+ }
258
+ }
259
+
260
+ fn format_date_span(days: i64) -> String {
261
+ if days < 0 {
262
+ return "No overlap".to_string();
263
+ }
264
+ let years = days / 365;
265
+ let months = (days % 365) / 30;
266
+ let remaining_days = days % 30;
267
+
268
+ let mut parts = Vec::new();
269
+ if years > 0 {
270
+ parts.push(format!("{}y", years));
271
+ }
272
+ if months > 0 {
273
+ parts.push(format!("{}m", months));
274
+ }
275
+ if remaining_days > 0 || parts.is_empty() {
276
+ parts.push(format!("{}d", remaining_days));
277
+ }
278
+ parts.join(" ")
279
+ }
280
+
136
281
  fn build_timeline(&self, frames: &[String]) -> Vec<TimelineBucket> {
137
282
  let mut all_buckets: HashMap<String, HashMap<String, usize>> = HashMap::new();
138
283