Skip to content

Commit 488380f

Browse files
2 parents 7dc98aa + eacd83c commit 488380f

7 files changed

Lines changed: 217 additions & 14 deletions

File tree

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,15 @@ Thumbs.db
7171
research/
7272
fixtures/generated/
7373
.ruff_cache/
74+
75+
# Merge artifacts and cache (added by workspace stabilization)
76+
*.pyc
77+
*.pyo
78+
*.pyd
79+
*.orig
80+
*.BACKUP.*
81+
*.BASE.*
82+
*.LOCAL.*
83+
*.REMOTE.*
84+
local.db
85+
*.sqlite3

.pre-commit-config.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
repos:
2+
- repo: https://github.com/pre-commit/pre-commit-hooks
3+
rev: v5.0.0
4+
hooks:
5+
- id: trailing-whitespace
6+
- id: end-of-file-fixer
7+
- id: check-yaml
8+
- id: check-toml
9+
- id: check-added-large-files
10+
- id: detect-private-key
11+
12+
- repo: https://github.com/astral-sh/ruff-pre-commit
13+
rev: v0.12.0
14+
hooks:
15+
- id: ruff
16+
args: ["--fix"]
17+
- id: ruff-format

README.md

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,8 @@ Preview infrastructure changes with human-readable diffs, cost impact estimation
1010
![Python](https://img.shields.io/badge/python-3.10%2B-blue)
1111
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/Coding-Dev-Tools/deploydiff/blob/main/LICENSE)
1212
[![Open Source Alternative](https://img.shields.io/badge/Open_Source_Alternative-%E2%87%92-blue?logo=opensourceinitiative)](https://www.opensourcealternative.to/project/deploydiff)
13-
[![CI](https://github.com/Coding-Dev-Tools/deploydiff/actions/workflows/ci.yml/badge.svg)](https://github.com/Coding-Dev-Tools/deploydiff/actions/workflows/ci.yml)
14-
[![PyPI](https://img.shields.io/pypi/v/deploydiff)](https://pypi.org/project/deploydiff/)
15-
16-
17-
18-
Real-world scenarios:
19-
- **CI/CD gating**: Gate deploys on cost thresholds or destructive changes — no more surprise $1000 bills
20-
- **Pre-deploy review**: Send a readable diff to your team instead of raw `terraform plan` output
21-
- **Cost governance**: Compare before/after costs per resource before approving any infrastructure change
22-
- **Incident recovery**: Generate rollback commands instantly instead of hand-typing reverse plans
13+
|[![LibHunt](https://img.shields.io/badge/LibHunt-%E2%87%92-blue?logo=codeigniter)](https://www.libhunt.com/r/Coding-Dev-Tools/deploydiff)
14+
|[![PyPI](https://img.shields.io/pypi/v/deploydiff)](https://pypi.org/project/deploydiff/)
2315

2416
## Installation
2517

@@ -150,7 +142,7 @@ DeployDiff is one of 11 tools in the Revenue Holdings suite. One license covers
150142
---
151143

152144
<p align="center">
153-
<sub>Part of <a href="https://coding-dev-tools.github.io/revenueholdings.dev/">Revenue Holdings</a> — CLI tools built by autonomous AI.</sub>
145+
<sub>Part of <a href="https://coding-dev-tools.github.io/devforge/">Revenue Holdings</a> — CLI tools built by autonomous AI.</sub>
154146
</p>
155147

156148
## License

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,16 @@ Repository = "https://github.com/Coding-Dev-Tools/deploydiff"
4444
Documentation = "https://github.com/Coding-Dev-Tools/deploydiff#readme"
4545
"Issue Tracker" = "https://github.com/Coding-Dev-Tools/deploydiff/issues"
4646

47+
[tool.setuptools]
48+
include-package-data = true
49+
4750
[tool.setuptools.packages.find]
4851
where = ["src"]
4952

5053

5154
[tool.setuptools.package-data]
52-
"*" = ["py.typed"]
55+
deploydiff = ["py.typed"]
56+
5357
[tool.pytest.ini_options]
5458
testpaths = ["tests"]
5559

@@ -62,4 +66,4 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
6266
ignore = ["E501"]
6367

6468
[tool.ruff.lint.isort]
65-
known-first-party = ["*"]
69+
known-first-party = ["deploydiff"]

src/deploydiff/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def rollback(terraform_file, cloudformation_file, pulumi_file) -> None:
114114
console.print(cmd)
115115

116116

117+
117118
def _load_plan(
118119
terraform_file: str | None,
119120
cloudformation_file: str | None,

tests/test_deploydiff.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Tests for DeployDiff CLI - models, parsers, cost estimator, rollback, and CLI."""
22

33
import json
4+
45
import pytest
56
from click.testing import CliRunner
7+
68
from deploydiff.cli import main
79
from deploydiff.cloudformation_parser import parse_cloudformation_changeset
810
from deploydiff.cost_estimator import estimate_costs
@@ -497,6 +499,7 @@ class TestRenderer:
497499
def test_render_basic_plan(self, sample_terraform_plan):
498500
"""Render should not raise errors."""
499501
from io import StringIO
502+
500503
from rich.console import Console
501504
plan = parse_terraform_plan(sample_terraform_plan)
502505
buf = StringIO()
@@ -509,6 +512,7 @@ def test_render_basic_plan(self, sample_terraform_plan):
509512
def test_render_empty_plan(self):
510513
"""Render an empty plan shows no changes."""
511514
from io import StringIO
515+
512516
from rich.console import Console
513517
plan = DeployPlan(source=ChangeSource.TERRAFORM, changes=[])
514518
buf = StringIO()
@@ -521,6 +525,7 @@ def test_render_empty_plan(self):
521525
def test_render_verbose_terraform(self, sample_terraform_plan):
522526
"""Verbose mode shows before/after details for each change."""
523527
from io import StringIO
528+
524529
from rich.console import Console
525530
plan = parse_terraform_plan(sample_terraform_plan)
526531
buf = StringIO()
@@ -533,6 +538,7 @@ def test_render_verbose_terraform(self, sample_terraform_plan):
533538
def test_render_verbose_with_sensitive(self):
534539
"""Verbose mode masks sensitive values."""
535540
from io import StringIO
541+
536542
from rich.console import Console
537543
change = ResourceChange(
538544
address="aws_db_instance.db",
@@ -558,6 +564,7 @@ def test_render_verbose_with_sensitive(self):
558564
def test_render_destructive_change_warning(self, sample_terraform_plan):
559565
"""Destructive changes trigger a warning message."""
560566
from io import StringIO
567+
561568
from rich.console import Console
562569
plan = parse_terraform_plan(sample_terraform_plan)
563570
buf = StringIO()
@@ -570,6 +577,7 @@ def test_render_destructive_change_warning(self, sample_terraform_plan):
570577
def test_render_plan_without_destructive_changes(self):
571578
"""Plan with only creates/updates should not show destructive warning."""
572579
from io import StringIO
580+
573581
from rich.console import Console
574582
changes = [
575583
ResourceChange(
@@ -597,6 +605,7 @@ def test_render_plan_without_destructive_changes(self):
597605
def test_render_cfn_plan(self, sample_cfn_changeset):
598606
"""Render a CloudFormation plan."""
599607
from io import StringIO
608+
600609
from rich.console import Console
601610
plan = parse_cloudformation_changeset(sample_cfn_changeset)
602611
buf = StringIO()
@@ -609,6 +618,7 @@ def test_render_cfn_plan(self, sample_cfn_changeset):
609618
def test_render_pulumi_plan(self, sample_pulumi_preview):
610619
"""Render a Pulumi plan."""
611620
from io import StringIO
621+
612622
from rich.console import Console
613623
plan = parse_pulumi_preview(sample_pulumi_preview)
614624
buf = StringIO()
@@ -620,6 +630,7 @@ def test_render_pulumi_plan(self, sample_pulumi_preview):
620630
def test_render_replacement(self):
621631
"""Render a plan with a replacement change."""
622632
from io import StringIO
633+
623634
from rich.console import Console
624635
change = ResourceChange(
625636
address="module.vpc.aws_nat_gateway.main",
@@ -640,9 +651,11 @@ def test_render_replacement(self):
640651

641652
def test_render_change_details_missing_data(self):
642653
"""Render change details with no before/after should not error."""
643-
from deploydiff.diff_renderer import _render_change_details
644654
from io import StringIO
655+
645656
from rich.console import Console
657+
658+
from deploydiff.diff_renderer import _render_change_details
646659
change = ResourceChange(
647660
address="aws_instance.web",
648661
action=ChangeAction.CREATE,

tests/test_edge_cases.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Targeted edge-case tests for DeployDiff.
2+
3+
Covers uncovered paths in CLI, cost estimator, rollback, and packaging config.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import json
9+
from io import StringIO
10+
from pathlib import Path
11+
12+
import tomllib
13+
from rich.console import Console
14+
15+
from deploydiff.cli import _load_plan, _render_costs
16+
from deploydiff.cost_estimator import DEFAULT_PRICING, _load_pricing
17+
from deploydiff.models import ChangeAction, ChangeSource, CostEstimate, DeployPlan, ResourceChange
18+
from deploydiff.rollback import _pulumi_rollback, generate_rollback_commands
19+
20+
21+
class TestCLIEdgeCases:
22+
"""Tests for uncovered CLI error paths."""
23+
24+
def test_load_plan_no_input_returns_none(self):
25+
"""_load_plan with no sources returns None (cli.py:142)."""
26+
plan = _load_plan(None, None, None)
27+
assert plan is None
28+
29+
def test_render_cost_decrease(self):
30+
"""_render_costs with cost decrease should mention decrease (cli.py:177-178)."""
31+
plan = DeployPlan(source=ChangeSource.TERRAFORM, changes=[])
32+
estimates = [
33+
CostEstimate(
34+
resource_address="aws_instance.old",
35+
monthly_cost_before=100.0,
36+
monthly_cost_after=50.0,
37+
),
38+
]
39+
# total_monthly_delta is computed from cost_estimates
40+
plan.cost_estimates = estimates
41+
assert plan.total_monthly_delta < 0
42+
43+
buf = StringIO()
44+
console = Console(file=buf, width=120)
45+
_render_costs(estimates, plan, console)
46+
output = buf.getvalue()
47+
assert "decrease" in output
48+
49+
def test_render_cost_increase(self):
50+
"""_render_costs with cost increase should mention increase."""
51+
plan = DeployPlan(source=ChangeSource.TERRAFORM, changes=[])
52+
estimates = [
53+
CostEstimate(
54+
resource_address="aws_instance.web",
55+
monthly_cost_before=0.0,
56+
monthly_cost_after=100.0,
57+
),
58+
]
59+
plan.cost_estimates = estimates
60+
buf = StringIO()
61+
console = Console(file=buf, width=120)
62+
_render_costs(estimates, plan, console)
63+
output = buf.getvalue()
64+
assert "increase" in output
65+
66+
67+
class TestCostEstimatorEdgeCases:
68+
"""Tests for uncovered cost estimator paths."""
69+
70+
def test_load_pricing_nonexistent_file(self):
71+
"""_load_pricing with nonexistent file returns defaults (cost_estimator.py:234)."""
72+
pricing = _load_pricing("/nonexistent/pricing.json")
73+
assert pricing == DEFAULT_PRICING
74+
75+
def test_load_pricing_custom_type_not_in_defaults(self, tmp_path):
76+
"""_load_pricing with custom resource type merges correctly (cost_estimator.py:245)."""
77+
pricing_file = tmp_path / "custom_pricing.json"
78+
custom = {"aws_lambda_function": {"custom_size": 5.0}}
79+
with open(pricing_file, "w") as f:
80+
json.dump(custom, f)
81+
82+
pricing = _load_pricing(str(pricing_file))
83+
assert "aws_lambda_function" in pricing
84+
assert pricing["aws_lambda_function"]["custom_size"] == 5.0
85+
# Defaults should still be present
86+
assert "t3.micro" in pricing["aws_instance"]
87+
88+
89+
class TestRollbackEdgeCases:
90+
"""Tests for uncovered rollback paths."""
91+
92+
def test_pulumi_rollback_single_create(self):
93+
"""_pulumi_rollback creates destroy command for create changes."""
94+
changes = [
95+
ResourceChange(
96+
address="aws_instance.web",
97+
action=ChangeAction.CREATE,
98+
resource_type="aws_instance",
99+
resource_name="web",
100+
source=ChangeSource.PULUMI,
101+
after={"ami": "ami-123"},
102+
),
103+
]
104+
plan = DeployPlan(source=ChangeSource.PULUMI, changes=changes)
105+
commands = _pulumi_rollback(plan)
106+
assert any("Destroy newly created" in c for c in commands)
107+
assert any("pulumi destroy" in c for c in commands)
108+
109+
def test_pulumi_rollback_unsupported_source_fallback(self):
110+
"""generate_rollback_commands for unmatched source returns fallback msg."""
111+
# This takes the final `return` path in generate_rollback_commands
112+
# Since all three enum members are handled, we need to bypass the if chain
113+
# by testing the function structure. The unhandled-source path exists
114+
# as future-proofing. Test that terraform, cloudformation and pulumi
115+
# all produce meaningful output.
116+
plan = DeployPlan(source=ChangeSource.TERRAFORM, changes=[])
117+
cmds = generate_rollback_commands(plan)
118+
assert len(cmds) > 1
119+
assert "Terraform" in cmds[0]
120+
121+
def test_cloudformation_rollback_no_raw_data(self):
122+
"""_cloudformation_rollback with no raw_data uses STACK_NAME."""
123+
from deploydiff.rollback import _cloudformation_rollback
124+
125+
plan = DeployPlan(source=ChangeSource.CLOUDFORMATION, changes=[])
126+
commands = _cloudformation_rollback(plan)
127+
assert any("STACK_NAME" in c for c in commands)
128+
129+
def test_cloudformation_rollback_with_raw_data(self):
130+
"""_cloudformation_rollback with raw_data uses the provided stack name."""
131+
from deploydiff.rollback import _cloudformation_rollback
132+
133+
plan = DeployPlan(
134+
source=ChangeSource.CLOUDFORMATION,
135+
changes=[],
136+
raw_data={"StackName": "my-app-stack"},
137+
)
138+
commands = _cloudformation_rollback(plan)
139+
assert any("my-app-stack" in c for c in commands)
140+
assert not any("STACK_NAME" in c for c in commands)
141+
142+
143+
class TestPackagingQuality:
144+
"""Tests for py.typed packaging config."""
145+
146+
def test_package_data_includes_py_typed(self):
147+
"""pyproject.toml should have package-data config for py.typed."""
148+
pyproject = Path(__file__).parent.parent / "pyproject.toml"
149+
with open(pyproject, "rb") as f:
150+
data = tomllib.load(f)
151+
pkg_data = data.get("tool", {}).get("setuptools", {}).get("package-data", {})
152+
assert "deploydiff" in pkg_data, \
153+
"Expected [tool.setuptools.package-data] section for 'deploydiff'"
154+
assert "py.typed" in pkg_data["deploydiff"], \
155+
f"Expected 'py.typed' in package-data, got {pkg_data['deploydiff']}"
156+
157+
def test_ruff_known_first_party(self):
158+
"""ruff known-first-party should be ['deploydiff'], not ['*']."""
159+
pyproject = Path(__file__).parent.parent / "pyproject.toml"
160+
with open(pyproject, "rb") as f:
161+
data = tomllib.load(f)
162+
isort_cfg = data.get("tool", {}).get("ruff", {}).get("lint", {}).get("isort", {})
163+
kfp = isort_cfg.get("known-first-party", [])
164+
assert kfp == ["deploydiff"], f"known-first-party should be ['deploydiff'], got {kfp}"

0 commit comments

Comments
 (0)