datadoc-cli 0.2.0__tar.gz → 0.3.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.
Files changed (41) hide show
  1. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/LICENSE +1 -1
  2. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/PKG-INFO +12 -8
  3. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/README.md +11 -7
  4. datadoc_cli-0.3.0/comprehensive_test.csv +11 -0
  5. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/cli/app.py +19 -33
  6. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/core/engine.py +84 -2
  7. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/plugins/base.py +15 -21
  8. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/plugins/datetime_feat.py +48 -22
  9. datadoc_cli-0.3.0/datadoc/plugins/encoders.py +95 -0
  10. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/plugins/missing_values.py +2 -5
  11. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/plugins/outliers.py +28 -26
  12. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/plugins/scaling.py +38 -27
  13. datadoc_cli-0.3.0/diagnose.py +97 -0
  14. datadoc_cli-0.3.0/diagnose_output.txt +130 -0
  15. datadoc_cli-0.3.0/diagnose_output2.txt +76 -0
  16. datadoc_cli-0.3.0/docs/api-docs.html +140 -0
  17. datadoc_cli-0.3.0/docs/contribute.html +128 -0
  18. datadoc_cli-0.3.0/docs/css/style.css +522 -0
  19. datadoc_cli-0.3.0/docs/how-it-works.html +127 -0
  20. datadoc_cli-0.3.0/docs/index.html +259 -0
  21. datadoc_cli-0.3.0/docs/license.html +106 -0
  22. datadoc_cli-0.3.0/docs/sdk.html +137 -0
  23. datadoc_cli-0.3.0/docs/setup.html +160 -0
  24. datadoc_cli-0.3.0/generate_heavy_test.py +113 -0
  25. datadoc_cli-0.3.0/heavy_test.csv +10001 -0
  26. datadoc_cli-0.3.0/heavy_test_output.txt +52 -0
  27. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/pyproject.toml +1 -1
  28. datadoc_cli-0.3.0/test_output.txt +7 -0
  29. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/tests/test_core.py +41 -6
  30. datadoc_cli-0.3.0/uv.lock +3940 -0
  31. datadoc_cli-0.2.0/datadoc/plugins/encoders.py +0 -73
  32. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  33. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/.gitignore +0 -0
  34. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/CONTRIBUTING.md +0 -0
  35. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/FULL_DOCUMENTATION.md +0 -0
  36. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/__init__.py +0 -0
  37. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/cli/__init__.py +0 -0
  38. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/core/__init__.py +0 -0
  39. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/datadoc/plugins/__init__.py +0 -0
  40. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/test.csv +0 -0
  41. {datadoc_cli-0.2.0 → datadoc_cli-0.3.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 narain-karti
3
+ Copyright (c) 2026 narain-karti
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datadoc-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: The Open Source Operating System for Dataset Engineering.
5
5
  Project-URL: Homepage, https://github.com/narain-karti/DATADOC
6
6
  Project-URL: Repository, https://github.com/narain-karti/DATADOC
@@ -33,7 +33,7 @@ Requires-Dist: ruff>=0.4.0; extra == 'dev'
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  <p align="center">
36
- <pre>
36
+ <pre align="center">
37
37
  ____ _ _____ _ ____ ___ ____
38
38
  | _ \ / \|_ _|/ \ | _ \ / _ \ / ___|
39
39
  | | | |/ _ \ | | / _ \ | | | | | | | |
@@ -44,6 +44,10 @@ Description-Content-Type: text/markdown
44
44
 
45
45
  <h3 align="center">The Open Source Operating System for Dataset Engineering.</h3>
46
46
 
47
+ <p align="center">
48
+ <a href="https://narain-karti.github.io/DATADOC/"><b>&#x1F4D6; View Official Documentation Website</b></a>
49
+ </p>
50
+
47
51
  <p align="center">
48
52
  <a href="https://pypi.org/project/datadoc-cli/"><img alt="PyPI version" src="https://img.shields.io/pypi/v/datadoc-cli.svg"></a>
49
53
  <a href="https://pypi.org/project/datadoc-cli/"><img alt="Python Versions" src="https://img.shields.io/pypi/pyversions/datadoc-cli.svg"></a>
@@ -51,14 +55,14 @@ Description-Content-Type: text/markdown
51
55
  </p>
52
56
 
53
57
  <p align="center">
54
- <a href="#installation">Install</a> |
55
- <a href="#why-datadoc">Why DATADOC?</a> |
56
- <a href="#quick-start">Quick Start</a> |
57
- <a href="#cli-commands">CLI Commands</a> |
58
- <a href="#architecture--plugins">Architecture</a>
58
+ <a href="#installation"><b>Install</b></a> &bull;
59
+ <a href="#why-datadoc"><b>Why DATADOC?</b></a> &bull;
60
+ <a href="#quick-start"><b>Quick Start</b></a> &bull;
61
+ <a href="#cli-commands"><b>CLI Commands</b></a> &bull;
62
+ <a href="#architecture--plugins"><b>Architecture</b></a>
59
63
  </p>
60
64
 
61
- ---
65
+ <hr>
62
66
 
63
67
  ## 🚀 What is DATADOC?
64
68
 
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <pre>
2
+ <pre align="center">
3
3
  ____ _ _____ _ ____ ___ ____
4
4
  | _ \ / \|_ _|/ \ | _ \ / _ \ / ___|
5
5
  | | | |/ _ \ | | / _ \ | | | | | | | |
@@ -10,6 +10,10 @@
10
10
 
11
11
  <h3 align="center">The Open Source Operating System for Dataset Engineering.</h3>
12
12
 
13
+ <p align="center">
14
+ <a href="https://narain-karti.github.io/DATADOC/"><b>&#x1F4D6; View Official Documentation Website</b></a>
15
+ </p>
16
+
13
17
  <p align="center">
14
18
  <a href="https://pypi.org/project/datadoc-cli/"><img alt="PyPI version" src="https://img.shields.io/pypi/v/datadoc-cli.svg"></a>
15
19
  <a href="https://pypi.org/project/datadoc-cli/"><img alt="Python Versions" src="https://img.shields.io/pypi/pyversions/datadoc-cli.svg"></a>
@@ -17,14 +21,14 @@
17
21
  </p>
18
22
 
19
23
  <p align="center">
20
- <a href="#installation">Install</a> |
21
- <a href="#why-datadoc">Why DATADOC?</a> |
22
- <a href="#quick-start">Quick Start</a> |
23
- <a href="#cli-commands">CLI Commands</a> |
24
- <a href="#architecture--plugins">Architecture</a>
24
+ <a href="#installation"><b>Install</b></a> &bull;
25
+ <a href="#why-datadoc"><b>Why DATADOC?</b></a> &bull;
26
+ <a href="#quick-start"><b>Quick Start</b></a> &bull;
27
+ <a href="#cli-commands"><b>CLI Commands</b></a> &bull;
28
+ <a href="#architecture--plugins"><b>Architecture</b></a>
25
29
  </p>
26
30
 
27
- ---
31
+ <hr>
28
32
 
29
33
  ## 🚀 What is DATADOC?
30
34
 
@@ -0,0 +1,11 @@
1
+ ID,Name,Age,Income,Score,City,Membership_Level,Signup_Date
2
+ 1,Alice,25,50000,0.85,New York,Silver,2023-01-15 10:00:00
3
+ 2,Bob,30,60000,0.90,London,Gold,2023-02-20 14:30:00
4
+ 3,Charlie,,55000,0.75,,Bronze,2023-03-05 09:15:00
5
+ 4,David,45,10000000,0.92,Paris,Gold,2023-04-10 16:45:00
6
+ 5,Eve,28,45000,0.88,New York,Silver,2023-05-12 11:20:00
7
+ 6,Frank,35,70000,,London,Silver,2023-06-18 08:00:00
8
+ 7,Grace,40,-500000,0.95,New York,Bronze,2023-07-22 13:10:00
9
+ 8,Heidi,32,65000,0.82,Paris,Gold,2023-08-30 17:55:00
10
+ 9,Ivan,29,52000,0.78,London,,2023-09-14 10:40:00
11
+ 10,Judy,38,75000,0.89,New York,Bronze,2023-10-01 15:25:00
@@ -11,10 +11,10 @@ from dotenv import load_dotenv
11
11
  # Load environment variables (including .env) at startup
12
12
  load_dotenv()
13
13
 
14
- # Determine the default model from the environment, fallback to Gemini
15
- DEFAULT_MODEL = os.getenv("DATADOC_MODEL", "gemini/gemini-2.0-flash")
14
+ # Determine the default model from the environment, fallback to Groq
15
+ DEFAULT_MODEL = os.getenv("DATADOC_MODEL", "groq/llama-3.3-70b-versatile")
16
16
 
17
- from datadoc.core.engine import DATADOC
17
+ from datadoc.core.engine import DATADOC # noqa: E402
18
18
 
19
19
  app = typer.Typer(
20
20
  help="DATADOC: The Open Source Operating System for Dataset Engineering.",
@@ -55,7 +55,7 @@ def load_dataset(file_path: str) -> DATADOC:
55
55
  print_step("[>>]", f"Loading [cyan]{file_path}[/cyan]...")
56
56
  try:
57
57
  doc = DATADOC(file_path)
58
- except Exception as e:
58
+ except (FileNotFoundError, ValueError, RuntimeError) as e:
59
59
  console.print(f"\n [bold red][X] Failed to read dataset:[/] {e}")
60
60
  raise typer.Exit(code=1)
61
61
 
@@ -65,23 +65,15 @@ def load_dataset(file_path: str) -> DATADOC:
65
65
 
66
66
 
67
67
  def _get_api_key(model: str) -> str:
68
- api_key = None
69
- if model.startswith("gemini"):
70
- api_key = os.getenv("GEMINI_API_KEY")
71
- key_name = "GEMINI_API_KEY"
72
- elif model.startswith("gpt") or model.startswith("openai"):
73
- api_key = os.getenv("OPENAI_API_KEY")
74
- key_name = "OPENAI_API_KEY"
75
- elif model.startswith("claude") or model.startswith("anthropic"):
76
- api_key = os.getenv("ANTHROPIC_API_KEY")
77
- key_name = "ANTHROPIC_API_KEY"
78
- elif model.startswith("groq"):
79
- api_key = os.getenv("GROQ_API_KEY")
80
- key_name = "GROQ_API_KEY"
81
- else:
82
- api_key = os.getenv("OPENAI_API_KEY") # Default fallback
83
- key_name = "API_KEY"
84
-
68
+ key_map = {
69
+ "gemini": "GEMINI_API_KEY",
70
+ "gpt": "OPENAI_API_KEY", "openai": "OPENAI_API_KEY",
71
+ "claude": "ANTHROPIC_API_KEY", "anthropic": "ANTHROPIC_API_KEY",
72
+ "groq": "GROQ_API_KEY"
73
+ }
74
+ key_name = next((v for k, v in key_map.items() if model.startswith(k)), "API_KEY")
75
+ api_key = os.getenv("OPENAI_API_KEY") if key_name == "API_KEY" else os.getenv(key_name)
76
+
85
77
  if not api_key:
86
78
  console.print(f"\n [bold yellow][!][/bold yellow] {key_name} not found in environment or .env file.")
87
79
  api_key = typer.prompt(f"Please enter your {key_name}", hide_input=True)
@@ -149,7 +141,7 @@ def analyze(
149
141
  has_issue = p_stats.get("has_scale_issues", False)
150
142
  ratio = p_stats.get("scale_ratio", 0)
151
143
  if has_issue:
152
- table.add_row("Scale Mismatch", f"{ratio}x", f"[yellow][!!] Needs scaling[/yellow]")
144
+ table.add_row("Scale Mismatch", f"{ratio}x", "[yellow][!!] Needs scaling[/yellow]")
153
145
  else:
154
146
  table.add_row("Scale Mismatch", "--", "[green][OK] Balanced[/green]")
155
147
 
@@ -389,7 +381,6 @@ def visualize(file_path: str):
389
381
  Generates a massive, interactive terminal dashboard comparing the before and after states.
390
382
  """
391
383
  import plotext as plt
392
- import numpy as np
393
384
  import sys
394
385
  import polars as pl
395
386
 
@@ -520,8 +511,8 @@ def report(file_path: str):
520
511
  "",
521
512
  "## Dataset Overview",
522
513
  "",
523
- f"| Metric | Value |",
524
- f"|--------|-------|",
514
+ "| Metric | Value |",
515
+ "|--------|-------|",
525
516
  f"| File | `{file_path}` |",
526
517
  f"| Rows | {report_data['rows']:,} |",
527
518
  f"| Columns | {report_data['cols']} |",
@@ -585,12 +576,7 @@ def plugin_list():
585
576
  """
586
577
  print_banner()
587
578
 
588
- # Create a temporary DATADOC instance with a minimal df just to list plugins
589
- # We don't need a real file for this
590
- import io
591
- dummy_csv = b"a,b\n1,2\n"
592
- import polars as pl
593
- dummy_df = pl.read_csv(dummy_csv)
579
+
594
580
 
595
581
  from datadoc.plugins.missing_values import MissingValuePlugin
596
582
  from datadoc.plugins.outliers import OutlierPlugin
@@ -784,7 +770,7 @@ Be helpful, analytical, and concise. Format your responses with markdown."""
784
770
  tool_result = f"Dataset successfully saved to {args['filename']}."
785
771
  else:
786
772
  tool_result = f"Error: Unknown function {func_name}"
787
- except Exception as e:
773
+ except (ValueError, KeyError, RuntimeError) as e:
788
774
  tool_result = f"Error executing {func_name}: {e}"
789
775
 
790
776
  messages.append({
@@ -812,7 +798,7 @@ Be helpful, analytical, and concise. Format your responses with markdown."""
812
798
  except KeyboardInterrupt:
813
799
  console.print("\n[bold green]Ending chat session. Goodbye![/bold green]\n")
814
800
  break
815
- except Exception as e:
801
+ except (KeyboardInterrupt, RuntimeError) as e:
816
802
  console.print(f"\n[bold red]Error:[/] {e}")
817
803
 
818
804
  if __name__ == "__main__":
@@ -1,5 +1,5 @@
1
1
  import polars as pl
2
- from typing import Dict, Any, List, Optional
2
+ from typing import Dict, Any, List
3
3
  import json
4
4
  from collections import Counter
5
5
  import litellm
@@ -26,6 +26,50 @@ class DATADOC:
26
26
  ScalingPlugin(),
27
27
  ], key=lambda p: p.priority)
28
28
 
29
+ @staticmethod
30
+ def _detect_column_roles(df: pl.DataFrame) -> Dict[str, str]:
31
+ """Classify each column as 'id', 'name', 'constant', or 'feature'."""
32
+ roles = {}
33
+ for col in df.columns:
34
+ series = df[col]
35
+ # Constant columns (single unique value)
36
+ if series.drop_nulls().n_unique() <= 1:
37
+ roles[col] = "constant"
38
+ # Likely ID: integer, all unique, monotonically increasing
39
+ elif series.dtype.is_integer() and series.drop_nulls().n_unique() == series.drop_nulls().len() and series.drop_nulls().len() > 0:
40
+ sorted_vals = series.drop_nulls().sort()
41
+ diffs = sorted_vals.diff().drop_nulls()
42
+ if diffs.len() > 0 and (diffs == diffs[0]).all():
43
+ roles[col] = "id"
44
+ else:
45
+ roles[col] = "feature"
46
+ # String columns need extra checks
47
+ elif series.dtype == pl.String and series.drop_nulls().n_unique() == series.drop_nulls().len() and series.drop_nulls().len() > 0:
48
+ # Check if it's a datetime string before labelling as name
49
+ try:
50
+ parsed = df.select(pl.col(col).str.to_datetime(strict=False))
51
+ not_null_ratio = 1 - (parsed[col].null_count() / max(parsed.height, 1))
52
+ if not_null_ratio > 0.5:
53
+ roles[col] = "feature" # Datetime string — let DatetimePlugin handle it
54
+ else:
55
+ roles[col] = "name"
56
+ except (ValueError, TypeError, pl.exceptions.ComputeError, pl.exceptions.InvalidOperationError):
57
+ roles[col] = "name"
58
+ else:
59
+ roles[col] = "feature"
60
+ return roles
61
+
62
+ @staticmethod
63
+ def _drop_constant_columns(df: pl.DataFrame) -> tuple[pl.DataFrame, list[str]]:
64
+ """Drop columns with zero variance (single unique non-null value)."""
65
+ to_drop = []
66
+ for col in df.columns:
67
+ if df[col].drop_nulls().n_unique() <= 1:
68
+ to_drop.append(col)
69
+ if to_drop:
70
+ df = df.drop(to_drop)
71
+ return df, to_drop
72
+
29
73
  def analyze(self) -> Dict[str, Any]:
30
74
  """Runs the analyze step across all plugins."""
31
75
  dtype_counts = Counter([str(dt) for dt in self.df.dtypes])
@@ -89,6 +133,17 @@ class DATADOC:
89
133
 
90
134
  self._applied_plugins = []
91
135
  self._skipped_plugins = []
136
+ self._dropped_columns = []
137
+
138
+ # Phase 0: Drop ID and name columns before any plugin runs
139
+ roles = self._detect_column_roles(df_transformed)
140
+ cols_to_drop = [c for c, r in roles.items() if r in ("id", "name")]
141
+ if cols_to_drop:
142
+ df_transformed = df_transformed.drop(cols_to_drop)
143
+ self._dropped_columns.extend(cols_to_drop)
144
+ if progress_callback:
145
+ progress_callback("ColumnFilter", "applied",
146
+ [f"Dropped non-feature columns: {', '.join(cols_to_drop)}"])
92
147
 
93
148
  for plugin in self.plugins:
94
149
  if progress_callback:
@@ -107,6 +162,14 @@ class DATADOC:
107
162
  if progress_callback:
108
163
  progress_callback(plugin.name, "skipped", [])
109
164
 
165
+ # Phase final: Drop any constant columns created during pipeline
166
+ df_transformed, dropped_constants = self._drop_constant_columns(df_transformed)
167
+ if dropped_constants:
168
+ self._dropped_columns.extend(dropped_constants)
169
+ if progress_callback:
170
+ progress_callback("ColumnFilter", "applied",
171
+ [f"Dropped constant columns: {', '.join(dropped_constants)}"])
172
+
110
173
  return df_transformed
111
174
 
112
175
  def compare(self, clean_df: pl.DataFrame) -> Dict[str, Any]:
@@ -230,7 +293,7 @@ Respond in plain text formatted nicely with bullet points and paragraphs where a
230
293
 
231
294
  try:
232
295
  planner_response = self._generate_ai_plan(model, goal, api_key)
233
- except Exception as e:
296
+ except (RuntimeError, ValueError, TypeError) as e:
234
297
  if progress_callback:
235
298
  progress_callback("AI Planner", "error", [f"LLM API Error: {e}"])
236
299
  raise RuntimeError(f"AI Planner failed: {e}")
@@ -238,6 +301,17 @@ Respond in plain text formatted nicely with bullet points and paragraphs where a
238
301
  df_transformed = self.df.clone()
239
302
  self._applied_plugins = []
240
303
  self._skipped_plugins = []
304
+ self._dropped_columns = []
305
+
306
+ # Phase 0: Drop ID and name columns before any plugin runs
307
+ roles = self._detect_column_roles(df_transformed)
308
+ cols_to_drop = [c for c, r in roles.items() if r in ("id", "name")]
309
+ if cols_to_drop:
310
+ df_transformed = df_transformed.drop(cols_to_drop)
311
+ self._dropped_columns.extend(cols_to_drop)
312
+ if progress_callback:
313
+ progress_callback("ColumnFilter", "applied",
314
+ [f"Dropped non-feature columns: {', '.join(cols_to_drop)}"])
241
315
 
242
316
  if progress_callback:
243
317
  progress_callback("AI Planner", "applied", [f"Generated plan with {len(planner_response.plan)} steps."])
@@ -266,6 +340,14 @@ Respond in plain text formatted nicely with bullet points and paragraphs where a
266
340
  self._skipped_plugins.append(p.name)
267
341
  if progress_callback:
268
342
  progress_callback(p.name, "skipped", ["Skipped by AI Planner"])
343
+
344
+ # Phase final: Drop any constant columns created during pipeline
345
+ df_transformed, dropped_constants = self._drop_constant_columns(df_transformed)
346
+ if dropped_constants:
347
+ self._dropped_columns.extend(dropped_constants)
348
+ if progress_callback:
349
+ progress_callback("ColumnFilter", "applied",
350
+ [f"Dropped constant columns: {', '.join(dropped_constants)}"])
269
351
 
270
352
  return df_transformed
271
353
 
@@ -21,12 +21,13 @@ class BasePlugin(ABC):
21
21
  return 50
22
22
 
23
23
  @property
24
- def supported_datatypes(self) -> list:
25
- return ["numeric", "categorical"]
24
+ def dependencies(self) -> list[str]:
25
+ """List of plugin names this plugin depends on."""
26
+ return []
26
27
 
27
28
  @property
28
- def dependencies(self) -> list:
29
- """List of plugin names that must run before this one."""
29
+ def supported_datatypes(self) -> list[str]:
30
+ """List of datatypes this plugin can handle."""
30
31
  return []
31
32
 
32
33
  @abstractmethod
@@ -49,26 +50,19 @@ class BasePlugin(ABC):
49
50
  """Apply the engineering transformation and return the new dataframe."""
50
51
  pass
51
52
 
52
- def validate(self, df: pl.DataFrame) -> bool:
53
- """Validate that the transformation produced a valid result."""
54
- if df.is_empty():
55
- return False
56
- return True
57
-
58
- def rollback(self, original_df: pl.DataFrame) -> pl.DataFrame:
59
- """Return the original dataframe, undoing any transformation."""
60
- return original_df.clone()
61
-
62
53
  def explain(self) -> str:
63
54
  """Return a human-readable explanation of what this plugin does."""
64
55
  return f"{self.name} (v{self.version}): {self.description}"
65
56
 
66
- def ai_explain(self, analysis_result: dict, goal: str, llm_reason: str) -> str:
67
- """Return a human-readable explanation of why this plugin is applied under AI guidance."""
68
- return f"{self.name} - AI Reason: {llm_reason}"
57
+ def validate(self, df: pl.DataFrame) -> bool:
58
+ """Validate that the plugin's transformation was applied correctly."""
59
+ return True
60
+
61
+ def rollback(self, df: pl.DataFrame) -> pl.DataFrame:
62
+ """Rollback the transformation (default: return df unchanged)."""
63
+ return df
69
64
 
70
65
  def estimate_runtime(self, df: pl.DataFrame) -> float:
71
- """Estimate runtime in seconds based on dataset size."""
72
- rows = df.height
73
- # Simple linear estimate: ~1ms per 1000 rows
74
- return round(rows / 1_000_000, 4)
66
+ """Estimate runtime in seconds based on dataframe size."""
67
+ return df.height * df.width * 1e-6
68
+
@@ -12,19 +12,22 @@ class DatetimePlugin(BasePlugin):
12
12
 
13
13
  @property
14
14
  def description(self) -> str:
15
- return "Detects datetime-like columns and extracts year, month, day, dayofweek features."
15
+ return "Detects datetime-like columns and extracts year, month, day, dayofweek, and hour features. Drops constant-value features."
16
16
 
17
17
  @property
18
18
  def priority(self) -> int:
19
19
  return 30 # After missing values and outliers, before encoding
20
20
 
21
- @property
22
- def supported_datatypes(self) -> list:
23
- return ["datetime"]
24
-
25
- @property
26
- def dependencies(self) -> list:
27
- return ["MissingValuePlugin"]
21
+ @staticmethod
22
+ def _has_time_component(series: pl.Series) -> bool:
23
+ """Check if a datetime series has meaningful time components (not all midnight)."""
24
+ times = series.drop_nulls()
25
+ if times.len() == 0:
26
+ return False
27
+ hours = times.dt.hour()
28
+ minutes = times.dt.minute()
29
+ # If all hours and minutes are 0, there's no meaningful time info
30
+ return not ((hours == 0).all() and (minutes == 0).all())
28
31
 
29
32
  def analyze(self, df: pl.DataFrame) -> dict:
30
33
  datetime_cols = []
@@ -39,7 +42,8 @@ class DatetimePlugin(BasePlugin):
39
42
  not_null_ratio = 1 - (parsed[col].null_count() / parsed.height)
40
43
  if not_null_ratio > 0.5:
41
44
  datetime_cols.append(col)
42
- except Exception:
45
+ except (ValueError, TypeError, pl.exceptions.ComputeError, pl.exceptions.InvalidOperationError):
46
+ # Column is not a valid datetime string; skipping
43
47
  pass
44
48
 
45
49
  return {
@@ -53,7 +57,8 @@ class DatetimePlugin(BasePlugin):
53
57
  cols = analysis_result["datetime_columns"]
54
58
  recs.append(
55
59
  f"Datetime columns detected: {', '.join(cols)}. "
56
- f"Recommendation: Extract year, month, day, day_of_week features and drop original."
60
+ f"Recommendation: Extract year, month, day, day_of_week, hour features. "
61
+ f"Drop original column and any constant features."
57
62
  )
58
63
  return recs
59
64
 
@@ -67,12 +72,23 @@ for col in datetime_cols:
67
72
  if df[col].dtype == pl.String:
68
73
  df = df.with_columns(pl.col(col).str.to_datetime(strict=False).alias(col))
69
74
 
70
- df = df.with_columns([
75
+ new_cols = [
71
76
  pl.col(col).dt.year().alias(col + '_year'),
72
77
  pl.col(col).dt.month().alias(col + '_month'),
73
78
  pl.col(col).dt.day().alias(col + '_day'),
74
- pl.col(col).dt.weekday().alias(col + '_dayofweek')
75
- ]).drop(col)"""
79
+ pl.col(col).dt.weekday().alias(col + '_dayofweek'),
80
+ ]
81
+ # Add hour if time component exists
82
+ hours = df[col].dt.hour()
83
+ if not ((hours == 0).all()):
84
+ new_cols.append(pl.col(col).dt.hour().alias(col + '_hour'))
85
+
86
+ df = df.with_columns(new_cols).drop(col)
87
+
88
+ # Drop any constant features (e.g. year when all dates are same year)
89
+ for c in [c for c in df.columns if c.startswith(col + '_')]:
90
+ if df[c].drop_nulls().n_unique() <= 1:
91
+ df = df.drop(c)"""
76
92
 
77
93
  def apply(self, df: pl.DataFrame) -> pl.DataFrame:
78
94
  df_clean = df.clone()
@@ -81,22 +97,32 @@ for col in datetime_cols:
81
97
  for col in dt_cols:
82
98
  if df_clean[col].dtype == pl.String:
83
99
  df_clean = df_clean.with_columns(pl.col(col).str.to_datetime(strict=False).alias(col))
84
-
85
- df_clean = df_clean.with_columns([
100
+
101
+ new_cols = [
86
102
  pl.col(col).dt.year().alias(col + '_year'),
87
103
  pl.col(col).dt.month().alias(col + '_month'),
88
104
  pl.col(col).dt.day().alias(col + '_day'),
89
- pl.col(col).dt.weekday().alias(col + '_dayofweek')
90
- ]).drop(col)
105
+ pl.col(col).dt.weekday().alias(col + '_dayofweek'),
106
+ ]
91
107
 
92
- return df_clean
108
+ # Add hour if time component exists
109
+ if self._has_time_component(df_clean[col]):
110
+ new_cols.append(pl.col(col).dt.hour().alias(col + '_hour'))
111
+
112
+ df_clean = df_clean.with_columns(new_cols).drop(col)
93
113
 
94
- def validate(self, df: pl.DataFrame) -> bool:
95
- return True
114
+ # Drop constant datetime features (e.g. year=2023 for all rows)
115
+ for c in [c for c in df_clean.columns if c.startswith(col + '_')]:
116
+ if df_clean[c].drop_nulls().n_unique() <= 1:
117
+ df_clean = df_clean.drop(c)
118
+
119
+ return df_clean
96
120
 
97
121
  def explain(self) -> str:
98
122
  return (
99
123
  "DatetimePlugin detects columns containing dates/times and extracts "
100
- "year, month, day, and day_of_week as new numeric features. "
101
- "The original datetime column is dropped."
124
+ "year, month, day, day_of_week, and hour as new numeric features. "
125
+ "The original datetime column is dropped, along with any constant features "
126
+ "(e.g., year when all dates are in the same year)."
102
127
  )
128
+
@@ -0,0 +1,95 @@
1
+ import polars as pl
2
+ from datadoc.plugins.base import BasePlugin
3
+
4
+ class CategoricalEncoderPlugin(BasePlugin):
5
+ @property
6
+ def name(self) -> str:
7
+ return "CategoricalEncoderPlugin"
8
+
9
+ @property
10
+ def version(self) -> str:
11
+ return "0.1.0"
12
+
13
+ @property
14
+ def description(self) -> str:
15
+ return "Drops high-cardinality identifier columns and applies One-Hot Encoding (drop_first=True) to low-cardinality categoricals."
16
+
17
+ @property
18
+ def priority(self) -> int:
19
+ return 40 # After missing values and outliers
20
+
21
+ def analyze(self, df: pl.DataFrame) -> dict:
22
+ str_cols = [col for col in df.columns if df[col].dtype == pl.String]
23
+
24
+ # Identifier columns: every value is unique (e.g. Name, Email)
25
+ id_cols = [col for col in str_cols if df[col].drop_nulls().n_unique() >= df.height]
26
+
27
+ # Encodable categorical: low cardinality (2-9 unique), not an identifier
28
+ valid_cats = [
29
+ col for col in str_cols
30
+ if col not in id_cols and 1 < df[col].n_unique() < 10
31
+ ]
32
+ cardinality = {col: df[col].n_unique() for col in valid_cats}
33
+
34
+ return {
35
+ "has_categorical": len(valid_cats) > 0 or len(id_cols) > 0,
36
+ "categorical_columns": valid_cats,
37
+ "identifier_columns": id_cols,
38
+ "cardinality": cardinality,
39
+ }
40
+
41
+ def recommend(self, analysis_result: dict) -> list[str]:
42
+ recs = []
43
+ id_cols = analysis_result.get("identifier_columns", [])
44
+ if id_cols:
45
+ recs.append(
46
+ f"Identifier columns detected: {', '.join(id_cols)}. "
47
+ f"Recommendation: Drop these (every value is unique, no ML signal)."
48
+ )
49
+ if analysis_result.get("categorical_columns"):
50
+ info = analysis_result.get("cardinality", {})
51
+ detail = ", ".join([f"{c} ({v} unique)" for c, v in info.items()])
52
+ recs.append(
53
+ f"Categorical columns found: {detail}. "
54
+ f"Recommendation: Apply One-Hot Encoding."
55
+ )
56
+ return recs
57
+
58
+ def generate_code(self, analysis_result: dict) -> str:
59
+ lines = []
60
+ id_cols = analysis_result.get("identifier_columns", [])
61
+ if id_cols:
62
+ lines.append(f"# Drop identifier columns")
63
+ lines.append(f"df = df.drop({id_cols})")
64
+
65
+ cat_cols = analysis_result.get("categorical_columns", [])
66
+ if cat_cols:
67
+ cols_str = str(cat_cols)
68
+ lines.append(f"# Categorical Encoding (One-Hot)")
69
+ lines.append(f"cat_cols = {cols_str}")
70
+ lines.append(f"df = df.to_dummies(columns=cat_cols, drop_first=True)")
71
+
72
+ return "\n".join(lines)
73
+
74
+ def apply(self, df: pl.DataFrame) -> pl.DataFrame:
75
+ df_clean = df.clone()
76
+ analysis = self.analyze(df_clean)
77
+
78
+ # Drop identifier columns first
79
+ id_cols = analysis.get("identifier_columns", [])
80
+ if id_cols:
81
+ df_clean = df_clean.drop(id_cols)
82
+
83
+ # One-hot encode low-cardinality categoricals
84
+ cat_cols = analysis.get("categorical_columns", [])
85
+ if cat_cols:
86
+ df_clean = df_clean.to_dummies(columns=cat_cols, drop_first=True)
87
+ return df_clean
88
+
89
+ def explain(self) -> str:
90
+ return (
91
+ "CategoricalEncoderPlugin detects text/string columns. "
92
+ "High-cardinality identifier columns (every value unique) are dropped. "
93
+ "Low-cardinality columns (< 10 unique values) are One-Hot Encoded with drop_first=True."
94
+ )
95
+
@@ -18,9 +18,7 @@ class MissingValuePlugin(BasePlugin):
18
18
  def priority(self) -> int:
19
19
  return 10 # Should run first
20
20
 
21
- @property
22
- def supported_datatypes(self) -> list:
23
- return ["numeric", "categorical"]
21
+
24
22
 
25
23
  def analyze(self, df: pl.DataFrame) -> dict:
26
24
  cols_with_missing = {c: df[c].null_count() for c in df.columns if df[c].null_count() > 0}
@@ -64,8 +62,7 @@ for col in df.columns:
64
62
  df_clean = df_clean.with_columns(pl.col(col).fill_null(pl.col(col).drop_nulls().mode().first()))
65
63
  return df_clean
66
64
 
67
- def validate(self, df: pl.DataFrame) -> bool:
68
- return bool(sum(df[c].null_count() for c in df.columns) == 0)
65
+
69
66
 
70
67
  def explain(self) -> str:
71
68
  return (