diff --git a/.gitignore b/.gitignore index 5eb00dd..b21c98f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .coverage -changedifferently.egg-info coverage.xml dist htmlcov +stackdiff.egg-info diff --git a/.vscode/settings.json b/.vscode/settings.json index 8fc84f7..0c6e0be 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ - "changedifferently" + "epilog", + "stackdiff" ] } diff --git a/MANIFEST.in b/MANIFEST.in index 5bf729f..23946b8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include changedifferently/version/VERSION +include stackdiff/version/VERSION diff --git a/Pipfile b/Pipfile index 737059e..294ebc2 100644 --- a/Pipfile +++ b/Pipfile @@ -4,15 +4,21 @@ verify_ssl = true name = "pypi" [packages] +ansiscape = "~=1.0" +boto3 = "~=1.18" +differently = "==1.0.0a6" +tabulate = "~=0.8" [dev-packages] black = "==21.9b0" +boto3-stubs = {extras = ["cloudformation"], version = "*"} flake8 = "*" isort = "*" mypy = "*" pytest = "*" pytest-cov = "*" twine = "*" +types-tabulate = "~=0.8" shellcheck-py = "*" yamllint = "*" diff --git a/Pipfile.lock b/Pipfile.lock index d0af2f4..5321c57 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "637d4933ebcc63d46659f21e5ae090491cd9f36c049f2e388816825cbbfdbaf4" + "sha256": "7836fb966c6d18e11f7ccc9cd557240779b9a0073920dd8e3f03b2ebbb3a1d1d" }, "pipfile-spec": 6, "requires": { @@ -15,7 +15,125 @@ } ] }, - "default": {}, + "default": { + "ansiscape": { + "hashes": [ + "sha256:c3602a832cc4e56782c988e415c2436dee64cb1f34b2eb08c73abf71f2cc3eda" + ], + "index": "pypi", + "version": "==1.1.0" + }, + "boto3": { + "hashes": [ + "sha256:11a6035060230e92327d4f10fef6bc44188b2cd68504012bc25ed62ac31d670b", + "sha256:1b8fac5f11ee1d185770d7619f2212e67e636ce190512770d0d36fd162739628" + ], + "index": "pypi", + "version": "==1.19.2" + }, + "botocore": { + "hashes": [ + "sha256:011360e79a4b843aa6591573cfa61e8eddc99b91adab1dfdb9a2b7f2c8511193", + "sha256:681d0d854d08beb9a5727152e95a884439b76cf19fb38d69ca2f17f1404d48ae" + ], + "markers": "python_version >= '3.6'", + "version": "==1.22.2" + }, + "differently": { + "hashes": [ + "sha256:28692ffdb36af07a00d4e462093c1634818ad3b612121f92068e5e03a2f6e8f0" + ], + "index": "pypi", + "version": "==1.0.0a6" + }, + "jmespath": { + "hashes": [ + "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", + "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.0" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" + }, + "pyyaml": { + "hashes": [ + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0" + }, + "s3transfer": { + "hashes": [ + "sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c", + "sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803" + ], + "markers": "python_version >= '3.6'", + "version": "==0.5.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "tabulate": { + "hashes": [ + "sha256:d7c013fe7abbc5e491394e10fa845f8f32fe54f8dc60c6622c6cf482d25d47e4", + "sha256:eb1d13f25760052e8931f2ef80aaf6045a6cceb47514db8beab24cded16f13a7" + ], + "index": "pypi", + "version": "==0.8.9" + }, + "urllib3": { + "hashes": [ + "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", + "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.7" + } + }, "develop": { "attrs": { "hashes": [ @@ -41,6 +159,25 @@ "markers": "python_version >= '3.6'", "version": "==4.1.0" }, + "boto3-stubs": { + "extras": [ + "cloudformation" + ], + "hashes": [ + "sha256:64cdcbd3fdfa673486cf7320dc0f7981ccd3ff296d942eecd650998b8cfc9147", + "sha256:85fae109fd96613e2c393f43790d5c2c89487c5139f9b3de839c5c1395ef7007" + ], + "index": "pypi", + "version": "==1.19.2" + }, + "botocore-stubs": { + "hashes": [ + "sha256:2f5b3bd462883b31edf05084c58817e1f712ba36c26db1ca0a1678a369bd171c", + "sha256:e9b3b9c9681d05d7d5430549d793920a30f20e44691ef928cc4551b4e3edc693" + ], + "markers": "python_version >= '3.6'", + "version": "==1.22.2" + }, "certifi": { "hashes": [ "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", @@ -294,6 +431,13 @@ "index": "pypi", "version": "==0.910" }, + "mypy-boto3-cloudformation": { + "hashes": [ + "sha256:5c5de7db3f6032026bdee88dc241c3cd73fadf03336486f25ef6d5c06735e773", + "sha256:e9e6e51cfe12e618ec27d29f620720edef60f3541bf46e3259c6e31b00b890f5" + ], + "version": "==1.19.2" + }, "mypy-extensions": { "hashes": [ "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", @@ -384,7 +528,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -535,7 +679,7 @@ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, "toml": { @@ -543,7 +687,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "tomli": { @@ -569,6 +713,14 @@ "index": "pypi", "version": "==3.4.2" }, + "types-tabulate": { + "hashes": [ + "sha256:7c28ca3b35f13eedefdc2cddc0b73e4b3e67b353174d9294a0687c558d1d1d85", + "sha256:f815bdcaa227777517edea91dcac85effe03f58dd350c7e19b8019f4fb14dacc" + ], + "index": "pypi", + "version": "==0.8.3" + }, "typing-extensions": { "hashes": [ "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e", @@ -582,7 +734,7 @@ "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.26.7" }, "webencodings": { diff --git a/README.md b/README.md index aa5e985..cded4c5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# changedifferently -Visualises the changes described by an Amazon Web Services CloudFormation change set +# stackdiff + +Visualises the changes described by an Amazon Web Services CloudFormation stack change set diff --git a/build.sh b/build.sh index a8d20bf..7499fd7 100755 --- a/build.sh +++ b/build.sh @@ -10,7 +10,7 @@ else version="-1.-1.-1" fi -echo "${version}" > changedifferently/version/VERSION +echo "${version}" > stackdiff/version/VERSION rm -rf dist python setup.py bdist_wheel rm -rf build diff --git a/lint.sh b/lint.sh index 2551550..f7dc21a 100755 --- a/lint.sh +++ b/lint.sh @@ -18,5 +18,5 @@ else fi flake8 . -mypy changedifferently +mypy stackdiff mypy tests diff --git a/pyproject.toml b/pyproject.toml index 1e4a455..f2a698e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,6 @@ profile = 'black' skip = '.venv' [tool.pytest.ini_options] -addopts = '--cov=changedifferently --cov-branch --cov-report=html --cov-report=term-missing:skip-covered --cov-report=xml --no-cov-on-fail' +addopts = '--cov=stackdiff --cov-branch --cov-report=html --cov-report=term-missing:skip-covered --cov-report=xml --no-cov-on-fail' log_cli = 1 testpaths = 'tests' diff --git a/setup.py b/setup.py index 068b9c3..b92761c 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup # pyright: reportMissingTypeStubs=false -from changedifferently.version import get_version +from stackdiff.version import get_version readme_path = Path(__file__).parent / "README.md" @@ -36,21 +36,26 @@ author="Cariad Eccleston", author_email="cariad@cariad.earth", classifiers=classifiers, - description="Visualises the changes described by an Amazon Web Services CloudFormation change set", + description="Visualises the changes described by an Amazon Web Services CloudFormation stack change set", + entry_points={ + "console_scripts": [ + "stackdiff=stackdiff.__main__:cli_entry", + ], + }, include_package_data=True, license="MIT", long_description=long_description, long_description_content_type="text/markdown", - name="changedifferently", + name="stackdiff", packages=[ - "changedifferently", - "changedifferently.version", + "stackdiff", + "stackdiff.version", ], package_data={ - "changedifferently": ["py.typed"], - "changedifferently.version": ["py.typed"], + "stackdiff": ["py.typed"], + "stackdiff.version": ["py.typed"], }, python_requires=">=3.8", - url="https://github.com/cariad/changedifferently", + url="https://github.com/cariad/stackdiff", version=version, ) diff --git a/changedifferently/__init__.py b/stackdiff/__init__.py similarity index 100% rename from changedifferently/__init__.py rename to stackdiff/__init__.py diff --git a/stackdiff/__main__.py b/stackdiff/__main__.py new file mode 100644 index 0000000..60ab218 --- /dev/null +++ b/stackdiff/__main__.py @@ -0,0 +1,32 @@ +from argparse import ArgumentParser +from sys import stdout + +from boto3.session import Session + +from stackdiff.stack_diff import StackDiff +from stackdiff.version import get_version + + +def cli_entry() -> None: + parser = ArgumentParser( + description="Visualises the changes described by an Amazon Web Services CloudFormation stack change set.", + epilog="Made with love by Cariad Eccleston: https://github.com/cariad/stackdiff", + ) + + parser.add_argument("--change", help="change set ARN, ID or name") + parser.add_argument("--stack", help="stack ARN, ID or name") + parser.add_argument("--version", action="store_true", help="print the version") + + args = parser.parse_args() + + if args.version: + print(get_version()) + exit(0) + + cs = StackDiff(change=args.change, session=Session(), stack=args.stack) + cs.render_differences(stdout) + cs.render_changes(stdout) + + +if __name__ == "__main__": + cli_entry() diff --git a/changedifferently/py.typed b/stackdiff/py.typed similarity index 100% rename from changedifferently/py.typed rename to stackdiff/py.typed diff --git a/stackdiff/stack_diff.py b/stackdiff/stack_diff.py new file mode 100644 index 0000000..ce2f1e5 --- /dev/null +++ b/stackdiff/stack_diff.py @@ -0,0 +1,104 @@ +from functools import cached_property +from typing import IO, Optional + +from ansiscape import green, heavy, yellow +from boto3.session import Session +from differently import render +from tabulate import tabulate + + +class StackDiff: + """ + Visualises the changes described by an Amazon Web Services CloudFormation + change set. + + Arguments: + change: ARN, ID or name of the CloudFormation change set to visualise + session: boto3 session (defaults to a new session) + stack: ARN, ID or name of the change set's CloudFormation stack + """ + + def __init__( + self, + change: str, + stack: str, + session: Optional[Session] = None, + ) -> None: + + session = session or Session() + + self.change = change + self.client = session.client( + "cloudformation" + ) # pyright: reportUnknownMemberType=false + self.stack = stack + + @cached_property + def change_template(self) -> str: + response = self.client.get_template( + ChangeSetName=self.change, + StackName=self.stack, + TemplateStage="Original", + ) + return response.get("TemplateBody", "") + + def render_changes(self, writer: IO[str]) -> None: + """Renders a visualisation of the changes to `writer`.""" + + response = self.client.describe_change_set( + ChangeSetName=self.change, + StackName=self.stack, + ) + + rows = [ + [ + heavy("Logical ID").encoded, + heavy("Physical ID").encoded, + heavy("Resource Type").encoded, + heavy("Action").encoded, + ] + ] + + for change in response["Changes"]: + rc = change.get("ResourceChange", None) + if not rc: + continue + + if rc["Action"] == "Add": + color = green + else: + color = yellow + + replacement = rc.get("Replacement", "False").lower() + will_replace = replacement == "true" + + action: str = rc["Action"] + + if action == "Modify": + action = "Replace ⚠️" if will_replace else "Update" + + rows.append( + [ + color(rc["LogicalResourceId"]).encoded, + color(rc["PhysicalResourceId"]).encoded, + color(rc["ResourceType"]).encoded, + color(action).encoded, + ] + ) + + t = tabulate(rows, headers="firstrow", tablefmt="plain") + + writer.write("\n" + t + "\n\n") + + def render_differences(self, writer: IO[str]) -> None: + """Renders a visualisation of the differences to `writer`.""" + + render(self.stack_template, self.change_template, writer) + + @cached_property + def stack_template(self) -> str: + response = self.client.get_template( + StackName=self.stack, + TemplateStage="Original", + ) + return response.get("TemplateBody", "") diff --git a/changedifferently/version/VERSION b/stackdiff/version/VERSION similarity index 100% rename from changedifferently/version/VERSION rename to stackdiff/version/VERSION diff --git a/changedifferently/version/__init__.py b/stackdiff/version/__init__.py similarity index 100% rename from changedifferently/version/__init__.py rename to stackdiff/version/__init__.py diff --git a/changedifferently/version/py.typed b/stackdiff/version/py.typed similarity index 100% rename from changedifferently/version/py.typed rename to stackdiff/version/py.typed diff --git a/tests/test_version.py b/tests/test_version.py index 12b804e..c6b160b 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,4 +1,4 @@ -from changedifferently.version import get_version +from stackdiff.version import get_version def test_get_version() -> None: