diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..ae1ccb3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/wxw-matt/devcontainer-features/command_runner:0": {}, + "ghcr.io/devcontainers-contrib/features/pylint:2": {}, + "ghcr.io/akhildevelops/devcontainer-features/pip:0": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip3 install --user -r requirements.txt && pip3 install --user -r requirements-dev.txt", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "njpwerner.autodocstring", + "Gruntfuggly.todo-tree", + "GitHub.copilot", + "github.vscode-github-actions", + "GitHub.vscode-pull-request-github", + "eamodio.gitlens", + "zaaack.markdown-editor", + "yzhang.markdown-all-in-one", + "evendead.help-me-add", + "charliermarsh.ruff", + "streetsidesoftware.code-spell-checker", + "njqdev.vscode-python-typehint" + ] + } + } + // Configure tool-specific properties. + // "customizations": {}, + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} \ No newline at end of file diff --git a/.github/workflows/check_format_and_lint.yml b/.github/workflows/check_format_and_lint.yml new file mode 100644 index 0000000..10ae4a8 --- /dev/null +++ b/.github/workflows/check_format_and_lint.yml @@ -0,0 +1,34 @@ +on: + pull_request: + types: + - opened + - synchronize + - reopened + - closed + branches: + - main + +jobs: + check_code: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Python + uses: actions/setup-python@v2 + with: + python-version: 3.11 + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Check code format with ruff + run: ruff format --check . + + - name: Check code with ruff + run: ruff check . + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b5472e0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,42 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "InfraPatch CLI: Report", + "type": "python", + "request": "launch", + "module": "infrapatch.cli", + "args": [ + "--debug", + "report" + ], + "justMyCode": true + }, + { + "name": "InfraPatch CLI: Update", + "type": "python", + "request": "launch", + "module": "infrapatch.cli", + "args": [ + "--debug", + "update" + ], + "justMyCode": true + }, + { + "name": "InfraPatch CLI: custom", + "type": "python", + "request": "launch", + "module": "infrapatch.cli", + "args": "${input:custom_args}", + "justMyCode": true + } + ], + "inputs": [ + { + "id": "custom_args", + "description": "Space separated list of arguments to pass to the infrapatch cli", + "type": "promptString" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be312f9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,43 @@ +{ + "terminal.integrated.env.osx": { + "PYTHONPATH": "${workspaceFolder}", + }, + "terminal.integrated.env.linux": { + "PYTHONPATH": "${workspaceFolder}", + }, + "terminal.integrated.env.windows": { + "PYTHONPATH": "${workspaceFolder}", + }, + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": true, + "source.organizeImports": true + }, + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "files.autoSave": "afterDelay", + "python.analysis.inlayHints.functionReturnTypes": true, + "python.analysis.inlayHints.pytestParameters": true, + "python.analysis.inlayHints.variableTypes": true, + "python.languageServer": "Default", + "editor.defaultFormatter": "charliermarsh.ruff", + "python.missingPackage.severity": "Error", + "python.terminal.activateEnvInCurrentTerminal": true, + "cSpell.words": [ + "infrapatch" + ], + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "python.analysis.autoImportCompletions": true, + "python.analysis.fixAll": [ + "source.unusedImports", + "source.convertImportFormat" + ], + "python.analysis.typeCheckingMode": "basic", + "python.analysis.diagnosticMode": "workspace", + "editor.guides.indentation": false, + "editor.guides.bracketPairs": true, + "editor.guides.highlightActiveBracketPair": true, + "editor.guides.bracketPairsHorizontal": false, +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a0992ef --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,31 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "format with ruff", + "type": "shell", + "command": "ruff", + "args": [ + "format", + "." + ], + "presentation": { + "reveal": "always" + }, + "problemMatcher": [] + }, + { + "label": "ruff lint project", + "type": "shell", + "command": "ruff", + "args": [ + "check", + "." + ], + "presentation": { + "reveal": "always" + }, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/infrapatch/action/__main__.py b/infrapatch/action/__main__.py index 7658a1f..27f0bd6 100644 --- a/infrapatch/action/__main__.py +++ b/infrapatch/action/__main__.py @@ -1,7 +1,7 @@ -import json +import logging as log import subprocess from pathlib import Path -import logging as log + import click from github import Auth, Github from github.PullRequest import PullRequest @@ -9,7 +9,6 @@ from infrapatch.core.composition import build_main_handler from infrapatch.core.log_helper import catch_exception, setup_logging from infrapatch.core.models.versioned_terraform_resources import get_upgradable_resources -from pygit2 import Repository, Remote @click.group(invoke_without_command=True) @@ -23,16 +22,26 @@ @click.option("--report-only", is_flag=True) @click.option("--working-directory") @catch_exception(handle=Exception) -def main(debug: bool, default_registry_domain: str, registry_secrets_string: str, github_token: str, target_branch: str, head_branch: str, repository_name: str, report_only: bool, - working_directory: str): +def main( + debug: bool, + default_registry_domain: str, + registry_secrets_string: str, + github_token: str, + target_branch: str, + head_branch: str, + repository_name: str, + report_only: bool, + working_directory: Path, +): setup_logging(debug) - log.debug(f"Running infrapatch with the following parameters: " - f"default_registry_domain={default_registry_domain}, " - f"registry_secrets_string={registry_secrets_string}, " - f"github_token={github_token}, " - f"report_only={report_only}, " - f"working_directory={working_directory}" - ) + log.debug( + f"Running infrapatch with the following parameters: " + f"default_registry_domain={default_registry_domain}, " + f"registry_secrets_string={registry_secrets_string}, " + f"github_token={github_token}, " + f"report_only={report_only}, " + f"working_directory={working_directory}" + ) credentials = {} working_directory = Path(working_directory) if registry_secrets_string is not None: @@ -74,12 +83,12 @@ def create_pr(github_token, head_branch, repository_name, target_branch) -> Pull token = Auth.Token(github_token) github = Github(auth=token) repo = github.get_repo(repository_name) - pull = repo.get_pulls(state='open', sort='created', base=head_branch, head=target_branch) + pull = repo.get_pulls(state="open", sort="created", base=head_branch, head=target_branch) if pull.totalCount != 0: log.info(f"Pull request found from '{target_branch}' to '{head_branch}'") return pull[0] log.info(f"No pull request found from '{target_branch}' to '{head_branch}'. Creating a new one.") - return repo.create_pull(title=f"InfraPatch Module and Provider Update", body=f"InfraPatch Module and Provider Update", base=head_branch, head=target_branch) + return repo.create_pull(title="InfraPatch Module and Provider Update", body="InfraPatch Module and Provider Update", base=head_branch, head=target_branch) def get_credentials_from_string(credentials_string: str) -> dict: diff --git a/infrapatch/cli/__init__.py b/infrapatch/cli/__init__.py index e344246..27fdca4 100644 --- a/infrapatch/cli/__init__.py +++ b/infrapatch/cli/__init__.py @@ -1 +1 @@ -__version__ = "0.0.3" \ No newline at end of file +__version__ = "0.0.3" diff --git a/infrapatch/cli/cli.py b/infrapatch/cli/cli.py index ed56178..0d632b4 100644 --- a/infrapatch/cli/cli.py +++ b/infrapatch/cli/cli.py @@ -1,20 +1,13 @@ -import json -import logging as log -from functools import partial, wraps from pathlib import Path +from typing import Union import click -from rich.console import Console -import infrapatch.core.constants as cs from infrapatch.cli.__init__ import __version__ from infrapatch.core.composition import MainHandler, build_main_handler from infrapatch.core.log_helper import catch_exception, setup_logging -from infrapatch.core.utils.hcl_edit_cli import HclEditCli -from infrapatch.core.utils.hcl_handler import HclHandler -from infrapatch.core.utils.registry_handler import RegistryHandler -main_handler: MainHandler = None +main_handler: Union[MainHandler, None] = None @click.group(invoke_without_command=True) @@ -29,33 +22,46 @@ def main(debug: bool, version: bool, credentials_file_path: str, default_registr exit(0) setup_logging(debug) global main_handler - main_handler = build_main_handler(default_registry_domain, credentials_file_path) + credentials_file = None + if credentials_file_path is not None: + credentials_file = Path(credentials_file_path) + main_handler = build_main_handler(default_registry_domain, credentials_file) # noinspection PyUnresolvedReferences @main.command() -@click.option("project_root", "--project-root", default=None, help="Root directory of the project. If not specified, the current working directory is used.") +@click.option("--project-root-path", default=None, help="Root directory of the project. If not specified, the current working directory is used.") @click.option("--only-upgradable", is_flag=True, help="Only show providers and modules that can be upgraded.") @click.option("--dump-json-statistics", is_flag=True, help="Creates a json file containing statistics about the found resources and there update status as json file in the cwd.") @catch_exception(handle=Exception) -def report(project_root: str, only_upgradable: bool, dump_json_statistics: bool): +def report(project_root_path: str, only_upgradable: bool, dump_json_statistics: bool): """Finds all modules and providers in the project_root and prints the newest version.""" - if project_root is None: project_root = Path.cwd() + if project_root_path is None: + project_root = Path.cwd() + else: + project_root = Path(project_root_path) global main_handler - resources = main_handler.get_all_terraform_resources(Path(project_root)) + if main_handler is None: + raise Exception("main_handler not initialized.") + resources = main_handler.get_all_terraform_resources(project_root) main_handler.print_resource_table(resources, only_upgradable) main_handler.dump_statistics(resources, dump_json_statistics) @main.command() -@click.option("project_root", "--project-root", default=None, help="Root directory of the project. If not specified, the current working directory is used.") +@click.option("--project-root-path", default=None, help="Root directory of the project. If not specified, the current working directory is used.") @click.option("--confirm", is_flag=True, help="Apply changes without confirmation.") @click.option("--dump-json-statistics", is_flag=True, help="Creates a json file containing statistics about the updated resources in the cwd.") @catch_exception(handle=Exception) -def update(project_root: str, confirm: bool, dump_json_statistics: bool): +def update(project_root_path: str, confirm: bool, dump_json_statistics: bool): """Finds all modules and providers in the project_root and updates them to the newest version.""" - if project_root is None: project_root = Path.cwd() + if project_root_path is None: + project_root = Path.cwd() + else: + project_root = Path(project_root_path) global main_handler - resources = main_handler.get_all_terraform_resources(Path(project_root)) + if main_handler is None: + raise Exception("main_handler not initialized.") + resources = main_handler.get_all_terraform_resources(project_root) main_handler.update_resources(resources, confirm, Path(project_root)) main_handler.dump_statistics(resources, dump_json_statistics) diff --git a/infrapatch/core/composition.py b/infrapatch/core/composition.py index f2bde81..9bd410f 100644 --- a/infrapatch/core/composition.py +++ b/infrapatch/core/composition.py @@ -1,9 +1,9 @@ import json import logging as log from pathlib import Path +from typing import Sequence, Union import click -import rich from git import Repo from rich import progress from rich.console import Console @@ -11,18 +11,24 @@ import infrapatch.core.constants as cs from infrapatch.core.credentials_helper import get_registry_credentials -from infrapatch.core.models.versioned_terraform_resources import VersionedTerraformResource, TerraformModule, TerraformProvider, get_upgradable_resources, ResourceStatus, \ - from_terraform_resources_to_dict_list +from infrapatch.core.models.versioned_terraform_resources import ( + VersionedTerraformResource, + TerraformModule, + TerraformProvider, + get_upgradable_resources, + ResourceStatus, + from_terraform_resources_to_dict_list, +) from infrapatch.core.utils.hcl_edit_cli import HclEditCliException, HclEditCli from infrapatch.core.utils.hcl_handler import HclHandler from infrapatch.core.utils.registry_handler import RegistryHandler -def build_main_handler(default_registry_domain: str, credentials_file_path: str = None, credentials_dict: dict = None): +def build_main_handler(default_registry_domain: str, credentials_file: Union[Path, None] = None, credentials_dict: Union[dict, None] = None): hcl_edit_cli = HclEditCli() hcl_handler = HclHandler(hcl_edit_cli) if credentials_dict is None: - credentials_dict = get_registry_credentials(hcl_handler, credentials_file_path) + credentials_dict = get_registry_credentials(hcl_handler, credentials_file) registry_handler = RegistryHandler(default_registry_domain, credentials_dict) return MainHandler(hcl_handler, registry_handler, Console(width=cs.CLI_WIDTH)) @@ -33,7 +39,7 @@ def __init__(self, hcl_handler: HclHandler, registry_handler: RegistryHandler, c self.registry_handler = registry_handler self._console = console - def get_all_terraform_resources(self, project_root: Path) -> list[VersionedTerraformResource]: + def get_all_terraform_resources(self, project_root: Path) -> Sequence[VersionedTerraformResource]: log.info(f"Searching for .tf files in {project_root.absolute().as_posix()} ...") terraform_files = self.hcl_handler.get_all_terraform_files(project_root) if len(terraform_files) == 0: @@ -45,7 +51,7 @@ def get_all_terraform_resources(self, project_root: Path) -> list[VersionedTerra resource.newest_version = self.registry_handler.get_newest_version(resource) return resources - def print_resource_table(self, resources: list[VersionedTerraformResource], only_upgradable: bool = False): + def print_resource_table(self, resources: Sequence[VersionedTerraformResource], only_upgradable: bool = False): if len(resources) == 0: print("No resources found.") return @@ -79,13 +85,16 @@ def print_resource_table(self, resources: list[VersionedTerraformResource], only print("No providers found.") # noinspection PyUnboundLocalVariable - def update_resources(self, resources: list[VersionedTerraformResource], confirm: bool, working_dir: Path, commit_changes: bool = False) -> list[VersionedTerraformResource]: + def update_resources( + self, resources: Sequence[VersionedTerraformResource], confirm: bool, working_dir: Path, commit_changes: bool = False + ) -> Sequence[VersionedTerraformResource]: upgradable_resources = get_upgradable_resources(resources) if len(upgradable_resources) == 0: log.info("All resources are up to date, nothing to do.") return [] + repo: Union[Repo, None] = None if commit_changes: - repo = Repo(working_dir.absolute().as_posix()) + repo = Repo(path=working_dir.absolute().as_posix()) if repo.bare: raise Exception("Working directory is not a git repository.") log.info(f"Committing changes to git branch '{repo.active_branch.name}'.") @@ -102,30 +111,22 @@ def update_resources(self, resources: list[VersionedTerraformResource], confirm: resource.set_patch_error() continue if commit_changes: + if repo is None: + raise Exception("repo is None.") repo.index.add([resource.source_file.absolute().as_posix()]) repo.index.commit(f"Bump {resource.resource_name} '{resource.name}' from version '{resource.current_version}' to '{resource.newest_version}'.") resource.set_patched() return upgradable_resources - def _compose_resource_table(self, resources: list[VersionedTerraformResource], title: str): - table = Table(show_header=True, - title=title, - expand=True - ) + def _compose_resource_table(self, resources: Sequence[VersionedTerraformResource], title: str): + table = Table(show_header=True, title=title, expand=True) table.add_column("Name", overflow="fold") table.add_column("Source", overflow="fold") table.add_column("Current") table.add_column("Newest") table.add_column("Upgradeable") for resource in resources: - name = resource.identifier if isinstance(resource, TerraformProvider) else resource.name - table.add_row( - resource.name, - resource.source, - resource.current_version, - resource.newest_version, - str(not resource.installed_version_equal_or_newer_than_new_version()) - ) + table.add_row(resource.name, resource.source, resource.current_version, resource.newest_version, str(not resource.installed_version_equal_or_newer_than_new_version())) self._console.print(table) def dump_statistics(self, resources, save_as_json_file: bool = False): @@ -134,7 +135,7 @@ def dump_statistics(self, resources, save_as_json_file: bool = False): statistics = {} statistics["errors"] = len([resource for resource in resources if resource.status == ResourceStatus.PATCH_ERROR]) statistics["resources_patched"] = len([resource for resource in resources if resource.status == ResourceStatus.PATCHED]) - statistics["resources_pending_update"] = len([resource for resource in resources if resource.check_if_up_to_date() == False]) + statistics["resources_pending_update"] = len([resource for resource in resources if resource.check_if_up_to_date() is False]) statistics["total_resources"] = len(resources) statistics["modules_count"] = len(modules) statistics["providers_count"] = len(providers) @@ -146,10 +147,7 @@ def dump_statistics(self, resources, save_as_json_file: bool = False): file.unlink() with open(file, "w") as f: f.write(json.dumps(statistics)) - table = Table(show_header=True, - title="Statistics", - expand=True - ) + table = Table(show_header=True, title="Statistics", expand=True) table.add_column("Total Resources") table.add_column("Resources Pending Update") table.add_column("Resources Patched") @@ -162,6 +160,6 @@ def dump_statistics(self, resources, save_as_json_file: bool = False): str(statistics["resources_patched"]), str(statistics["errors"]), str(statistics["modules_count"]), - str(statistics["providers_count"]) + str(statistics["providers_count"]), ) self._console.print(table) diff --git a/infrapatch/core/constants.py b/infrapatch/core/constants.py index 98620b1..e9f46b0 100644 --- a/infrapatch/core/constants.py +++ b/infrapatch/core/constants.py @@ -4,4 +4,4 @@ # Name of this App. Used all over the place in the cli APP_NAME = "InfraPatch" -DEFAULT_CREDENTIALS_FILE_NAME = "infrapatch_credentials.json" \ No newline at end of file +DEFAULT_CREDENTIALS_FILE_NAME = "infrapatch_credentials.json" diff --git a/infrapatch/core/credentials_helper.py b/infrapatch/core/credentials_helper.py index 8eb69dd..d55364d 100644 --- a/infrapatch/core/credentials_helper.py +++ b/infrapatch/core/credentials_helper.py @@ -1,3 +1,4 @@ +from typing import Union import json import logging as log import infrapatch.core.constants as cs @@ -6,7 +7,7 @@ from infrapatch.core.utils.hcl_handler import HclHandler -def get_registry_credentials(hcl_handler: HclHandler, credentials_file: str = None) -> dict[str, str]: +def get_registry_credentials(hcl_handler: HclHandler, credentials_file: Union[Path, None] = None) -> dict[str, str]: credentials = hcl_handler.get_credentials_form_user_rc_file() if credentials_file is None: credentials_file = Path.cwd().joinpath(cs.DEFAULT_CREDENTIALS_FILE_NAME) diff --git a/infrapatch/core/log_helper.py b/infrapatch/core/log_helper.py index 188f6a8..c3b0888 100644 --- a/infrapatch/core/log_helper.py +++ b/infrapatch/core/log_helper.py @@ -12,7 +12,7 @@ def setup_logging(debug: bool = False): _debug = debug if debug: log_level = log.DEBUG - log.basicConfig(level=log_level, format='%(asctime)s - %(levelname)s - %(message)s') + log.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s") def catch_exception(func=None, *, handle): diff --git a/infrapatch/core/models/versioned_terraform_resources.py b/infrapatch/core/models/versioned_terraform_resources.py index 27690b8..5c68d09 100644 --- a/infrapatch/core/models/versioned_terraform_resources.py +++ b/infrapatch/core/models/versioned_terraform_resources.py @@ -3,7 +3,7 @@ import semantic_version from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Optional, Sequence, Union class ResourceStatus: @@ -17,14 +17,14 @@ class VersionedTerraformResource: name: str current_version: str source_file: Path - _newest_version: Optional[str] = None + _newest_version: Union[str, None] = None _status: str = ResourceStatus.UNPATCHED - _base_domain: str = None - _identifier: str = None - _source: str = None + _base_domain: Union[str, None] = None + _identifier: Union[str, None] = None + _source: Union[str, None] = None @property - def source(self) -> str: + def source(self) -> Union[str, None]: return self._source @property @@ -40,7 +40,7 @@ def resource_name(self): raise NotImplementedError() @property - def identifier(self) -> str: + def identifier(self) -> Union[str, None]: return self._identifier @property @@ -50,6 +50,8 @@ def newest_version(self) -> Optional[str]: @property def newest_version_base(self): if self.has_tile_constraint(): + if self.newest_version is None: + raise Exception(f"Newest version of resource '{self.name}' is not set.") return self.newest_version.strip("~>") return self.newest_version @@ -70,7 +72,6 @@ def set_patch_error(self): self._status = ResourceStatus.PATCH_ERROR def installed_version_equal_or_newer_than_new_version(self): - if self.newest_version is None: raise Exception(f"Newest version of resource '{self.name}' is not set.") @@ -86,9 +87,9 @@ def installed_version_equal_or_newer_than_new_version(self): # chech if the current version has the following format: "~>3.76.0" if self.has_tile_constraint(): current = semantic_version.Version(self.current_version.strip("~>")) - if current.major > newest.major: + if current.major > newest.major: # type: ignore return True - if current.minor >= newest.minor: + if current.minor >= newest.minor: # type: ignore return True return False @@ -115,18 +116,19 @@ def __to_dict__(self): "status": self.status, "base_domain": self.base_domain, "identifier": self.identifier, - "source": self.source + "source": self.source, } @dataclass class TerraformModule(VersionedTerraformResource): - def __post_init__(self): + if self._source is None: + raise Exception("Source is None.") self.source = self._source @property - def source(self) -> str: + def source(self) -> Union[str, None]: return self._source @property @@ -137,14 +139,13 @@ def resource_name(self): def source(self, source: str): source_lower_case = source.lower() self._source = source_lower_case - self.newest_version = None + self._newest_version = None if re.match(r"^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+/[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+$", source_lower_case): log.debug(f"Source '{source_lower_case}' is from a generic registry.") self._base_domain = source_lower_case.split("/")[0] self._identifier = "/".join(source_lower_case.split("/")[1:]) elif re.match(r"^[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+$", source_lower_case): - log.debug( - f"Source '{source_lower_case}' is from the public registry.") + log.debug(f"Source '{source_lower_case}' is from the public registry.") self._identifier = source_lower_case else: raise Exception(f"Source '{source_lower_case}' is not a valid terraform resource source.") @@ -152,12 +153,13 @@ def source(self, source: str): @dataclass class TerraformProvider(VersionedTerraformResource): - def __post_init__(self): + if self._source is None: + raise Exception("Source is None.") self.source = self._source @property - def source(self) -> str: + def source(self) -> Union[str, None]: return self._source @property @@ -168,22 +170,21 @@ def resource_name(self): def source(self, source: str) -> None: source_lower_case = source.lower() self._source = source_lower_case - self.newest_version = None + self._newest_version = None if re.match(r"^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+/[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+$", source_lower_case): log.debug(f"Source '{source_lower_case}' is from a generic registry.") self._base_domain = source_lower_case.split("/")[0] self._identifier = "/".join(source_lower_case.split("/")[1:]) elif re.match(r"^[a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+$", source_lower_case): - log.debug( - f"Source '{source_lower_case}' is from the public registry.") + log.debug(f"Source '{source_lower_case}' is from the public registry.") self._identifier = source_lower_case else: raise Exception(f"Source '{source_lower_case}' is not a valid terraform resource source.") -def get_upgradable_resources(resources: list[VersionedTerraformResource]) -> list[VersionedTerraformResource]: +def get_upgradable_resources(resources: Sequence[VersionedTerraformResource]) -> Sequence[VersionedTerraformResource]: return [resource for resource in resources if not resource.check_if_up_to_date()] -def from_terraform_resources_to_dict_list(terraform_resources: list[VersionedTerraformResource]) -> list[dict]: +def from_terraform_resources_to_dict_list(terraform_resources: Sequence[VersionedTerraformResource]) -> Sequence[dict]: return [terraform_resource.__to_dict__() for terraform_resource in terraform_resources] diff --git a/infrapatch/core/utils/hcl_edit_cli.py b/infrapatch/core/utils/hcl_edit_cli.py index 3c7b684..dd74163 100644 --- a/infrapatch/core/utils/hcl_edit_cli.py +++ b/infrapatch/core/utils/hcl_edit_cli.py @@ -2,7 +2,7 @@ import platform import subprocess from pathlib import Path -from typing import Optional +from typing import Optional, Union class HclEditCliException(BaseException): @@ -34,7 +34,7 @@ def get_hcl_value(self, resource: str, file: Path) -> str: raise HclEditCliException(f"Could not get value for resource '{resource}' from file '{file}'.") return result - def _run_hcl_edit_command(self, action: str, resource: str, file: Path, value: str = None) -> Optional[str]: + def _run_hcl_edit_command(self, action: str, resource: str, file: Path, value: Union[str, None] = None) -> Optional[str]: command = [self._get_binary_path().absolute().as_posix(), action, resource] if value is not None: command.append(value) @@ -47,6 +47,5 @@ def _run_hcl_edit_command(self, action: str, resource: str, file: Path, value: s raise HclEditCliException(f"Could not execute CLI command '{command_string}': {e}") if result.returncode != 0: log.error(f"Stdout: {result.stdout}") - raise HclEditCliException( - f"CLI command '{command_string}' failed with exit code {result.returncode}.") + raise HclEditCliException(f"CLI command '{command_string}' failed with exit code {result.returncode}.") return result.stdout diff --git a/infrapatch/core/utils/hcl_handler.py b/infrapatch/core/utils/hcl_handler.py index 35c7cca..5ee7c82 100644 --- a/infrapatch/core/utils/hcl_handler.py +++ b/infrapatch/core/utils/hcl_handler.py @@ -2,11 +2,12 @@ import logging as log import platform from pathlib import Path +from typing import Sequence import pygohcl -from infrapatch.core.utils.hcl_edit_cli import HclEditCli from infrapatch.core.models.versioned_terraform_resources import TerraformModule, TerraformProvider, VersionedTerraformResource +from infrapatch.core.utils.hcl_edit_cli import HclEditCli class HclParserException(BaseException): @@ -37,7 +38,7 @@ def bump_resource_version(self, resource: VersionedTerraformResource): self.hcl_edit_cli.update_hcl_value(resource_name, resource.source_file, resource.newest_version) - def get_terraform_resources_from_file(self, tf_file: Path) -> list[VersionedTerraformResource]: + def get_terraform_resources_from_file(self, tf_file: Path) -> Sequence[VersionedTerraformResource]: if not tf_file.exists(): raise Exception(f"File '{tf_file}' does not exist.") if not tf_file.is_file(): @@ -53,29 +54,18 @@ def get_terraform_resources_from_file(self, tf_file: Path) -> list[VersionedTerr providers = terraform_file_dict["terraform"]["required_providers"] for provider_name, provider_config in providers.items(): found_resources.append( - TerraformProvider( - name=provider_name, - _source=provider_config["source"], - current_version=provider_config["version"], - source_file=tf_file - ) + TerraformProvider(name=provider_name, _source=provider_config["source"], current_version=provider_config["version"], source_file=tf_file) ) if "module" in terraform_file_dict: modules = terraform_file_dict["module"] for module_name, value in modules.items(): - if not "source" in value: + if "source" not in value: log.debug(f"Skipping module '{module_name}' because it has no source attribute.") continue - found_resources.append( - TerraformModule( - name=module_name, - _source=value["source"], - current_version=value["version"], - source_file=tf_file - )) + found_resources.append(TerraformModule(name=module_name, _source=value["source"], current_version=value["version"], source_file=tf_file)) return found_resources - def get_all_terraform_files(self, root: Path = None) -> list[Path]: + def get_all_terraform_files(self, root: Path) -> Sequence[Path]: if not root.is_dir(): raise Exception(f"Path '{root}' is not a directory.") search_string = "*.tf" @@ -85,9 +75,12 @@ def get_all_terraform_files(self, root: Path = None) -> list[Path]: files = [Path(file_path) for file_path in file_paths] return files - def get_credentials_form_user_rc_file(self): + def get_credentials_form_user_rc_file(self) -> dict[str, str]: # get the home of the user user_home = Path.home() + + credentials: dict[str, str] = {} + # check if on windows if platform.system() == "Windows": terraform_rc_file = user_home.joinpath("AppData/Roaming/terraform.rc") @@ -95,18 +88,17 @@ def get_credentials_form_user_rc_file(self): terraform_rc_file = user_home.joinpath(".terraformrc") if not terraform_rc_file.exists() or not terraform_rc_file.is_file(): log.debug("No terraformrc file found for the current user.") - return {} - credentials = {} + return credentials try: with open(terraform_rc_file.absolute(), "r") as file: try: terraform_rc_file_dict = pygohcl.loads(file.read()) except Exception as e: log.error(f"Could not parse terraformrc file: {e}") - return {} + return credentials if "credentials" not in terraform_rc_file_dict: log.debug("No credentials found in terraformrc file.") - return {} + return credentials for name, value in terraform_rc_file_dict["credentials"].items(): token = value["token"] log.debug(f"Found the following credentials in terraformrc file: {name}={token[0:5]}...") @@ -114,3 +106,4 @@ def get_credentials_form_user_rc_file(self): return credentials except Exception as e: log.error(f"Could not read terraformrc file: {e}") + return credentials diff --git a/infrapatch/core/utils/registry_handler.py b/infrapatch/core/utils/registry_handler.py index 68488c3..f9de4db 100644 --- a/infrapatch/core/utils/registry_handler.py +++ b/infrapatch/core/utils/registry_handler.py @@ -10,9 +10,11 @@ class RegistryNotFoundException(BaseException): pass + class RegistryMetadataException(BaseException): pass + class ResourceNotFoundException(BaseException): pass diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..68c10f4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.ruff] +line-length = 180 + + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] +ignore = [] \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ede3eb4 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +ruff \ No newline at end of file diff --git a/setup.py b/setup.py index 0bca866..c446b03 100644 --- a/setup.py +++ b/setup.py @@ -2,31 +2,18 @@ from infrapatch.cli.__init__ import __version__ setup( - name='infrapatch', - description='CLI Tool to patch Terraform Providers and Modules.', + name="infrapatch", + description="CLI Tool to patch Terraform Providers and Modules.", version=__version__, - packages=find_packages( - where='.', - include=['infrapatch*'], - exclude=['action*'] - ), - package_data={ - 'infrapatch': ['core/bin/*'] - }, - install_requires=[ - "click~=8.1.7", - "rich~=13.6.0", - "pygohcl~=1.0.7", - "GitPython~=3.1.40", - "setuptools~=65.5.1", - "semantic_version~=2.10.0" - ], - python_requires='>=3.11', - entry_points=''' + packages=find_packages(where=".", include=["infrapatch*"], exclude=["action*"]), + package_data={"infrapatch": ["core/bin/*"]}, + install_requires=["click~=8.1.7", "rich~=13.6.0", "pygohcl~=1.0.7", "GitPython~=3.1.40", "setuptools~=65.5.1", "semantic_version~=2.10.0"], + python_requires=">=3.11", + entry_points=""" [console_scripts] infrapatch=infrapatch.cli.__main__:main - ''', + """, author="Noah Canadea", - url='https://github.com/Noahnc/infrapatch', - author_email='noah@canadea.ch' + url="https://github.com/Noahnc/infrapatch", + author_email="noah@canadea.ch", ) diff --git a/tf_test_files/project1/.terraform/modules/test_module b/tf_test_files/project1/.terraform/modules/test_module deleted file mode 160000 index dadff66..0000000 --- a/tf_test_files/project1/.terraform/modules/test_module +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dadff66539f40d97fd5ebd032556415ea3b614b5