dataframe-mutator 0.1.1__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.
Files changed (21) hide show
  1. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/PKG-INFO +2 -1
  2. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/dataframe_mutator.egg-info/PKG-INFO +2 -1
  3. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/dataframe_mutator.egg-info/SOURCES.txt +7 -0
  4. dataframe_mutator-0.2.0/dataframe_mutator.egg-info/entry_points.txt +5 -0
  5. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/dataframe_mutator.egg-info/requires.txt +1 -0
  6. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/pyproject.toml +8 -1
  7. dataframe_mutator-0.2.0/src/dataframe_mutator/cli.py +159 -0
  8. dataframe_mutator-0.2.0/src/dataframe_mutator/config.py +95 -0
  9. dataframe_mutator-0.2.0/src/dataframe_mutator/integrations.py +170 -0
  10. dataframe_mutator-0.2.0/src/dataframe_mutator/pytest_plugin.py +78 -0
  11. dataframe_mutator-0.2.0/src/dataframe_mutator/reports.py +181 -0
  12. dataframe_mutator-0.2.0/tests/test_advanced_features.py +177 -0
  13. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/LICENSE +0 -0
  14. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/README.md +0 -0
  15. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/dataframe_mutator.egg-info/dependency_links.txt +0 -0
  16. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/dataframe_mutator.egg-info/top_level.txt +0 -0
  17. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/setup.cfg +0 -0
  18. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/src/dataframe_mutator/__init__.py +0 -0
  19. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/tests/test_polars_100_coverage.py +0 -0
  20. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/tests/test_polars_new_operators.py +0 -0
  21. {dataframe_mutator-0.1.1 → dataframe_mutator-0.2.0}/tests/test_polars_operators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataframe-mutator
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Production-grade mutation testing for Polars dataframes. Validate test suite quality by detecting which mutations your tests catch.
5
5
  Author-email: Dataframe Mutator Contributors <suhrusai@gmail.com>
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ Requires-Python: >=3.8
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: mutmut>=2.4.0
29
+ Requires-Dist: click>=8.0.0
29
30
  Provides-Extra: polars
30
31
  Requires-Dist: polars>=0.19.0; extra == "polars"
31
32
  Provides-Extra: pyspark
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataframe-mutator
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Production-grade mutation testing for Polars dataframes. Validate test suite quality by detecting which mutations your tests catch.
5
5
  Author-email: Dataframe Mutator Contributors <suhrusai@gmail.com>
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ Requires-Python: >=3.8
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: mutmut>=2.4.0
29
+ Requires-Dist: click>=8.0.0
29
30
  Provides-Extra: polars
30
31
  Requires-Dist: polars>=0.19.0; extra == "polars"
31
32
  Provides-Extra: pyspark
@@ -4,9 +4,16 @@ pyproject.toml
4
4
  dataframe_mutator.egg-info/PKG-INFO
5
5
  dataframe_mutator.egg-info/SOURCES.txt
6
6
  dataframe_mutator.egg-info/dependency_links.txt
7
+ dataframe_mutator.egg-info/entry_points.txt
7
8
  dataframe_mutator.egg-info/requires.txt
8
9
  dataframe_mutator.egg-info/top_level.txt
9
10
  src/dataframe_mutator/__init__.py
11
+ src/dataframe_mutator/cli.py
12
+ src/dataframe_mutator/config.py
13
+ src/dataframe_mutator/integrations.py
14
+ src/dataframe_mutator/pytest_plugin.py
15
+ src/dataframe_mutator/reports.py
16
+ tests/test_advanced_features.py
10
17
  tests/test_polars_100_coverage.py
11
18
  tests/test_polars_new_operators.py
12
19
  tests/test_polars_operators.py
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ dataframe-mutator = dataframe_mutator.cli:cli
3
+
4
+ [pytest11]
5
+ dataframe-mutator = dataframe_mutator.pytest_plugin
@@ -1,4 +1,5 @@
1
1
  mutmut>=2.4.0
2
+ click>=8.0.0
2
3
 
3
4
  [all]
4
5
  dataframe-mutator[dev,pandas,polars,pyspark]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dataframe-mutator"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Production-grade mutation testing for Polars dataframes. Validate test suite quality by detecting which mutations your tests catch."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -30,8 +30,15 @@ classifiers = [
30
30
  ]
31
31
  dependencies = [
32
32
  "mutmut>=2.4.0",
33
+ "click>=8.0.0",
33
34
  ]
34
35
 
36
+ [project.scripts]
37
+ dataframe-mutator = "dataframe_mutator.cli:cli"
38
+
39
+ [project.entry-points.pytest11]
40
+ dataframe-mutator = "dataframe_mutator.pytest_plugin"
41
+
35
42
  [project.urls]
36
43
  Homepage = "https://github.com/suhrusai/dataframe-mutator"
37
44
  Documentation = "https://github.com/suhrusai/dataframe-mutator#readme"
@@ -0,0 +1,159 @@
1
+ """Command-line interface for dataframe-mutator."""
2
+
3
+ import click
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from .config import MutationConfig
7
+ from .polars import SmartPolarsTestRunner
8
+ from .reports import HTMLReportGenerator, JSONExporter, BaselineTracker
9
+
10
+
11
+ @click.group()
12
+ def cli():
13
+ """dataframe-mutator - Mutation testing for Polars dataframes."""
14
+ pass
15
+
16
+
17
+ @cli.command()
18
+ @click.argument("source", type=click.Path(exists=True), nargs=-1, required=True)
19
+ @click.option("--tests", default="tests/", help="Path to test directory")
20
+ @click.option("--config", default="dataframe-mutator.toml", help="Config file path")
21
+ @click.option("--output", default="text", type=click.Choice(["text", "json", "html"]),
22
+ help="Output format")
23
+ @click.option("--threshold", type=float, help="Mutation score threshold")
24
+ @click.option("--parallel", is_flag=True, default=True, help="Use parallel testing")
25
+ @click.option("--workers", type=int, help="Number of parallel workers")
26
+ @click.option("--save-baseline", is_flag=True, help="Save current results as baseline")
27
+ def analyze(source, tests, config, output, threshold, parallel, workers, save_baseline):
28
+ """Analyze mutation efficiency of your code."""
29
+
30
+ # Load configuration
31
+ cfg = MutationConfig.from_toml(config)
32
+
33
+ # Override with CLI arguments
34
+ if threshold:
35
+ cfg.mutation_threshold = threshold
36
+ if not parallel:
37
+ cfg.parallel = False
38
+ if workers:
39
+ cfg.num_workers = workers
40
+ cfg.output_format = output
41
+ cfg.source_files = list(source)
42
+ cfg.test_command = f"pytest {tests}"
43
+
44
+ click.echo(f"[*] Analyzing {len(source)} file(s)...")
45
+ click.echo(f"[TEST] Test command: {cfg.test_command}")
46
+
47
+ try:
48
+ tester = SmartPolarsTestRunner(
49
+ test_command=cfg.test_command,
50
+ parallel=cfg.parallel,
51
+ num_workers=cfg.num_workers
52
+ )
53
+
54
+ all_results = {}
55
+ for src_file in source:
56
+ click.echo(f"\n[FILE] {src_file}")
57
+ results = tester.analyze_mutation_efficiency(src_file)
58
+ all_results[src_file] = results
59
+
60
+ click.echo(f" [OK] High-value mutations: {results['high_value_mutations']}")
61
+ click.echo(f" [STAT] False positives avoided: {results['potential_false_positives_avoided']:.1f}%")
62
+
63
+ if results['high_value_mutations'] == 0:
64
+ click.echo(" [WARN] No mutations found!")
65
+
66
+ # Generate reports
67
+ Path(cfg.output_dir).mkdir(exist_ok=True)
68
+
69
+ if output in ["text", "json", "html"]:
70
+ if output == "json" or output == "html":
71
+ exporter = JSONExporter(cfg)
72
+ exporter.export(all_results, f"{cfg.output_dir}/results.json")
73
+ click.echo(f"\n[REPORT] JSON report: {cfg.output_dir}/results.json")
74
+
75
+ if output == "html":
76
+ generator = HTMLReportGenerator(cfg)
77
+ generator.generate(all_results, f"{cfg.output_dir}/report.html")
78
+ click.echo(f"[REPORT] HTML report: {cfg.output_dir}/report.html")
79
+
80
+ # Save baseline if requested
81
+ if save_baseline:
82
+ tracker = BaselineTracker(cfg)
83
+ tracker.save_baseline(all_results)
84
+ click.echo(f"\n[SAVE] Baseline saved: {cfg.baseline_path}")
85
+
86
+ # Check threshold
87
+ avg_mutations = sum(r['high_value_mutations'] for r in all_results.values()) / len(all_results)
88
+ if avg_mutations < cfg.min_mutations:
89
+ click.echo(f"\n[FAIL] Below minimum mutations ({avg_mutations:.0f} < {cfg.min_mutations})")
90
+ raise click.Exit(1)
91
+
92
+ click.echo("\n[SUCCESS] Analysis complete!")
93
+
94
+ except Exception as e:
95
+ click.echo(f"[ERROR] Error: {e}", err=True)
96
+ raise click.Exit(1)
97
+
98
+
99
+ @cli.command()
100
+ @click.argument("source", type=click.Path(exists=True), nargs=-1, required=True)
101
+ @click.option("--tests", default="tests/", help="Path to test directory")
102
+ @click.option("--config", default="dataframe-mutator.toml", help="Config file path")
103
+ def check(source, tests, config):
104
+ """Check mutation score against baseline."""
105
+
106
+ cfg = MutationConfig.from_toml(config)
107
+ cfg.source_files = list(source)
108
+ cfg.test_command = f"pytest {tests}"
109
+
110
+ tracker = BaselineTracker(cfg)
111
+
112
+ if not Path(cfg.baseline_path).exists():
113
+ click.echo("[FAIL] No baseline found. Run with --save-baseline first.")
114
+ raise click.Exit(1)
115
+
116
+ tester = SmartPolarsTestRunner(test_command=cfg.test_command)
117
+ baseline = tracker.load_baseline()
118
+
119
+ click.echo("[*] Comparing to baseline...")
120
+
121
+ all_pass = True
122
+ for src_file in source:
123
+ results = tester.analyze_mutation_efficiency(src_file)
124
+ current = results['high_value_mutations']
125
+ baseline_val = baseline.get(src_file, {}).get('high_value_mutations', 0)
126
+
127
+ change = current - baseline_val
128
+ if change >= 0:
129
+ click.echo(f"[OK] {src_file}: {current} mutations (+{change})")
130
+ else:
131
+ click.echo(f"[WARN] {src_file}: {current} mutations ({change})")
132
+ all_pass = False
133
+
134
+ if not all_pass:
135
+ click.echo("\n[WARN] Some files have fewer mutations than baseline")
136
+ raise click.Exit(1)
137
+
138
+ click.echo("\n[OK] All files meet or exceed baseline!")
139
+
140
+
141
+ @cli.command()
142
+ @click.option("--config", default="dataframe-mutator.toml", help="Config file path")
143
+ def init(config):
144
+ """Initialize configuration file."""
145
+
146
+ cfg = MutationConfig()
147
+ cfg.to_json(config)
148
+ click.echo(f"[OK] Created {config}")
149
+ click.echo("Edit this file to customize mutation testing settings.")
150
+
151
+
152
+ @cli.command()
153
+ def version():
154
+ """Show version."""
155
+ click.echo("dataframe-mutator 0.1.1")
156
+
157
+
158
+ if __name__ == "__main__":
159
+ cli()
@@ -0,0 +1,95 @@
1
+ """Configuration management for dataframe-mutator."""
2
+
3
+ import json
4
+ import sys
5
+ from dataclasses import dataclass, field, asdict
6
+ from pathlib import Path
7
+ from typing import Optional, List
8
+
9
+ # Try to import tomllib (Python 3.11+)
10
+ try:
11
+ import tomllib
12
+ except ImportError:
13
+ tomllib = None
14
+
15
+
16
+ @dataclass
17
+ class MutationConfig:
18
+ """Configuration for mutation testing."""
19
+
20
+ # Paths
21
+ source_files: List[str] = field(default_factory=list)
22
+ test_command: str = "pytest tests/"
23
+ skip_patterns: List[str] = field(default_factory=lambda: ["__pycache__", "*.pyc", ".git"])
24
+
25
+ # Thresholds
26
+ mutation_threshold: float = 0.85 # Fail if below 85%
27
+ min_mutations: int = 5 # Require at least 5 mutations
28
+
29
+ # Operators
30
+ operators: Optional[List[str]] = None # None = all operators
31
+ skip_operators: List[str] = field(default_factory=list)
32
+
33
+ # Features
34
+ skip_low_value_mutations: bool = True
35
+ parallel: bool = True
36
+ num_workers: Optional[int] = None # None = auto (cpu_count)
37
+
38
+ # Output
39
+ output_format: str = "text" # text, json, html
40
+ output_dir: str = "mutation-results"
41
+ save_baseline: bool = False
42
+ baseline_path: str = ".mutation-baseline.json"
43
+
44
+ # Integrations
45
+ github_annotations: bool = False
46
+ slack_webhook: Optional[str] = None
47
+ slack_on_fail_only: bool = True
48
+
49
+ # Advanced
50
+ timeout_per_test: Optional[int] = None # Seconds
51
+ max_mutations_per_file: Optional[int] = None
52
+
53
+ @classmethod
54
+ def from_toml(cls, path: str = "dataframe-mutator.toml") -> "MutationConfig":
55
+ """Load configuration from TOML file."""
56
+ config_path = Path(path)
57
+ if not config_path.exists():
58
+ return cls()
59
+
60
+ try:
61
+ # Try Python 3.11+ tomllib first
62
+ if tomllib:
63
+ with open(config_path, "rb") as f:
64
+ data = tomllib.load(f)
65
+ else:
66
+ # Fallback for older Python
67
+ import tomli
68
+ with open(config_path, "rb") as f:
69
+ data = tomli.load(f)
70
+
71
+ config_data = data.get("dataframe-mutator", {})
72
+ return cls(**config_data)
73
+ except Exception as e:
74
+ print(f"Warning: Could not load config from {path}: {e}")
75
+ return cls()
76
+
77
+ @classmethod
78
+ def from_json(cls, path: str) -> "MutationConfig":
79
+ """Load configuration from JSON file."""
80
+ try:
81
+ with open(path) as f:
82
+ data = json.load(f)
83
+ return cls(**data)
84
+ except Exception as e:
85
+ print(f"Warning: Could not load config from {path}: {e}")
86
+ return cls()
87
+
88
+ def to_json(self, path: str) -> None:
89
+ """Save configuration to JSON file."""
90
+ with open(path, "w") as f:
91
+ json.dump(asdict(self), f, indent=2)
92
+
93
+ def to_dict(self) -> dict:
94
+ """Convert to dictionary."""
95
+ return asdict(self)
@@ -0,0 +1,170 @@
1
+ """Integrations with external services and tools."""
2
+
3
+ import json
4
+ import os
5
+ from typing import Dict, Any, Optional
6
+ from urllib.request import urlopen, Request
7
+ from pathlib import Path
8
+
9
+
10
+ class SlackNotifier:
11
+ """Send mutation testing results to Slack."""
12
+
13
+ def __init__(self, webhook_url: str):
14
+ self.webhook_url = webhook_url
15
+
16
+ def notify(self, results: Dict[str, Any], threshold: float = 0.85) -> None:
17
+ """Send Slack notification."""
18
+ total_mutations = sum(r.get("high_value_mutations", 0) for r in results.values())
19
+ avg_false_pos = sum(r.get("potential_false_positives_avoided", 0) for r in results.values()) / len(results) if results else 0
20
+
21
+ color = "good" if total_mutations > 0 else "danger"
22
+
23
+ payload = {
24
+ "attachments": [{
25
+ "color": color,
26
+ "title": "🧬 Mutation Testing Results",
27
+ "fields": [
28
+ {"title": "Total Mutations", "value": str(total_mutations), "short": True},
29
+ {"title": "Files Analyzed", "value": str(len(results)), "short": True},
30
+ {"title": "False Positives Avoided", "value": f"{avg_false_pos:.1f}%", "short": True},
31
+ {"title": "Status", "value": "✅ Pass" if total_mutations > 0 else "⚠️ No mutations", "short": True},
32
+ ]
33
+ }]
34
+ }
35
+
36
+ try:
37
+ req = Request(
38
+ self.webhook_url,
39
+ data=json.dumps(payload).encode(),
40
+ headers={"Content-Type": "application/json"}
41
+ )
42
+ urlopen(req)
43
+ except Exception as e:
44
+ print(f"Warning: Failed to send Slack notification: {e}")
45
+
46
+
47
+ class GitHubAnnotator:
48
+ """Add annotations to GitHub Actions workflow."""
49
+
50
+ @staticmethod
51
+ def annotate_mutations(results: Dict[str, Any]) -> None:
52
+ """Add GitHub workflow annotations for mutations."""
53
+ if not os.getenv("GITHUB_ACTIONS"):
54
+ return
55
+
56
+ for file, data in results.items():
57
+ mutations = data.get("high_value_mutations", 0)
58
+ if mutations == 0:
59
+ level = "warning"
60
+ msg = "No mutations detected - may need more tests"
61
+ else:
62
+ level = "notice"
63
+ msg = f"{mutations} high-value mutations found"
64
+
65
+ print(f"::{level} file={file}::{msg}")
66
+
67
+
68
+ class CustomOperatorRegistry:
69
+ """Registry for custom mutation operators."""
70
+
71
+ def __init__(self, registry_path: str = ".mutation-operators"):
72
+ self.registry_path = Path(registry_path)
73
+ self.registry_path.mkdir(exist_ok=True)
74
+
75
+ def register(self, operator_class) -> None:
76
+ """Register a custom operator."""
77
+ operator_file = self.registry_path / f"{operator_class.name}.py"
78
+
79
+ # Save operator code
80
+ import inspect
81
+ code = inspect.getsource(operator_class)
82
+ operator_file.write_text(code)
83
+
84
+ def load_operators(self) -> list:
85
+ """Load all registered operators."""
86
+ operators = []
87
+
88
+ for op_file in self.registry_path.glob("*.py"):
89
+ if op_file.name == "__init__.py":
90
+ continue
91
+
92
+ # Dynamically import operator
93
+ import importlib.util
94
+ spec = importlib.util.spec_from_file_location("operator", op_file)
95
+ module = importlib.util.module_from_spec(spec)
96
+ spec.loader.exec_module(module)
97
+
98
+ # Find operator class
99
+ for attr in dir(module):
100
+ obj = getattr(module, attr)
101
+ if hasattr(obj, "name") and hasattr(obj, "mutate"):
102
+ operators.append(obj)
103
+
104
+ return operators
105
+
106
+
107
+ class OperatorSuggester:
108
+ """Suggest missing mutation operators based on code."""
109
+
110
+ @staticmethod
111
+ def analyze_code(source_file: str) -> Dict[str, int]:
112
+ """Analyze code and suggest operators."""
113
+ content = Path(source_file).read_text()
114
+ suggestions = {}
115
+
116
+ # Check for patterns
117
+ if "filter(" in content:
118
+ suggestions["filter_mutations"] = content.count("filter(")
119
+
120
+ if ".sum()" in content or ".mean()" in content:
121
+ suggestions["aggregation_mutations"] = (
122
+ content.count(".sum()") + content.count(".mean()")
123
+ )
124
+
125
+ if ".join(" in content:
126
+ suggestions["join_mutations"] = content.count(".join(")
127
+
128
+ if "is_null" in content or "fill_null" in content:
129
+ suggestions["null_handling_mutations"] = (
130
+ content.count("is_null") + content.count("fill_null")
131
+ )
132
+
133
+ if ">" in content or ">=" in content or "<" in content:
134
+ # Count comparison operators (rough estimate)
135
+ suggestions["boundary_mutations"] = (
136
+ content.count(" > ") + content.count(" >= ") +
137
+ content.count(" < ") + content.count(" <= ")
138
+ )
139
+
140
+ return suggestions
141
+
142
+
143
+ class ParallelMutationRunner:
144
+ """Run mutations in parallel for faster testing."""
145
+
146
+ def __init__(self, num_workers: Optional[int] = None):
147
+ import multiprocessing
148
+ self.num_workers = num_workers or multiprocessing.cpu_count()
149
+
150
+ def run_parallel(self, mutations: list, test_command: str) -> Dict[str, bool]:
151
+ """Run mutations in parallel."""
152
+ from multiprocessing import Pool
153
+ import subprocess
154
+
155
+ def run_mutation(mutation_code: str) -> bool:
156
+ try:
157
+ result = subprocess.run(
158
+ ["python", "-c", test_command],
159
+ capture_output=True,
160
+ timeout=30,
161
+ text=True
162
+ )
163
+ return result.returncode == 0
164
+ except:
165
+ return False
166
+
167
+ with Pool(self.num_workers) as pool:
168
+ results = pool.map(run_mutation, mutations)
169
+
170
+ return {f"mutation_{i}": passed for i, passed in enumerate(results)}
@@ -0,0 +1,78 @@
1
+ """pytest plugin for mutation testing integration."""
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+ from .polars import SmartPolarsTestRunner
6
+ from .config import MutationConfig
7
+
8
+
9
+ def pytest_addoption(parser):
10
+ """Add pytest options for mutation testing."""
11
+ parser.addoption(
12
+ "--mutation",
13
+ action="store_true",
14
+ default=False,
15
+ help="Run mutation testing on Polars code"
16
+ )
17
+ parser.addoption(
18
+ "--mutation-source",
19
+ default="src/",
20
+ help="Source directory to analyze"
21
+ )
22
+ parser.addoption(
23
+ "--mutation-threshold",
24
+ type=float,
25
+ default=0.85,
26
+ help="Minimum mutation score threshold"
27
+ )
28
+
29
+
30
+ def pytest_configure(config):
31
+ """Configure pytest for mutation testing."""
32
+ if config.getoption("--mutation"):
33
+ config.addinivalue_line("markers", "mutation: mark test as mutation testing")
34
+
35
+
36
+ def pytest_collection_modifyitems(config, items):
37
+ """Modify test collection for mutation testing."""
38
+ if config.getoption("--mutation"):
39
+ # Add mutation marker to all tests
40
+ for item in items:
41
+ item.add_marker(pytest.mark.mutation)
42
+
43
+
44
+ @pytest.fixture(scope="session")
45
+ def mutation_tester(request):
46
+ """Provide mutation tester fixture."""
47
+ cfg = MutationConfig.from_toml()
48
+ return SmartPolarsTestRunner(test_command="pytest")
49
+
50
+
51
+ def pytest_terminal_summary(terminalreporter, exitstatus, config):
52
+ """Add mutation testing summary to pytest output."""
53
+ if not config.getoption("--mutation"):
54
+ return
55
+
56
+ source_dir = config.getoption("--mutation-source")
57
+ threshold = config.getoption("--mutation-threshold")
58
+
59
+ if not Path(source_dir).exists():
60
+ terminalreporter.write_sep("=", "No source directory found", red=True)
61
+ return
62
+
63
+ try:
64
+ tester = SmartPolarsTestRunner(test_command="pytest")
65
+
66
+ terminalreporter.write_sep("=", "Mutation Testing Results")
67
+
68
+ for py_file in Path(source_dir).rglob("*.py"):
69
+ results = tester.analyze_mutation_efficiency(str(py_file))
70
+ mutations = results['high_value_mutations']
71
+ false_pos = results['potential_false_positives_avoided']
72
+
73
+ if mutations >= 1: # Only show if mutations found
74
+ status = "✅" if mutations > 0 else "⚠️"
75
+ terminalreporter.write(f"\n{status} {py_file}: {mutations} mutations ({false_pos:.1f}% false pos avoided)")
76
+
77
+ except Exception as e:
78
+ terminalreporter.write_sep("!", f"Mutation testing error: {e}", yellow=True)
@@ -0,0 +1,181 @@
1
+ """Report generation and export functionality."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from datetime import datetime
6
+ from typing import Dict, Any, Optional
7
+ from .config import MutationConfig
8
+
9
+
10
+ class BaselineTracker:
11
+ """Track and compare mutation testing baselines."""
12
+
13
+ def __init__(self, config: MutationConfig):
14
+ self.config = config
15
+
16
+ def save_baseline(self, results: Dict[str, Any]) -> None:
17
+ """Save current results as baseline."""
18
+ baseline_data = {
19
+ "timestamp": datetime.now().isoformat(),
20
+ "results": results
21
+ }
22
+ Path(self.config.baseline_path).write_text(json.dumps(baseline_data, indent=2))
23
+
24
+ def load_baseline(self) -> Dict[str, Any]:
25
+ """Load saved baseline."""
26
+ path = Path(self.config.baseline_path)
27
+ if not path.exists():
28
+ return {}
29
+
30
+ data = json.loads(path.read_text())
31
+ return data.get("results", {})
32
+
33
+ def compare(self, current: Dict[str, Any]) -> Dict[str, Any]:
34
+ """Compare current results to baseline."""
35
+ baseline = self.load_baseline()
36
+ comparison = {}
37
+
38
+ for file, results in current.items():
39
+ baseline_val = baseline.get(file, {})
40
+ comparison[file] = {
41
+ "current": results.get("high_value_mutations", 0),
42
+ "baseline": baseline_val.get("high_value_mutations", 0),
43
+ "change": results.get("high_value_mutations", 0) - baseline_val.get("high_value_mutations", 0)
44
+ }
45
+
46
+ return comparison
47
+
48
+
49
+ class JSONExporter:
50
+ """Export mutation testing results to JSON."""
51
+
52
+ def __init__(self, config: MutationConfig):
53
+ self.config = config
54
+
55
+ def export(self, results: Dict[str, Any], output_path: str) -> None:
56
+ """Export results to JSON file."""
57
+ export_data = {
58
+ "timestamp": datetime.now().isoformat(),
59
+ "config": self.config.to_dict(),
60
+ "results": results,
61
+ "summary": self._generate_summary(results)
62
+ }
63
+
64
+ Path(output_path).write_text(json.dumps(export_data, indent=2))
65
+
66
+ def _generate_summary(self, results: Dict[str, Any]) -> Dict[str, Any]:
67
+ """Generate summary statistics."""
68
+ total_mutations = sum(r.get("high_value_mutations", 0) for r in results.values())
69
+ avg_false_pos_avoided = sum(
70
+ r.get("potential_false_positives_avoided", 0)
71
+ for r in results.values()
72
+ ) / len(results) if results else 0
73
+
74
+ return {
75
+ "total_files": len(results),
76
+ "total_mutations": total_mutations,
77
+ "avg_false_positives_avoided": round(avg_false_pos_avoided, 2),
78
+ "timestamp": datetime.now().isoformat()
79
+ }
80
+
81
+
82
+ class HTMLReportGenerator:
83
+ """Generate HTML reports with visualizations."""
84
+
85
+ def __init__(self, config: MutationConfig):
86
+ self.config = config
87
+
88
+ def generate(self, results: Dict[str, Any], output_path: str) -> None:
89
+ """Generate HTML report."""
90
+ html = self._build_html(results)
91
+ Path(output_path).write_text(html, encoding="utf-8")
92
+
93
+ def _build_html(self, results: Dict[str, Any]) -> str:
94
+ """Build HTML content."""
95
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
96
+
97
+ # Generate rows
98
+ rows = ""
99
+ for file, data in results.items():
100
+ mutations = data.get("high_value_mutations", 0)
101
+ false_pos = data.get("potential_false_positives_avoided", 0)
102
+ rows += f"""
103
+ <tr>
104
+ <td>{file}</td>
105
+ <td>{mutations}</td>
106
+ <td>{false_pos:.1f}%</td>
107
+ </tr>
108
+ """
109
+
110
+ total_mutations = sum(d.get("high_value_mutations", 0) for d in results.values())
111
+ avg_false_pos = sum(d.get("potential_false_positives_avoided", 0) for d in results.values()) / len(results) if results else 0
112
+
113
+ return f"""
114
+ <!DOCTYPE html>
115
+ <html>
116
+ <head>
117
+ <title>Mutation Testing Report</title>
118
+ <meta charset="utf-8">
119
+ <meta name="viewport" content="width=device-width, initial-scale=1">
120
+ <style>
121
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
122
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #f5f5f5; padding: 20px; }}
123
+ .container {{ max-width: 1000px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
124
+ h1 {{ color: #333; margin-bottom: 10px; }}
125
+ .timestamp {{ color: #999; font-size: 14px; margin-bottom: 30px; }}
126
+ .metrics {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }}
127
+ .metric {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px; }}
128
+ .metric-value {{ font-size: 28px; font-weight: bold; }}
129
+ .metric-label {{ font-size: 12px; opacity: 0.9; margin-top: 5px; }}
130
+ table {{ width: 100%; border-collapse: collapse; }}
131
+ th {{ background: #f5f5f5; padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #ddd; }}
132
+ td {{ padding: 12px; border-bottom: 1px solid #eee; }}
133
+ tr:hover {{ background: #f9f9f9; }}
134
+ .badge {{ display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }}
135
+ .badge-success {{ background: #c6f6d5; color: #22543d; }}
136
+ .badge-warning {{ background: #fed7d7; color: #742a2a; }}
137
+ footer {{ margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 12px; }}
138
+ </style>
139
+ </head>
140
+ <body>
141
+ <div class="container">
142
+ <h1>🧬 Mutation Testing Report</h1>
143
+ <div class="timestamp">Generated on {timestamp}</div>
144
+
145
+ <div class="metrics">
146
+ <div class="metric">
147
+ <div class="metric-value">{total_mutations}</div>
148
+ <div class="metric-label">Total Mutations</div>
149
+ </div>
150
+ <div class="metric">
151
+ <div class="metric-value">{len(results)}</div>
152
+ <div class="metric-label">Files Analyzed</div>
153
+ </div>
154
+ <div class="metric">
155
+ <div class="metric-value">{avg_false_pos:.1f}%</div>
156
+ <div class="metric-label">False Positives Avoided</div>
157
+ </div>
158
+ </div>
159
+
160
+ <h2 style="margin-bottom: 15px; color: #333;">Results by File</h2>
161
+ <table>
162
+ <thead>
163
+ <tr>
164
+ <th>File</th>
165
+ <th>Mutations</th>
166
+ <th>False Positives Avoided</th>
167
+ </tr>
168
+ </thead>
169
+ <tbody>
170
+ {rows}
171
+ </tbody>
172
+ </table>
173
+
174
+ <footer>
175
+ <p>Generated by dataframe-mutator v0.1.1</p>
176
+ <p>6-10x faster mutation testing with smart AST-aware filtering</p>
177
+ </footer>
178
+ </div>
179
+ </body>
180
+ </html>
181
+ """
@@ -0,0 +1,177 @@
1
+ """Tests for advanced features."""
2
+
3
+ import pytest
4
+ import json
5
+ from pathlib import Path
6
+ import tempfile
7
+
8
+ # Import new features
9
+ from dataframe_mutator.config import MutationConfig
10
+ from dataframe_mutator.reports import BaselineTracker, JSONExporter, HTMLReportGenerator
11
+ from dataframe_mutator.integrations import (
12
+ CustomOperatorRegistry, OperatorSuggester, SlackNotifier
13
+ )
14
+
15
+
16
+ class TestMutationConfig:
17
+ """Test configuration system."""
18
+
19
+ def test_config_creation(self):
20
+ """Test creating default config."""
21
+ config = MutationConfig()
22
+ assert config.mutation_threshold == 0.85
23
+ assert config.parallel == True
24
+ assert config.output_format == "text"
25
+
26
+ def test_config_to_dict(self):
27
+ """Test config serialization."""
28
+ config = MutationConfig(mutation_threshold=0.9)
29
+ data = config.to_dict()
30
+ assert data["mutation_threshold"] == 0.9
31
+
32
+ def test_config_json_save_load(self):
33
+ """Test JSON config save/load."""
34
+ with tempfile.TemporaryDirectory() as tmpdir:
35
+ path = f"{tmpdir}/config.json"
36
+
37
+ config = MutationConfig(mutation_threshold=0.95)
38
+ config.to_json(path)
39
+
40
+ loaded = MutationConfig.from_json(path)
41
+ assert loaded.mutation_threshold == 0.95
42
+
43
+
44
+ class TestBaselineTracker:
45
+ """Test baseline tracking."""
46
+
47
+ def test_save_and_load_baseline(self):
48
+ """Test saving and loading baseline."""
49
+ with tempfile.TemporaryDirectory() as tmpdir:
50
+ config = MutationConfig(baseline_path=f"{tmpdir}/baseline.json")
51
+ tracker = BaselineTracker(config)
52
+
53
+ results = {
54
+ "file.py": {"high_value_mutations": 10}
55
+ }
56
+
57
+ tracker.save_baseline(results)
58
+ loaded = tracker.load_baseline()
59
+
60
+ assert loaded["file.py"]["high_value_mutations"] == 10
61
+
62
+ def test_compare_results(self):
63
+ """Test comparing current to baseline."""
64
+ with tempfile.TemporaryDirectory() as tmpdir:
65
+ config = MutationConfig(baseline_path=f"{tmpdir}/baseline.json")
66
+ tracker = BaselineTracker(config)
67
+
68
+ baseline = {"file.py": {"high_value_mutations": 10}}
69
+ tracker.save_baseline(baseline)
70
+
71
+ current = {"file.py": {"high_value_mutations": 12}}
72
+ comparison = tracker.compare(current)
73
+
74
+ assert comparison["file.py"]["change"] == 2
75
+
76
+
77
+ class TestJSONExporter:
78
+ """Test JSON export."""
79
+
80
+ def test_export_json(self):
81
+ """Test exporting to JSON."""
82
+ with tempfile.TemporaryDirectory() as tmpdir:
83
+ path = f"{tmpdir}/results.json"
84
+ config = MutationConfig()
85
+ exporter = JSONExporter(config)
86
+
87
+ results = {
88
+ "file.py": {
89
+ "high_value_mutations": 10,
90
+ "potential_false_positives_avoided": 98.5
91
+ }
92
+ }
93
+
94
+ exporter.export(results, path)
95
+
96
+ with open(path) as f:
97
+ data = json.load(f)
98
+
99
+ assert data["results"]["file.py"]["high_value_mutations"] == 10
100
+ assert data["summary"]["total_mutations"] == 10
101
+
102
+
103
+ class TestHTMLReportGenerator:
104
+ """Test HTML report generation."""
105
+
106
+ def test_generate_html_report(self):
107
+ """Test HTML report generation."""
108
+ with tempfile.TemporaryDirectory() as tmpdir:
109
+ path = f"{tmpdir}/report.html"
110
+ config = MutationConfig()
111
+ generator = HTMLReportGenerator(config)
112
+
113
+ results = {
114
+ "file.py": {
115
+ "high_value_mutations": 10,
116
+ "potential_false_positives_avoided": 98.5
117
+ }
118
+ }
119
+
120
+ generator.generate(results, path)
121
+
122
+ html = Path(path).read_text()
123
+ assert "<html>" in html.lower()
124
+ assert "mutation" in html.lower()
125
+ assert "10" in html
126
+
127
+
128
+ class TestOperatorSuggester:
129
+ """Test operator suggestions."""
130
+
131
+ def test_suggest_operators_for_code(self):
132
+ """Test suggesting operators for code."""
133
+ with tempfile.TemporaryDirectory() as tmpdir:
134
+ # Create test file
135
+ test_file = f"{tmpdir}/test.py"
136
+ Path(test_file).write_text("""
137
+ df.filter(pl.col('amount') > 0)
138
+ df.sum()
139
+ df.join(other)
140
+ """)
141
+
142
+ suggestions = OperatorSuggester.analyze_code(test_file)
143
+
144
+ assert "filter_mutations" in suggestions
145
+ assert "aggregation_mutations" in suggestions
146
+ assert "join_mutations" in suggestions
147
+
148
+
149
+ class TestCustomOperatorRegistry:
150
+ """Test custom operator registry."""
151
+
152
+ def test_registry_creation(self):
153
+ """Test creating operator registry."""
154
+ with tempfile.TemporaryDirectory() as tmpdir:
155
+ registry = CustomOperatorRegistry(tmpdir)
156
+ assert Path(tmpdir).exists()
157
+
158
+
159
+ class TestSlackNotifier:
160
+ """Test Slack integration."""
161
+
162
+ def test_notifier_creation(self):
163
+ """Test creating Slack notifier."""
164
+ notifier = SlackNotifier("https://hooks.slack.com/test")
165
+ assert notifier.webhook_url == "https://hooks.slack.com/test"
166
+
167
+ def test_notify_does_not_error(self):
168
+ """Test notification doesn't crash on invalid webhook."""
169
+ notifier = SlackNotifier("https://invalid.example.com")
170
+
171
+ results = {"file.py": {"high_value_mutations": 10}}
172
+ # Should not raise
173
+ notifier.notify(results)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ pytest.main([__file__, "-v"])