├── .github └── workflows │ ├── cleanup.yaml │ └── publish-pypi.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── demo ├── tftui.cast └── tftui.gif ├── poetry.lock ├── pyproject.toml ├── reset.sh ├── run.sh ├── test ├── .terraform.lock.hcl ├── 1.txt ├── aws │ ├── ami.tf │ ├── aws.tf │ ├── az.tf │ ├── igw.tf │ ├── my-eip.tf │ ├── my-privrtassociation.tf │ ├── my-pubrtassociation.tf │ ├── my_instance.tf │ ├── mysg.tf │ ├── mysgingress-80.tf │ ├── mysgingress-icmp.tf │ ├── mysgrule-egress-all.tf │ ├── nat_gateway.tf │ ├── rtb1-natgw-tgw.tf │ ├── rtb2-igw.tf │ ├── subnets.tf │ ├── user_data.sh │ ├── variables.tf │ └── vpc.tf ├── local_file_#1.txt ├── local_file_#2.txt ├── local_file_#3.txt ├── main.tf ├── module │ ├── 1.txt │ └── main.tf ├── module2 │ └── main.tf ├── module3 │ └── main.tf └── terraform.tfvars └── tftui ├── __init__.py ├── __main__.py ├── apis.py ├── constants.py ├── debug_log.py ├── modal.py ├── plan.py ├── state.py └── ui.tcss /.github/workflows/cleanup.yaml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v9 12 | with: 13 | stale-issue-message: "Issue marked as stale due to inactivity. It will be closed in 5 days if no further activity occurs." 14 | close-issue-message: "This issue was closed because it was marked as stale and has not had recent activity. Please re-open if needed." 15 | stale-issue-label: "stale" 16 | exempt-issue-labels: "enhancement,bug" 17 | days-before-stale: 10 18 | days-before-close: 5 19 | days-before-pr-close: -1 20 | -------------------------------------------------------------------------------- /.github/workflows/publish-pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [released] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v3 19 | with: 20 | python-version: "3.x" 21 | 22 | - name: Install and configure Poetry 23 | uses: snok/install-poetry@v1 24 | with: 25 | virtualenvs-create: true 26 | installer-parallel: true 27 | 28 | - name: Build package 29 | run: poetry install && poetry build 30 | 31 | - name: Upload Artifact 32 | uses: actions/upload-artifact@v3 33 | with: 34 | name: dist 35 | path: ./dist/ 36 | 37 | publish-test: 38 | runs-on: ubuntu-latest 39 | environment: test 40 | permissions: 41 | id-token: write 42 | needs: build 43 | 44 | steps: 45 | - name: Download Artifact 46 | uses: actions/download-artifact@v3 47 | with: 48 | name: dist 49 | path: ./dist/ 50 | 51 | - name: Publish package distributions to PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | with: 54 | repository-url: https://test.pypi.org/legacy/ 55 | skip-existing: true 56 | 57 | publish-production: 58 | runs-on: ubuntu-latest 59 | environment: production 60 | permissions: 61 | id-token: write 62 | needs: publish-test 63 | 64 | steps: 65 | - name: Download Artifact 66 | uses: actions/download-artifact@v3 67 | with: 68 | name: dist 69 | path: ./dist/ 70 | 71 | - name: Publish package distributions to PyPI 72 | uses: pypa/gh-action-pypi-publish@release/v1 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | state.txt 163 | 164 | .terraform/ 165 | 166 | .vscode/** 167 | 168 | test/terraform.tfstate* 169 | test/tftui.plan 170 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-added-large-files 6 | args: ["--maxkb=3300"] 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - id: no-commit-to-branch 10 | args: 11 | - --branch=main 12 | - --branch=develop 13 | - repo: https://github.com/commitizen-tools/commitizen 14 | rev: 3.5.3 15 | hooks: 16 | - id: commitizen 17 | - repo: https://github.com/python-jsonschema/check-jsonschema 18 | rev: 0.23.3 19 | hooks: 20 | - id: check-github-workflows 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.9.0 23 | hooks: 24 | - id: pyupgrade 25 | args: 26 | - --py310-plus 27 | - repo: https://github.com/astral-sh/ruff-pre-commit 28 | rev: v0.0.280 29 | hooks: 30 | - id: ruff 31 | args: 32 | - --fix 33 | - --line-length=150 34 | - repo: https://github.com/psf/black 35 | rev: 23.7.0 36 | hooks: 37 | - id: black 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TFTUI - The Terraform textual UI 2 | 3 | [![PyPI version](https://badge.fury.io/py/tftui.svg?random=stuff)](https://badge.fury.io/py/tftui?) 4 | ![GitHub](https://img.shields.io/github/license/idoavrah/terraform-tui?random=stuff) 5 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/tftui?random=stuff) 6 | 7 | `TFTUI` is a powerful textual UI that empowers users to effortlessly view and interact with their Terraform state. 8 | 9 | With its latest version you can easily visualize the complete state tree, gaining deeper insights into your infrastructure's current configuration. Additionally, the ability to search the tree and inspect individual resource states allows you to focus on specific details for better analysis and management. It's also possible to select specific resources and perform actions such as tainting, untainting and deleting them. Finally, you are now able to create and apply plans directly from the UI. 10 | 11 | ## Key Features 12 | 13 | - [x] Comprehensive display of the entire Terraform state tree 14 | - [x] Effortlessly view and navigate through a single resource state 15 | - [x] Search the state tree and resource definitions 16 | - [x] Create plans, present them in full colors and apply them directly from the TUI 17 | - [x] Single/multiple resource selection 18 | - [x] Operate on resources: taint, untaint, delete, destroy 19 | - [x] Support for Terraform wrappers (e.g. terragrunt) 20 | 21 | ## Changelog (latest versions) 22 | 23 | ### Version 0.13 24 | 25 | - [x] Added support for workspace switching 26 | - [x] Added plan summary in the screen title 27 | - [x] Empty tree is now shown when no state exists instead of program shutting down, allowing for plan creation 28 | - [x] Added `-o` flag for offline mode (no outbound API calls) 29 | - [x] Removed the default outbound call to PostHog when tracking is disabled 30 | - [x] Added sensitive values extraction in resource details 31 | - [x] Added support for vim-like navigation 32 | 33 | ### Version 0.12 34 | 35 | - [x] Enabled targeting specific resources for plan creation 36 | - [x] Introducing cli argument: tfvars file 37 | - [x] Added destroy functionality 38 | - [x] Added a help screen 39 | - [x] Added dynamic value for "targets" checkbox (checkbox is marked when resources are selected) 40 | - [x] Added a short summary of the suggested plan before applying it 41 | - [x] Added a redacted error tracker on unhandeled exceptions (only when usage reporting is enabled) 42 | - [x] Added a fullscreen mode to allow easier copying of resource / plan parts 43 | - [x] Fixed: search through full module names 44 | - [x] Fixed: Copy to clipboard crashes on some systems 45 | 46 | ### Version 0.11 47 | 48 | - [x] Added support for creating plans (in vivid colors!) and applying them 49 | - [x] Changed the confirmation dialog to a modal screen 50 | - [x] Added coloring to tainted resources considering some terminals can't display strikethrough correctly 51 | - [x] Improved loading screen mechanism 52 | 53 | ## Demo 54 | 55 | ![](demo/tftui.gif "demo") 56 | 57 | ## Installation 58 | 59 | | Tool | Install | Upgrade | Run | 60 | | -------- | -------------------------------------- | ----------------------------- | ---------------------------------------- | 61 | | Homebrew | `brew install idoavrah/homebrew/tftui` | `brew upgrade tftui` | `cd /path/to/terraform/project && tftui` | 62 | | PIP | `pip install tftui` | `pip install --upgrade tftui` | `cd /path/to/terraform/project && tftui` | 63 | | PIPX | `pipx install tftui` | `pipx upgrade tftui` | `cd /path/to/terraform/project && tftui` | 64 | 65 | ## Usage Tracking 66 | 67 | - TFTUI utilizes [PostHog](https://posthog.com) to track usage of the application. 68 | - This is done to help us understand how the tool is being used and to improve it. 69 | - No personal data is being sent to the tracking service. Returning users are being uniquely identified by a generated fingerprint. 70 | - You can opt-out of usage tracking completely by setting the `-d` flag when running the tool. 71 | 72 | ## Star History 73 | 74 | [![Star History Chart](https://api.star-history.com/svg?repos=idoavrah/terraform-tui&type=Date)](https://star-history.com/#idoavrah/terraform-tui&Date) 75 | -------------------------------------------------------------------------------- /demo/tftui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idoavrah/terraform-tui/08a3ba8010ef66181298cb4ea5b68bccae9d9d43/demo/tftui.gif -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "backoff" 5 | version = "2.2.1" 6 | description = "Function decoration for backoff and retry" 7 | optional = false 8 | python-versions = ">=3.7,<4.0" 9 | files = [ 10 | {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, 11 | {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, 12 | ] 13 | 14 | [[package]] 15 | name = "certifi" 16 | version = "2023.11.17" 17 | description = "Python package for providing Mozilla's CA Bundle." 18 | optional = false 19 | python-versions = ">=3.6" 20 | files = [ 21 | {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, 22 | {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, 23 | ] 24 | 25 | [[package]] 26 | name = "charset-normalizer" 27 | version = "3.3.2" 28 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 29 | optional = false 30 | python-versions = ">=3.7.0" 31 | files = [ 32 | {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, 33 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, 34 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, 35 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, 36 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, 37 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, 38 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, 39 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, 40 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, 41 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, 42 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, 43 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, 44 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, 45 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, 46 | {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, 47 | {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, 48 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, 49 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, 50 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, 51 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, 52 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, 53 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, 54 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, 55 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, 56 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, 57 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, 58 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, 59 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, 60 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, 61 | {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, 62 | {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, 63 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, 64 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, 65 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, 66 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, 67 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, 68 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, 69 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, 70 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, 71 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, 72 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, 73 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, 74 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, 75 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, 76 | {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, 77 | {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, 78 | {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, 79 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, 80 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, 81 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, 82 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, 83 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, 84 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, 85 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, 86 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, 87 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, 88 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, 89 | {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, 90 | {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, 91 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, 92 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, 93 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, 94 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, 95 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, 96 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, 97 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, 98 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, 99 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, 100 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, 101 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, 102 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, 103 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, 104 | {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, 105 | {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, 106 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, 107 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, 108 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, 109 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, 110 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, 111 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, 112 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, 113 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, 114 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, 115 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, 116 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, 117 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, 118 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, 119 | {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, 120 | {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, 121 | {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, 122 | ] 123 | 124 | [[package]] 125 | name = "idna" 126 | version = "3.7" 127 | description = "Internationalized Domain Names in Applications (IDNA)" 128 | optional = false 129 | python-versions = ">=3.5" 130 | files = [ 131 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, 132 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, 133 | ] 134 | 135 | [[package]] 136 | name = "importlib-metadata" 137 | version = "7.0.1" 138 | description = "Read metadata from Python packages" 139 | optional = false 140 | python-versions = ">=3.8" 141 | files = [ 142 | {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, 143 | {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, 144 | ] 145 | 146 | [package.dependencies] 147 | zipp = ">=0.5" 148 | 149 | [package.extras] 150 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] 151 | perf = ["ipython"] 152 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] 153 | 154 | [[package]] 155 | name = "linkify-it-py" 156 | version = "2.0.2" 157 | description = "Links recognition library with FULL unicode support." 158 | optional = false 159 | python-versions = ">=3.7" 160 | files = [ 161 | {file = "linkify-it-py-2.0.2.tar.gz", hash = "sha256:19f3060727842c254c808e99d465c80c49d2c7306788140987a1a7a29b0d6ad2"}, 162 | {file = "linkify_it_py-2.0.2-py3-none-any.whl", hash = "sha256:a3a24428f6c96f27370d7fe61d2ac0be09017be5190d68d8658233171f1b6541"}, 163 | ] 164 | 165 | [package.dependencies] 166 | uc-micro-py = "*" 167 | 168 | [package.extras] 169 | benchmark = ["pytest", "pytest-benchmark"] 170 | dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] 171 | doc = ["myst-parser", "sphinx", "sphinx-book-theme"] 172 | test = ["coverage", "pytest", "pytest-cov"] 173 | 174 | [[package]] 175 | name = "markdown-it-py" 176 | version = "3.0.0" 177 | description = "Python port of markdown-it. Markdown parsing, done right!" 178 | optional = false 179 | python-versions = ">=3.8" 180 | files = [ 181 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 182 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 183 | ] 184 | 185 | [package.dependencies] 186 | linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} 187 | mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} 188 | mdurl = ">=0.1,<1.0" 189 | 190 | [package.extras] 191 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 192 | code-style = ["pre-commit (>=3.0,<4.0)"] 193 | compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] 194 | linkify = ["linkify-it-py (>=1,<3)"] 195 | plugins = ["mdit-py-plugins"] 196 | profiling = ["gprof2dot"] 197 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 198 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 199 | 200 | [[package]] 201 | name = "mdit-py-plugins" 202 | version = "0.4.0" 203 | description = "Collection of plugins for markdown-it-py" 204 | optional = false 205 | python-versions = ">=3.8" 206 | files = [ 207 | {file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"}, 208 | {file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"}, 209 | ] 210 | 211 | [package.dependencies] 212 | markdown-it-py = ">=1.0.0,<4.0.0" 213 | 214 | [package.extras] 215 | code-style = ["pre-commit"] 216 | rtd = ["myst-parser", "sphinx-book-theme"] 217 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 218 | 219 | [[package]] 220 | name = "mdurl" 221 | version = "0.1.2" 222 | description = "Markdown URL utilities" 223 | optional = false 224 | python-versions = ">=3.7" 225 | files = [ 226 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 227 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 228 | ] 229 | 230 | [[package]] 231 | name = "monotonic" 232 | version = "1.6" 233 | description = "An implementation of time.monotonic() for Python 2 & < 3.3" 234 | optional = false 235 | python-versions = "*" 236 | files = [ 237 | {file = "monotonic-1.6-py2.py3-none-any.whl", hash = "sha256:68687e19a14f11f26d140dd5c86f3dba4bf5df58003000ed467e0e2a69bca96c"}, 238 | {file = "monotonic-1.6.tar.gz", hash = "sha256:3a55207bcfed53ddd5c5bae174524062935efed17792e9de2ad0205ce9ad63f7"}, 239 | ] 240 | 241 | [[package]] 242 | name = "posthog" 243 | version = "3.3.1" 244 | description = "Integrate PostHog into any python application." 245 | optional = false 246 | python-versions = "*" 247 | files = [ 248 | {file = "posthog-3.3.1-py2.py3-none-any.whl", hash = "sha256:5f53b232acb680a0389e372db5f786061a18386b8b5324bddcc64eff9fdb319b"}, 249 | {file = "posthog-3.3.1.tar.gz", hash = "sha256:252cb6ab5cbe7ff002753f34fb647721b3af75034b4a5a631317ebf3db58fe59"}, 250 | ] 251 | 252 | [package.dependencies] 253 | backoff = ">=1.10.0" 254 | monotonic = ">=1.5" 255 | python-dateutil = ">2.1" 256 | requests = ">=2.7,<3.0" 257 | six = ">=1.5" 258 | 259 | [package.extras] 260 | dev = ["black", "flake8", "flake8-print", "isort", "pre-commit"] 261 | sentry = ["django", "sentry-sdk"] 262 | test = ["coverage", "flake8", "freezegun (==0.3.15)", "mock (>=2.0.0)", "pylint", "pytest", "pytest-timeout"] 263 | 264 | [[package]] 265 | name = "pygments" 266 | version = "2.17.2" 267 | description = "Pygments is a syntax highlighting package written in Python." 268 | optional = false 269 | python-versions = ">=3.7" 270 | files = [ 271 | {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, 272 | {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, 273 | ] 274 | 275 | [package.extras] 276 | plugins = ["importlib-metadata"] 277 | windows-terminal = ["colorama (>=0.4.6)"] 278 | 279 | [[package]] 280 | name = "pyperclip" 281 | version = "1.8.2" 282 | description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" 283 | optional = false 284 | python-versions = "*" 285 | files = [ 286 | {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"}, 287 | ] 288 | 289 | [[package]] 290 | name = "python-dateutil" 291 | version = "2.8.2" 292 | description = "Extensions to the standard Python datetime module" 293 | optional = false 294 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 295 | files = [ 296 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 297 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 298 | ] 299 | 300 | [package.dependencies] 301 | six = ">=1.5" 302 | 303 | [[package]] 304 | name = "requests" 305 | version = "2.31.0" 306 | description = "Python HTTP for Humans." 307 | optional = false 308 | python-versions = ">=3.7" 309 | files = [ 310 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 311 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 312 | ] 313 | 314 | [package.dependencies] 315 | certifi = ">=2017.4.17" 316 | charset-normalizer = ">=2,<4" 317 | idna = ">=2.5,<4" 318 | urllib3 = ">=1.21.1,<3" 319 | 320 | [package.extras] 321 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 322 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 323 | 324 | [[package]] 325 | name = "rich" 326 | version = "13.7.0" 327 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 328 | optional = false 329 | python-versions = ">=3.7.0" 330 | files = [ 331 | {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, 332 | {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, 333 | ] 334 | 335 | [package.dependencies] 336 | markdown-it-py = ">=2.2.0" 337 | pygments = ">=2.13.0,<3.0.0" 338 | 339 | [package.extras] 340 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 341 | 342 | [[package]] 343 | name = "six" 344 | version = "1.16.0" 345 | description = "Python 2 and 3 compatibility utilities" 346 | optional = false 347 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 348 | files = [ 349 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 350 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 351 | ] 352 | 353 | [[package]] 354 | name = "textual" 355 | version = "0.44.1" 356 | description = "Modern Text User Interface framework" 357 | optional = false 358 | python-versions = ">=3.8,<4.0" 359 | files = [ 360 | {file = "textual-0.44.1-py3-none-any.whl", hash = "sha256:19cfd3a0c623bff02cc80d872ba3e93e1a5b77289fecf74c16ffcfa7407b49a1"}, 361 | {file = "textual-0.44.1.tar.gz", hash = "sha256:7a45b85943957095b97d0a90c4fa4d3e1028fa26493c0720f403d879157a6589"}, 362 | ] 363 | 364 | [package.dependencies] 365 | importlib-metadata = ">=4.11.3" 366 | markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} 367 | rich = ">=13.3.3" 368 | typing-extensions = ">=4.4.0,<5.0.0" 369 | 370 | [package.extras] 371 | syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree_sitter_languages (>=1.7.0)"] 372 | 373 | [[package]] 374 | name = "typing-extensions" 375 | version = "4.9.0" 376 | description = "Backported and Experimental Type Hints for Python 3.8+" 377 | optional = false 378 | python-versions = ">=3.8" 379 | files = [ 380 | {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, 381 | {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, 382 | ] 383 | 384 | [[package]] 385 | name = "uc-micro-py" 386 | version = "1.0.2" 387 | description = "Micro subset of unicode data files for linkify-it-py projects." 388 | optional = false 389 | python-versions = ">=3.7" 390 | files = [ 391 | {file = "uc-micro-py-1.0.2.tar.gz", hash = "sha256:30ae2ac9c49f39ac6dce743bd187fcd2b574b16ca095fa74cd9396795c954c54"}, 392 | {file = "uc_micro_py-1.0.2-py3-none-any.whl", hash = "sha256:8c9110c309db9d9e87302e2f4ad2c3152770930d88ab385cd544e7a7e75f3de0"}, 393 | ] 394 | 395 | [package.extras] 396 | test = ["coverage", "pytest", "pytest-cov"] 397 | 398 | [[package]] 399 | name = "urllib3" 400 | version = "2.1.0" 401 | description = "HTTP library with thread-safe connection pooling, file post, and more." 402 | optional = false 403 | python-versions = ">=3.8" 404 | files = [ 405 | {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, 406 | {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, 407 | ] 408 | 409 | [package.extras] 410 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 411 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 412 | zstd = ["zstandard (>=0.18.0)"] 413 | 414 | [[package]] 415 | name = "zipp" 416 | version = "3.17.0" 417 | description = "Backport of pathlib-compatible object wrapper for zip files" 418 | optional = false 419 | python-versions = ">=3.8" 420 | files = [ 421 | {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, 422 | {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, 423 | ] 424 | 425 | [package.extras] 426 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] 427 | testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 428 | 429 | [metadata] 430 | lock-version = "2.0" 431 | python-versions = "^3.9" 432 | content-hash = "610e29aba706f8c14211daede75d383774986a9526c7d61e9e1c3f219b1ad61e" 433 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "tftui" 7 | version = "0.13.4" 8 | description = "Terraform Textual User Interface" 9 | authors = ["Ido Avraham"] 10 | license = "Apache-2.0" 11 | readme = "README.md" 12 | repository = "https://github.com/idoavrah/terraform-tui" 13 | keywords = ["terraform", "tui"] 14 | classifiers = [ 15 | "License :: OSI Approved :: Apache Software License", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | ] 19 | packages = [ 20 | { include = "tftui" }, 21 | ] 22 | include = ["tftui/ui.tcss"] 23 | 24 | [tool.poetry.dependencies] 25 | python = "^3.9" 26 | textual = "^0.44.1" 27 | posthog = "^3.1.0" 28 | requests = "^2.31.0" 29 | pyperclip = "^1.8.2" 30 | 31 | [tool.poetry.scripts] 32 | tftui = 'tftui.__main__:main' 33 | 34 | [tool.commitizen] 35 | name = "cz_conventional_commits" 36 | tag_format = "v$version" 37 | version_scheme = "pep440" 38 | version_provider = "poetry" 39 | update_changelog_on_bump = true 40 | major_version_zero = true 41 | -------------------------------------------------------------------------------- /reset.sh: -------------------------------------------------------------------------------- 1 | cd test 2 | terraform apply -auto-approve 3 | rm -f *.backup tftui.plan 4 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | cd test 2 | rm -f *.backup tftui.plan 3 | poetry run tftui -ndg 4 | -------------------------------------------------------------------------------- /test/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/local" { 5 | version = "2.4.1" 6 | constraints = "2.4.1" 7 | hashes = [ 8 | "h1:gpp25uNkYJYzJVnkyRr7RIBVfwLs9GSq2HNnFpTRBg0=", 9 | "zh:244b445bf34ddbd167731cc6c6b95bbed231dc4493f8cc34bd6850cfe1f78528", 10 | "zh:3c330bdb626123228a0d1b1daa6c741b4d5d484ab1c7ae5d2f48d4c9885cc5e9", 11 | "zh:5ff5f9b791ddd7557e815449173f2db38d338e674d2d91800ac6e6d808de1d1d", 12 | "zh:70206147104f4bf26ae67d730c995772f85bf23e28c2c2e7612c74f4dae3c46f", 13 | "zh:75029676993accd6bef933c196b2fad51a9ec8a69a847dbbe96ec8ebf7926cdc", 14 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 15 | "zh:7d48d5999fe1fcdae9295a7c3448ac1541f5a24c474bd82df6d4fa3732483f2b", 16 | "zh:b766b38b027f0f84028244d1c2f990431a37d4fc3ac645962924554016507e77", 17 | "zh:bfc7ad301dada204cf51c59d8bd6a9a87de5fddb42190b4d6ba157d6e08a1f10", 18 | "zh:c902b527702a8c5e2c25a6637d07bbb1690cb6c1e63917a5f6dc460efd18d43f", 19 | "zh:d68ae0e1070cf429c46586bc87580c3ed113f76241da2b6e4f1a8348126b3c46", 20 | "zh:f4903fd89f7c92a346ae9e666c2d0b6884c4474ae109e9b4bd15e7efaa4bfc29", 21 | ] 22 | } 23 | 24 | provider "registry.terraform.io/hashicorp/random" { 25 | version = "3.5.1" 26 | constraints = "3.5.1" 27 | hashes = [ 28 | "h1:IL9mSatmwov+e0+++YX2V6uel+dV6bn+fC/cnGDK3Ck=", 29 | "zh:04e3fbd610cb52c1017d282531364b9c53ef72b6bc533acb2a90671957324a64", 30 | "zh:119197103301ebaf7efb91df8f0b6e0dd31e6ff943d231af35ee1831c599188d", 31 | "zh:4d2b219d09abf3b1bb4df93d399ed156cadd61f44ad3baf5cf2954df2fba0831", 32 | "zh:6130bdde527587bbe2dcaa7150363e96dbc5250ea20154176d82bc69df5d4ce3", 33 | "zh:6cc326cd4000f724d3086ee05587e7710f032f94fc9af35e96a386a1c6f2214f", 34 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 35 | "zh:b6d88e1d28cf2dfa24e9fdcc3efc77adcdc1c3c3b5c7ce503a423efbdd6de57b", 36 | "zh:ba74c592622ecbcef9dc2a4d81ed321c4e44cddf7da799faa324da9bf52a22b2", 37 | "zh:c7c5cde98fe4ef1143bd1b3ec5dc04baf0d4cc3ca2c5c7d40d17c0e9b2076865", 38 | "zh:dac4bad52c940cd0dfc27893507c1e92393846b024c5a9db159a93c534a3da03", 39 | "zh:de8febe2a2acd9ac454b844a4106ed295ae9520ef54dc8ed2faf29f12716b602", 40 | "zh:eab0d0495e7e711cca367f7d4df6e322e6c562fc52151ec931176115b83ed014", 41 | ] 42 | } 43 | 44 | provider "registry.terraform.io/hashicorp/time" { 45 | version = "0.10.0" 46 | constraints = "0.10.0" 47 | hashes = [ 48 | "h1:NAl8eupFAZXCAbE5uiHZTz+Yqler55B3fMG+jNPrjjM=", 49 | "zh:0ab31efe760cc86c9eef9e8eb070ae9e15c52c617243bbd9041632d44ea70781", 50 | "zh:0ee4e906e28f23c598632eeac297ab098d6d6a90629d15516814ab90ad42aec8", 51 | "zh:3bbb3e9da728b82428c6f18533b5b7c014e8ff1b8d9b2587107c966b985e5bcc", 52 | "zh:6771c72db4e4486f2c2603c81dfddd9e28b6554d1ded2996b4cb37f887b467de", 53 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 54 | "zh:833c636d86c2c8f23296a7da5d492bdfd7260e22899fc8af8cc3937eb41a7391", 55 | "zh:c545f1497ae0978ffc979645e594b57ff06c30b4144486f4f362d686366e2e42", 56 | "zh:def83c6a85db611b8f1d996d32869f59397c23b8b78e39a978c8a2296b0588b2", 57 | "zh:df9579b72cc8e5fac6efee20c7d0a8b72d3d859b50828b1c473d620ab939e2c7", 58 | "zh:e281a8ecbb33c185e2d0976dc526c93b7359e3ffdc8130df7422863f4952c00e", 59 | "zh:ecb1af3ae67ac7933b5630606672c94ec1f54b119bf77d3091f16d55ab634461", 60 | "zh:f8109f13e07a741e1e8a52134f84583f97a819e33600be44623a21f6424d6593", 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /test/1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idoavrah/terraform-tui/08a3ba8010ef66181298cb4ea5b68bccae9d9d43/test/1.txt -------------------------------------------------------------------------------- /test/aws/ami.tf: -------------------------------------------------------------------------------- 1 | data "aws_ami" "amazon_linux" { 2 | most_recent = true 3 | owners = ["amazon"] 4 | 5 | filter { 6 | name = "name" 7 | values = [ 8 | "amzn2-ami-hvm-*-x86_64-gp2", 9 | ] 10 | } 11 | 12 | filter { 13 | name = "owner-alias" 14 | values = [ 15 | "amazon", 16 | ] 17 | } 18 | 19 | filter { 20 | name = "virtualization-type" 21 | values = ["hvm"] 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /test/aws/aws.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "> 1.5.0" 3 | required_providers { 4 | aws = { 5 | source = "hashicorp/aws" 6 | # Fix version version of the AWS provider 7 | version = "= 5.3.0" 8 | } 9 | } 10 | } 11 | 12 | provider "aws" { 13 | region = var.region 14 | } 15 | 16 | variable "region" { 17 | description = "The name of the AWS Region" 18 | type = string 19 | default = "eu-west-1" 20 | } 21 | -------------------------------------------------------------------------------- /test/aws/az.tf: -------------------------------------------------------------------------------- 1 | data "aws_availability_zones" "az" { 2 | state = "available" 3 | } 4 | -------------------------------------------------------------------------------- /test/aws/igw.tf: -------------------------------------------------------------------------------- 1 | # File generated by aws2tf see https://github.com/aws-samples/aws2tf 2 | # aws_internet_gateway.igw-06fcd611034e99d14: 3 | resource "aws_internet_gateway" "myigw" { 4 | count = var.mycount 5 | tags = {} 6 | vpc_id = aws_vpc.VPC[count.index].id 7 | } 8 | -------------------------------------------------------------------------------- /test/aws/my-eip.tf: -------------------------------------------------------------------------------- 1 | resource "aws_eip" "my-eip" { 2 | count = var.mycount 3 | public_ipv4_pool = "amazon" 4 | tags = {} 5 | domain = "vpc" 6 | timeouts {} 7 | } 8 | -------------------------------------------------------------------------------- /test/aws/my-privrtassociation.tf: -------------------------------------------------------------------------------- 1 | # File generated by aws2tf see https://github.com/aws-samples/aws2tf 2 | # aws_route_table_association.rtbassoc-01b2fe2ddfac5825c: 3 | resource "aws_route_table_association" "myrtbassociation2" { 4 | count = var.mycount 5 | route_table_id = aws_route_table.rtb1-natgw-tgw[count.index].id 6 | subnet_id = aws_subnet.myprivsubnet[count.index].id 7 | } 8 | -------------------------------------------------------------------------------- /test/aws/my-pubrtassociation.tf: -------------------------------------------------------------------------------- 1 | # File generated by aws2tf see https://github.com/aws-samples/aws2tf 2 | # aws_route_table_association.rtbassoc-01b2fe2ddfac5825c: 3 | resource "aws_route_table_association" "myrtbassociation" { 4 | count = var.mycount 5 | route_table_id = aws_route_table.rtb2-igw[count.index].id 6 | subnet_id = aws_subnet.mypubsubnet[count.index].id 7 | } 8 | -------------------------------------------------------------------------------- /test/aws/my_instance.tf: -------------------------------------------------------------------------------- 1 | # File generated by aws2tf see https://github.com/aws-samples/aws2tf 2 | # aws_instance.i-07ecaaf125c9a6807: 3 | resource "aws_instance" "myinstance" { 4 | count = var.mycount 5 | ami = data.aws_ami.amazon_linux.id 6 | 7 | associate_public_ip_address = false 8 | availability_zone = data.aws_availability_zones.az.names[0] 9 | 10 | iam_instance_profile = aws_iam_instance_profile.test_profile.name 11 | instance_type = "t2.micro" 12 | monitoring = false 13 | 14 | source_dest_check = true 15 | subnet_id = aws_subnet.myprivsubnet[count.index].id 16 | tags = { 17 | "Name" = format("instance-10-%s-4-first", count.index + 1) 18 | } 19 | tenancy = "default" 20 | lifecycle { 21 | ignore_changes = [user_data, user_data_base64] 22 | } 23 | #user_data_base64 = "IyEvYmluL2Jhc2gKCnl1bSB1cGRhdGUgLXkKCmN1cmwgaHR0cHM6Ly9naXN0LmdpdGh1YnVzZXJjb250ZW50LmNvbS9BbnRvbmlvRmVpamFvVUsvZDg1MzNhNzFlNWVjZmYyOTcxZjY4NTlhN2JlNDI2ZGEvcmF3LzNkMDkzMDAwNGI5MzdmNmRkN2YyNzMwMjEyMTgzMjdiNzEyOWQ2MDkvYXdzLWVjMi11c2VyZGF0YS1sYW5kaW5nLXdlYnBhZ2Uuc2ggfCBiYXNoCgo=" 24 | user_data = file("aws/user_data.sh") 25 | volume_tags = { 26 | "Name" = format("instance-10-%s-4-first", count.index + 1) 27 | } 28 | vpc_security_group_ids = [ 29 | aws_security_group.mysg[count.index].id, 30 | ] 31 | 32 | credit_specification { 33 | cpu_credits = "standard" 34 | } 35 | 36 | metadata_options { 37 | http_endpoint = "enabled" 38 | http_put_response_hop_limit = 1 39 | http_tokens = "optional" 40 | } 41 | 42 | root_block_device { 43 | delete_on_termination = true 44 | encrypted = false 45 | volume_size = 8 46 | volume_type = "gp2" 47 | } 48 | 49 | timeouts {} 50 | } 51 | 52 | resource "aws_iam_instance_profile" "test_profile" { 53 | name = "test_profile" 54 | role = aws_iam_role.test_role.name 55 | } 56 | 57 | resource "aws_iam_role" "test_role" { 58 | name = "test_role" 59 | 60 | assume_role_policy = < None: 113 | self.clear() 114 | self.selected_nodes = [] 115 | self.current_node = None 116 | module_nodes = {} 117 | 118 | filtered_blocks = dict( 119 | filter( 120 | lambda block: search_string in block[1].contents 121 | or search_string in block[1].name 122 | or search_string in block[1].submodule, 123 | self.current_state.state_tree.items(), 124 | ) 125 | ) 126 | modules = { 127 | block.submodule 128 | for block in filtered_blocks.values() 129 | if block.submodule != "" 130 | } 131 | 132 | logger.debug( 133 | "Filter tree: %s", 134 | json.dumps( 135 | { 136 | "search string": search_string, 137 | "filtered blocks": len(filtered_blocks), 138 | "filtered modules": len(modules), 139 | }, 140 | indent=2, 141 | ), 142 | ) 143 | 144 | for module_fullname in sorted(modules): 145 | parts = split_resource_name(module_fullname) 146 | submodule = "" 147 | i = 0 148 | while i < len(parts): 149 | parent = submodule 150 | short_name = f"{parts[i]}.{parts[i+1]}" 151 | submodule = ( 152 | ".".join([submodule, short_name]) if submodule else short_name 153 | ) 154 | if submodule not in module_nodes: 155 | if module_nodes.get(parent) is None: 156 | parent_node = self.root 157 | else: 158 | parent_node = module_nodes[parent] 159 | node = parent_node.add(short_name, data=submodule) 160 | module_nodes[submodule] = node 161 | i += 2 162 | 163 | # build resource tree 164 | for block in filtered_blocks.values(): 165 | if block.submodule == "": 166 | module_node = self.root 167 | else: 168 | module_node = module_nodes[block.submodule] 169 | leaf = module_node.add_leaf(block.name, data=block) 170 | if block.is_tainted: 171 | leaf.label.stylize("gold3 strike") 172 | 173 | self.root.expand_all() 174 | 175 | @work(exclusive=True) 176 | async def extract_sensitive_values(self) -> None: 177 | self.sensitive_values = {} 178 | try: 179 | returncode, stdout = await execute_async( 180 | ApplicationGlobals.executable, "show -json" 181 | ) 182 | if returncode == 0: 183 | self.current_state_json = json.loads(stdout) 184 | self.sensitive_values = extract_sensitive_values( 185 | self.current_state_json 186 | ) 187 | except CancelledError: 188 | pass 189 | except Exception as e: 190 | logger.error("Error extracting sensitive values: %s", e) 191 | 192 | @work(exclusive=True) 193 | async def refresh_state(self, focus=True) -> None: 194 | self.loading = True 195 | self.app.notify("Refreshing state tree") 196 | self.app.search.value = "" 197 | try: 198 | await self.current_state.refresh_state() 199 | self.extract_sensitive_values() 200 | except Exception as e: 201 | ApplicationGlobals.successful_termination = False 202 | self.app.exit(e) 203 | return 204 | 205 | self.build_tree() 206 | self.current_node = self.get_node_at_line(min(self.cursor_line, self.last_line)) 207 | self.update_highlighted_resource_node(self.current_node) 208 | self.loading = False 209 | OutboundAPIs.post_usage("refreshed state") 210 | if focus: 211 | self.focus() 212 | 213 | def update_highlighted_resource_node(self, node) -> None: 214 | self.current_node = node 215 | if type(self.current_node.data) == Block: 216 | self.highlighted_resource_node = ( 217 | [node] if self.current_node.data.type == Block.TYPE_RESOURCE else [] 218 | ) 219 | else: 220 | self.highlighted_resource_node = [] 221 | 222 | def on_tree_node_highlighted(self, node) -> None: 223 | self.update_highlighted_resource_node(node.node) 224 | 225 | def on_tree_node_selected(self) -> None: 226 | if not self.current_node: 227 | return 228 | if not self.current_node.allow_expand: 229 | self.app.resource.clear() 230 | self.app.resource.write(self.current_node.data.contents) 231 | self.app.switcher.border_title = ( 232 | f"{self.current_node.data.submodule}.{self.current_node.data.name}" 233 | if self.current_node.data.submodule 234 | else self.current_node.data.name 235 | ) 236 | self.app.switcher.current = "resource" 237 | 238 | def select_current_node(self) -> None: 239 | if self.current_node is None: 240 | return 241 | if ( 242 | self.current_node.allow_expand 243 | or self.current_node.data.type == Block.TYPE_DATASOURCE 244 | ): 245 | return 246 | if self.current_node in self.selected_nodes: 247 | self.selected_nodes.remove(self.current_node) 248 | self.current_node.label = self.current_node.label.plain 249 | if self.current_node.data.is_tainted: 250 | self.current_node.label.stylize("gold3 strike") 251 | else: 252 | self.selected_nodes.append(self.current_node) 253 | self.current_node.label.stylize("red bold italic reverse") 254 | 255 | def display_sensitive_data(self, fullname, contents) -> None: 256 | self.app.resource.clear() 257 | sensitive_values = self.sensitive_values.get(fullname) 258 | if sensitive_values: 259 | for value in sensitive_values.values(): 260 | if value: 261 | contents = contents.replace( 262 | " (sensitive value)\n", f" {value}\n", 1 263 | ) 264 | self.app.resource.write(contents) 265 | 266 | 267 | class TerraformTUI(App): 268 | switcher = None 269 | tree = None 270 | resource = None 271 | search = None 272 | plan = None 273 | selected_action = None 274 | error_message = "" 275 | 276 | def __init__(self, *args, **kwargs): 277 | super().__init__(*args, **kwargs) 278 | self.dark = ApplicationGlobals.darkmode 279 | 280 | TITLE = f"Terraform TUI v{OutboundAPIs.version}" 281 | SUB_TITLE = f"The textual UI for Terraform{' (new version available)' if OutboundAPIs.is_new_version_available else ''}" 282 | CSS_PATH = "ui.tcss" 283 | 284 | BINDINGS = [ 285 | Binding("Enter", "", "View", show=False), 286 | Binding("escape", "back", "Back", show=False), 287 | Binding("h", "left", "Left", show=False), 288 | Binding("j", "down", "Down", show=False), 289 | Binding("k", "up", "Up", show=False), 290 | Binding("l", "right", "Right", show=False), 291 | Binding("spacebar", "select", "Select"), 292 | ("f", "fullscreen", "FullScreen"), 293 | ("d", "delete", "Delete"), 294 | ("t", "taint", "Taint"), 295 | ("u", "untaint", "Untaint"), 296 | ("c", "copy", "Copy"), 297 | ("r", "refresh", "Refresh"), 298 | ("p", "plan", "Plan"), 299 | ("a", "apply", "Apply"), 300 | ("ctrl+d", "destroy", "Destroy"), 301 | ("/", "search", "Search"), 302 | ("0-9", "collapse", "Collapse"), 303 | ("w", "workspaces", "Workspaces"), 304 | ("x", "sensitive", "Sensitive"), 305 | ("m", "toggle_dark", "Dark mode"), 306 | ("?", "help", "Help"), 307 | ("q", "quit", "Quit"), 308 | ] + [Binding(f"{i}", f"collapse({i})", show=False) for i in range(10)] 309 | 310 | def compose(self): 311 | yield AppHeader(id="header") 312 | yield Input(id="search", placeholder="Search text...") 313 | with ContentSwitcher(id="switcher", initial="tree"): 314 | yield StateTree("State", id="tree") 315 | yield TextLog( 316 | id="resource", 317 | highlight=True, 318 | markup=True, 319 | wrap=True, 320 | classes="resource", 321 | auto_scroll=False, 322 | ) 323 | yield PlanScreen(id="plan", executable=ApplicationGlobals.executable) 324 | yield Footer() 325 | 326 | def on_mount(self) -> None: 327 | self.resource = self.get_widget_by_id("resource") 328 | self.tree = self.get_widget_by_id("tree") 329 | self.switcher = self.get_widget_by_id("switcher") 330 | self.search = self.get_widget_by_id("search") 331 | self.plan = self.get_widget_by_id("plan") 332 | 333 | def on_ready(self) -> None: 334 | self.tree.refresh_state() 335 | 336 | def on_input_changed(self, event: Input.Changed) -> None: 337 | if self.app.search.value == "": 338 | return 339 | elif self.app.tree.loading: 340 | self.app.search.value = "" 341 | self.app.notify( 342 | "Please wait until state refresh is complete", severity="warning" 343 | ) 344 | elif event.input.id == "search": 345 | search_string = event.value.strip() 346 | self.perform_search(search_string) 347 | 348 | def on_input_submitted(self, event: Input.Submitted) -> None: 349 | if self.switcher.current == "tree": 350 | self.tree.focus() 351 | 352 | def on_key(self, event) -> None: 353 | if event.key == "j": 354 | self.post_message(Key("down", character="down")) 355 | elif event.key == "k": 356 | self.post_message(Key("up", character="up")) 357 | if not self.tree.has_focus or isinstance(self.screen, ModalScreen): 358 | return 359 | if event.key == "space": 360 | self.action_select() 361 | elif event.key in ("left", "h") and self.switcher.current == "tree": 362 | if self.tree.current_node is not None: 363 | if ( 364 | self.tree.current_node.allow_expand 365 | and self.tree.current_node.is_expanded 366 | ): 367 | self.tree.current_node.collapse() 368 | elif self.tree.current_node.parent is not None: 369 | self.tree.current_node = self.tree.current_node.parent 370 | self.tree.select_node(self.tree.current_node) 371 | self.tree.scroll_to_node(self.tree.current_node) 372 | elif event.key in ("right", "l") and self.switcher.current == "tree": 373 | if self.tree.current_node is not None: 374 | if ( 375 | self.tree.current_node.allow_expand 376 | and not self.tree.current_node.is_expanded 377 | ): 378 | self.tree.current_node.expand() 379 | else: 380 | if ( 381 | self.tree.get_node_at_line(self.tree.cursor_line + 1) 382 | is not None 383 | ): 384 | self.tree.current_node = self.tree.get_node_at_line( 385 | self.tree.cursor_line + 1 386 | ) 387 | self.tree.select_node(self.tree.current_node) 388 | self.tree.scroll_to_node(self.tree.current_node) 389 | 390 | async def manipulate_resources(self, what_to_do: str) -> None: 391 | nodes = ( 392 | self.tree.selected_nodes 393 | if self.tree.selected_nodes 394 | else self.tree.highlighted_resource_node 395 | ) 396 | for node in nodes: 397 | await execute_async( 398 | ApplicationGlobals.executable, 399 | (what_to_do if what_to_do != "delete" else "state rm"), 400 | ( 401 | ".".join([node.data.submodule, node.data.name]) 402 | if node.data.submodule 403 | else node.data.name 404 | ), 405 | ) 406 | 407 | async def perform_action(self) -> None: 408 | if self.selected_action in ["taint", "untaint", "delete"]: 409 | self.switcher.loading = True 410 | self.notify( 411 | f"Executing {ApplicationGlobals.executable.capitalize()} {self.selected_action}" 412 | ) 413 | await self.manipulate_resources(self.selected_action) 414 | OutboundAPIs.post_usage(f"applied {self.selected_action}") 415 | self.tree.refresh_state() 416 | self.tree.focus() 417 | self.switcher.loading = False 418 | 419 | def perform_search(self, search_string: str) -> None: 420 | self.tree.root.collapse_all() 421 | self.tree.build_tree(search_string) 422 | self.tree.root.expand() 423 | 424 | def action_back(self) -> None: 425 | if ( 426 | not self.switcher.current == "resource" 427 | and not self.switcher.current == "plan" 428 | and not self.focused.id == "search" 429 | ): 430 | return 431 | self.switcher.current = "tree" 432 | self.app.switcher.border_title = "" 433 | self.tree.focus() 434 | 435 | async def create_plan(self, destroy="") -> None: 436 | self.switcher.current = "plan" 437 | 438 | async def execute(response): 439 | if response is not None: 440 | self.notify(f"Creating {destroy} plan") 441 | targets = [] 442 | if response[1]: 443 | if self.tree.selected_nodes: 444 | targets = [ 445 | f"{node.parent.data}.{node.label.plain}".lstrip(".") 446 | for node in self.tree.selected_nodes 447 | ] 448 | else: 449 | targets = [ 450 | f"{node.parent.data}.{node.label.plain}".lstrip(".") 451 | for node in self.tree.highlighted_resource_node 452 | ] 453 | self.plan.create_plan(response[0], targets, destroy) 454 | OutboundAPIs.post_usage( 455 | f"create {'targeted' if targets else ''} {destroy} plan" 456 | ) 457 | else: 458 | self.switcher.current = "tree" 459 | 460 | self.push_screen( 461 | PlanInputsModal( 462 | ApplicationGlobals.var_file, 463 | len(self.tree.selected_nodes) > 0, 464 | ), 465 | execute, 466 | ) 467 | self.plan.focus() 468 | 469 | async def action_plan(self) -> None: 470 | await self.create_plan() 471 | 472 | async def action_destroy(self) -> None: 473 | await self.create_plan("destruction") 474 | 475 | async def action_apply(self) -> None: 476 | if not self.plan.active_plan: 477 | self.app.notify("No active plan to apply", severity="warning") 478 | return 479 | 480 | question = Text.assemble( 481 | ("Are you sure you wish to apply the current plan?\n\n", "bold"), 482 | self.plan.active_plan, 483 | ) 484 | 485 | async def execute_if_yes(flag): 486 | if flag: 487 | self.switcher.loading = True 488 | self.notify("Applying plan") 489 | OutboundAPIs.post_usage("apply plan") 490 | logger.debug("Applying plan %s", self.plan.active_plan) 491 | self.plan.execute_apply() 492 | 493 | self.push_screen(YesNoModal(question), execute_if_yes) 494 | self.plan.focus() 495 | 496 | def action_select(self) -> None: 497 | if not self.switcher.current == "tree": 498 | return 499 | self.tree.select_current_node() 500 | 501 | async def action_manipulate_resources(self, what_to_do: str) -> None: 502 | if not self.switcher.current == "tree": 503 | return 504 | nodes = ( 505 | self.tree.selected_nodes 506 | if self.tree.selected_nodes 507 | else self.tree.highlighted_resource_node 508 | ) 509 | 510 | if nodes: 511 | self.selected_action = what_to_do 512 | resources = [ 513 | f"{node.parent.data}.{node.label.plain}".lstrip(".") for node in nodes 514 | ] 515 | 516 | question = Text.assemble( 517 | ("Are you sure you wish to ", "bold"), 518 | (what_to_do, "bold red"), 519 | (" the selected resources?\n\n - ", "bold"), 520 | ("\n - ".join(resources)), 521 | ) 522 | 523 | async def execute_if_yes(flag): 524 | if flag: 525 | await self.perform_action() 526 | 527 | self.push_screen(YesNoModal(question), execute_if_yes) 528 | 529 | async def action_delete(self) -> None: 530 | await self.action_manipulate_resources("delete") 531 | 532 | async def action_taint(self) -> None: 533 | await self.action_manipulate_resources("taint") 534 | 535 | async def action_untaint(self) -> None: 536 | await self.action_manipulate_resources("untaint") 537 | 538 | def action_copy(self) -> None: 539 | try: 540 | if self.switcher.current == "resource": 541 | pyperclip.copy(self.app.tree.current_node.data.contents) 542 | self.notify("Copied resource definition to clipboard") 543 | elif self.switcher.current == "tree": 544 | pyperclip.copy(self.app.tree.current_node.label.plain) 545 | self.notify("Copied resource name to clipboard") 546 | except Exception: 547 | self.notify( 548 | "Copy to clipboard is unsupported in this terminal", severity="warning" 549 | ) 550 | 551 | def action_refresh(self) -> None: 552 | self.switcher.current = "tree" 553 | self.tree.refresh_state() 554 | 555 | def action_help(self) -> None: 556 | self.push_screen(HelpModal()) 557 | 558 | def action_toggle_dark(self) -> None: 559 | self.dark = not self.dark 560 | 561 | def expand_node(self, level, node) -> None: 562 | if not node.allow_expand: 563 | return 564 | cnt = node.data.count(".module.") + 1 565 | if level <= cnt: 566 | return 567 | for child in node.children: 568 | self.expand_node(level, child) 569 | node.expand() 570 | 571 | def action_collapse(self, level=0) -> None: 572 | if not self.switcher.current == "tree": 573 | return 574 | if level == 0: 575 | self.tree.root.expand_all() 576 | else: 577 | self.tree.root.collapse_all() 578 | for node in self.tree.root.children: 579 | self.expand_node(level, node) 580 | self.tree.root.expand() 581 | 582 | def action_search(self) -> None: 583 | self.switcher.border_title = "" 584 | self.switcher.current = "tree" 585 | self.search.focus() 586 | 587 | def action_workspaces(self) -> None: 588 | if self.switcher.current != "tree": 589 | return 590 | 591 | result = subprocess.run( 592 | [ApplicationGlobals.executable, "workspace", "list"], 593 | capture_output=True, 594 | text=True, 595 | ) 596 | 597 | if result.returncode == 0: 598 | workspaces = [] 599 | for workspace in result.stdout.split("\n"): 600 | if workspace.strip(): 601 | workspaces.append(workspace[2:]) 602 | if workspace.startswith("*"): 603 | current_workspace = workspace[2:] 604 | else: 605 | logger.error(f"Error getting workspaces: {result.stderr}") 606 | self.notify("Failed getting workspaces", severity="error") 607 | 608 | def switch_workspace(selected_workspace: str): 609 | if ( 610 | selected_workspace is not None 611 | and selected_workspace != current_workspace 612 | ): 613 | result = subprocess.run( 614 | [ 615 | ApplicationGlobals.executable, 616 | "workspace", 617 | "select", 618 | selected_workspace, 619 | ], 620 | capture_output=True, 621 | text=True, 622 | ) 623 | if result.returncode == 0: 624 | self.app.get_child_by_id("header").refresh_info() 625 | self.action_refresh() 626 | else: 627 | logger.error(f"Failed switching workspaces: {result.stderr}") 628 | self.notify("Failed switching workspaces", severity="error") 629 | 630 | self.push_screen( 631 | WorkspaceModal(workspaces, current_workspace), switch_workspace 632 | ) 633 | 634 | def action_fullscreen(self) -> None: 635 | if self.switcher.current not in ("plan", "resource"): 636 | return 637 | self.push_screen( 638 | FullTextModal( 639 | ( 640 | self.tree.current_node.data.contents 641 | if self.switcher.current == "resource" 642 | else self.plan.fulltext 643 | ), 644 | self.switcher.current == "resource", 645 | ) 646 | ) 647 | self.plan.focus() 648 | 649 | def action_sensitive(self) -> None: 650 | if self.switcher.current != "resource": 651 | return 652 | fullname = f"{self.tree.current_node.data.submodule}.{self.tree.current_node.data.name}".strip( 653 | "." 654 | ) 655 | if self.tree.current_node.data.contents is None: 656 | self.notify("Unable to display sensitive contents", severity="warning") 657 | else: 658 | self.tree.display_sensitive_data( 659 | fullname, self.tree.current_node.data.contents 660 | ) 661 | 662 | def _handle_exception(self, exception: Exception) -> None: 663 | self.error_message = "".join( 664 | traceback.format_exception( 665 | type(exception), exception, exception.__traceback__ 666 | ) 667 | ) 668 | super()._handle_exception(exception) 669 | 670 | def _on_resize(self, event): 671 | main_height = max(event.size.height - 11, 5) 672 | self.switcher.styles.height = main_height 673 | logger.debug("Main height: %s", main_height) 674 | super()._on_resize(event) 675 | 676 | 677 | def parse_command_line() -> None: 678 | parser = argparse.ArgumentParser( 679 | prog="tftui", 680 | description="TFTUI - the Terraform terminal UI", 681 | epilog="Enjoy!", 682 | ) 683 | parser.add_argument( 684 | "-e", 685 | "--executable", 686 | help="set executable command (default 'terraform')", 687 | ) 688 | parser.add_argument( 689 | "-n", 690 | "--no-init", 691 | help="do not run terraform init on startup (default run)", 692 | action="store_true", 693 | ) 694 | parser.add_argument( 695 | "-f", 696 | "--var-file", 697 | help="tfvars filename to be used in planning", 698 | ) 699 | parser.add_argument( 700 | "-o", 701 | "--offline", 702 | help="run in offline mode (i.e. no outbound API calls; default online)", 703 | action="store_true", 704 | ) 705 | parser.add_argument( 706 | "-d", 707 | "--disable-usage-tracking", 708 | help="disable usage tracking (default enabled)", 709 | action="store_true", 710 | ) 711 | parser.add_argument( 712 | "-l", 713 | "--light-mode", 714 | help="enable light mode (default dark)", 715 | action="store_true", 716 | ) 717 | parser.add_argument( 718 | "-g", 719 | "--generate-debug-log", 720 | action="store_true", 721 | help="generate debug log file (default disabled)", 722 | ) 723 | parser.add_argument( 724 | "-v", "--version", help="show version information", action="store_true" 725 | ) 726 | args = parser.parse_args() 727 | 728 | if args.offline or args.disable_usage_tracking: 729 | OutboundAPIs.disable_usage_tracking() 730 | if not args.offline: 731 | OutboundAPIs.check_for_new_version() 732 | if args.version: 733 | print( 734 | f"\ntftui v{OutboundAPIs.version}{' (new version available)' if OutboundAPIs.is_new_version_available else ''}\n" 735 | ) 736 | exit(0) 737 | if args.executable: 738 | ApplicationGlobals.executable = args.executable 739 | if args.var_file: 740 | ApplicationGlobals.var_file = args.var_file 741 | if args.generate_debug_log: 742 | logger = setup_logging("debug") 743 | logger.debug("*" * 50) 744 | logger.debug(f"Debug log enabled (tftui v{OutboundAPIs.version})") 745 | ApplicationGlobals.darkmode = not args.light_mode 746 | if ( 747 | which(ApplicationGlobals.executable) is None 748 | and which(f"{ApplicationGlobals.executable}.exe") is None 749 | ): 750 | print( 751 | f"Executable '{ApplicationGlobals.executable}' not found. Please install and try again." 752 | ) 753 | exit(1) 754 | 755 | 756 | def main() -> None: 757 | parse_command_line() 758 | OutboundAPIs.post_usage("started application", platform=platform.platform()) 759 | 760 | result = "" 761 | try: 762 | app = TerraformTUI() 763 | result = app.run() 764 | finally: 765 | if app.return_code > 0: 766 | ApplicationGlobals.successful_termination = False 767 | 768 | if result is not None: 769 | print(result) 770 | if ApplicationGlobals.successful_termination: 771 | OutboundAPIs.post_usage("exited successfully") 772 | else: 773 | error_message = re.sub( 774 | r"(?<=[/\\])[^\s/\\]+(?=[/\\])", 775 | "***", 776 | app.error_message or str(result).strip(), 777 | ).split("\n") 778 | logger.debug(error_message) 779 | OutboundAPIs.post_usage("exited unsuccessfully", error_message=error_message) 780 | 781 | if OutboundAPIs.is_new_version_available: 782 | print("\n*** New version available. ***") 783 | 784 | print( 785 | """ 786 | For questions and suggestions, please visit https://github.com/idoavrah/terraform-tui/discussions 787 | For issues and bugs, please visit https://github.com/idoavrah/terraform-tui/issues 788 | 789 | Bye! 790 | """ 791 | ) 792 | 793 | 794 | if __name__ == "__main__": 795 | main() 796 | -------------------------------------------------------------------------------- /tftui/apis.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import socket 3 | import hashlib 4 | import importlib.metadata 5 | import requests 6 | from tftui.constants import nouns, adjectives 7 | 8 | 9 | class OutboundAPIs: 10 | is_new_version_available = False 11 | is_usage_tracking_enabled = True 12 | generated_handle = None 13 | posthog = None 14 | version = importlib.metadata.version("tftui") 15 | 16 | @staticmethod 17 | def check_for_new_version(): 18 | try: 19 | response = requests.get("https://pypi.org/pypi/tftui/json") 20 | if response.status_code == 200: 21 | ver = response.json()["info"]["version"] 22 | if ver != OutboundAPIs.version: 23 | OutboundAPIs.is_new_version_available = True 24 | except Exception: 25 | pass 26 | 27 | @staticmethod 28 | def generate_handle(): 29 | fingerprint_data = f"{platform.system()}-{platform.node()}-{platform.release()}-{socket.gethostname()}" 30 | fingerprint = int(hashlib.sha256(fingerprint_data.encode()).hexdigest(), 16) 31 | OutboundAPIs.generated_handle = ( 32 | adjectives[fingerprint % len(adjectives)] 33 | + " " 34 | + nouns[fingerprint % len(nouns)] 35 | ) 36 | 37 | @staticmethod 38 | def post_usage(message: str, error_message="", platform="", size="") -> None: 39 | if not OutboundAPIs.is_usage_tracking_enabled: 40 | return 41 | if not OutboundAPIs.generated_handle: 42 | OutboundAPIs.generate_handle() 43 | if not OutboundAPIs.posthog: 44 | from posthog import Posthog 45 | 46 | POSTHOG_API_KEY = "phc_tjGzx7V6Y85JdNfOFWxQLXo5wtUs6MeVLvoVfybqz09" # + "uncomment-while-developing" 47 | 48 | OutboundAPIs.posthog = Posthog( 49 | project_api_key=POSTHOG_API_KEY, 50 | host="https://app.posthog.com", 51 | disable_geoip=False, 52 | ) 53 | 54 | OutboundAPIs.posthog.capture( 55 | OutboundAPIs.generated_handle, 56 | message, 57 | { 58 | "tftui_version": OutboundAPIs.version, 59 | "error_message": error_message, 60 | "platform": platform, 61 | "size": size, 62 | }, 63 | ) 64 | 65 | @staticmethod 66 | def disable_usage_tracking() -> None: 67 | OutboundAPIs.is_usage_tracking_enabled = False 68 | -------------------------------------------------------------------------------- /tftui/constants.py: -------------------------------------------------------------------------------- 1 | nouns = [ 2 | "ability", 3 | "abroad", 4 | "abuse", 5 | "access", 6 | "accident", 7 | "account", 8 | "act", 9 | "action", 10 | "active", 11 | "activity", 12 | "actor", 13 | "ad", 14 | "addition", 15 | "address", 16 | "administration", 17 | "adult", 18 | "advance", 19 | "advantage", 20 | "advertising", 21 | "advice", 22 | "affair", 23 | "affect", 24 | "afternoon", 25 | "age", 26 | "agency", 27 | "agent", 28 | "agreement", 29 | "air", 30 | "airline", 31 | "airport", 32 | "alarm", 33 | "alcohol", 34 | "alternative", 35 | "ambition", 36 | "amount", 37 | "analysis", 38 | "analyst", 39 | "anger", 40 | "angle", 41 | "animal", 42 | "annual", 43 | "answer", 44 | "anxiety", 45 | "anybody", 46 | "anything", 47 | "anywhere", 48 | "apartment", 49 | "appeal", 50 | "appearance", 51 | "apple", 52 | "application", 53 | "appointment", 54 | "area", 55 | "argument", 56 | "arm", 57 | "army", 58 | "arrival", 59 | "art", 60 | "article", 61 | "aside", 62 | "ask", 63 | "aspect", 64 | "assignment", 65 | "assist", 66 | "assistance", 67 | "assistant", 68 | "associate", 69 | "association", 70 | "assumption", 71 | "atmosphere", 72 | "attack", 73 | "attempt", 74 | "attention", 75 | "attitude", 76 | "audience", 77 | "author", 78 | "average", 79 | "award", 80 | "awareness", 81 | "baby", 82 | "back", 83 | "background", 84 | "bad", 85 | "bag", 86 | "bake", 87 | "balance", 88 | "ball", 89 | "band", 90 | "bank", 91 | "bar", 92 | "base", 93 | "baseball", 94 | "basis", 95 | "basket", 96 | "bat", 97 | "bath", 98 | "bathroom", 99 | "battle", 100 | "beach", 101 | "bear", 102 | "beat", 103 | "beautiful", 104 | "bed", 105 | "bedroom", 106 | "beer", 107 | "beginning", 108 | "being", 109 | "bell", 110 | "belt", 111 | "bench", 112 | "bend", 113 | "benefit", 114 | "bet", 115 | "beyond", 116 | "bicycle", 117 | "bid", 118 | "big", 119 | "bike", 120 | "bill", 121 | "bird", 122 | "birth", 123 | "birthday", 124 | "bit", 125 | "bite", 126 | "bitter", 127 | "black", 128 | "blame", 129 | "blank", 130 | "blind", 131 | "block", 132 | "blood", 133 | "blow", 134 | "blue", 135 | "board", 136 | "boat", 137 | "body", 138 | "bone", 139 | "bonus", 140 | "book", 141 | "boot", 142 | "border", 143 | "boss", 144 | "bother", 145 | "bottle", 146 | "bottom", 147 | "bowl", 148 | "box", 149 | "boy", 150 | "boyfriend", 151 | "brain", 152 | "branch", 153 | "brave", 154 | "bread", 155 | "break", 156 | "breakfast", 157 | "breast", 158 | "breath", 159 | "brick", 160 | "bridge", 161 | "brief", 162 | "brilliant", 163 | "broad", 164 | "brother", 165 | "brown", 166 | "brush", 167 | "buddy", 168 | "budget", 169 | "bug", 170 | "building", 171 | "bunch", 172 | "burn", 173 | "bus", 174 | "business", 175 | "button", 176 | "buy", 177 | "buyer", 178 | "cabinet", 179 | "cable", 180 | "cake", 181 | "calendar", 182 | "call", 183 | "calm", 184 | "camera", 185 | "camp", 186 | "campaign", 187 | "can", 188 | "cancel", 189 | "cancer", 190 | "candidate", 191 | "candle", 192 | "candy", 193 | "cap", 194 | "capital", 195 | "car", 196 | "card", 197 | "care", 198 | "career", 199 | "carpet", 200 | "carry", 201 | "case", 202 | "cash", 203 | "cat", 204 | "catch", 205 | "category", 206 | "cause", 207 | "celebration", 208 | "cell", 209 | "chain", 210 | "chair", 211 | "challenge", 212 | "champion", 213 | "championship", 214 | "chance", 215 | "change", 216 | "channel", 217 | "chapter", 218 | "character", 219 | "charge", 220 | "charity", 221 | "chart", 222 | "check", 223 | "cheek", 224 | "chemical", 225 | "chemistry", 226 | "chest", 227 | "chicken", 228 | "child", 229 | "childhood", 230 | "chip", 231 | "chocolate", 232 | "choice", 233 | "church", 234 | "cigarette", 235 | "city", 236 | "claim", 237 | "class", 238 | "classic", 239 | "classroom", 240 | "clerk", 241 | "click", 242 | "client", 243 | "climate", 244 | "clock", 245 | "closet", 246 | "clothes", 247 | "cloud", 248 | "club", 249 | "clue", 250 | "coach", 251 | "coast", 252 | "coat", 253 | "code", 254 | "coffee", 255 | "cold", 256 | "collar", 257 | "collection", 258 | "college", 259 | "combination", 260 | "combine", 261 | "comfort", 262 | "comfortable", 263 | "command", 264 | "comment", 265 | "commercial", 266 | "commission", 267 | "committee", 268 | "common", 269 | "communication", 270 | "community", 271 | "company", 272 | "comparison", 273 | "competition", 274 | "complaint", 275 | "complex", 276 | "computer", 277 | "concentrate", 278 | "concept", 279 | "concern", 280 | "concert", 281 | "conclusion", 282 | "condition", 283 | "conference", 284 | "confidence", 285 | "conflict", 286 | "confusion", 287 | "connection", 288 | "consequence", 289 | "consideration", 290 | "consist", 291 | "constant", 292 | "construction", 293 | "contact", 294 | "contest", 295 | "context", 296 | "contract", 297 | "contribution", 298 | "control", 299 | "conversation", 300 | "convert", 301 | "cook", 302 | "cookie", 303 | "copy", 304 | "corner", 305 | "cost", 306 | "count", 307 | "counter", 308 | "country", 309 | "county", 310 | "couple", 311 | "courage", 312 | "course", 313 | "court", 314 | "cousin", 315 | "cover", 316 | "cow", 317 | "crack", 318 | "craft", 319 | "crash", 320 | "crazy", 321 | "cream", 322 | "creative", 323 | "credit", 324 | "crew", 325 | "criticism", 326 | "cross", 327 | "cry", 328 | "culture", 329 | "cup", 330 | "currency", 331 | "current", 332 | "curve", 333 | "customer", 334 | "cut", 335 | "cycle", 336 | "dad", 337 | "damage", 338 | "dance", 339 | "dare", 340 | "dark", 341 | "data", 342 | "database", 343 | "date", 344 | "daughter", 345 | "day", 346 | "dead", 347 | "deal", 348 | "dealer", 349 | "dear", 350 | "death", 351 | "debate", 352 | "debt", 353 | "decision", 354 | "deep", 355 | "definition", 356 | "degree", 357 | "delay", 358 | "delivery", 359 | "demand", 360 | "department", 361 | "departure", 362 | "dependent", 363 | "deposit", 364 | "depression", 365 | "depth", 366 | "description", 367 | "design", 368 | "designer", 369 | "desire", 370 | "desk", 371 | "detail", 372 | "development", 373 | "device", 374 | "devil", 375 | "diamond", 376 | "diet", 377 | "difference", 378 | "difficulty", 379 | "dig", 380 | "dimension", 381 | "dinner", 382 | "direction", 383 | "director", 384 | "dirt", 385 | "disaster", 386 | "discipline", 387 | "discount", 388 | "discussion", 389 | "disease", 390 | "dish", 391 | "disk", 392 | "display", 393 | "distance", 394 | "distribution", 395 | "district", 396 | "divide", 397 | "doctor", 398 | "document", 399 | "dog", 400 | "door", 401 | "dot", 402 | "double", 403 | "doubt", 404 | "draft", 405 | "drag", 406 | "drama", 407 | "draw", 408 | "drawer", 409 | "drawing", 410 | "dream", 411 | "dress", 412 | "drink", 413 | "drive", 414 | "driver", 415 | "drop", 416 | "drunk", 417 | "due", 418 | "dump", 419 | "dust", 420 | "duty", 421 | "ear", 422 | "earth", 423 | "ease", 424 | "east", 425 | "eat", 426 | "economics", 427 | "economy", 428 | "edge", 429 | "editor", 430 | "education", 431 | "effect", 432 | "effective", 433 | "efficiency", 434 | "effort", 435 | "egg", 436 | "election", 437 | "elevator", 438 | "emergency", 439 | "emotion", 440 | "emphasis", 441 | "employ", 442 | "employee", 443 | "employer", 444 | "employment", 445 | "end", 446 | "energy", 447 | "engine", 448 | "engineer", 449 | "engineering", 450 | "entertainment", 451 | "enthusiasm", 452 | "entrance", 453 | "entry", 454 | "environment", 455 | "equal", 456 | "equipment", 457 | "equivalent", 458 | "error", 459 | "escape", 460 | "essay", 461 | "establishment", 462 | "estate", 463 | "estimate", 464 | "evening", 465 | "event", 466 | "evidence", 467 | "exam", 468 | "examination", 469 | "example", 470 | "exchange", 471 | "excitement", 472 | "excuse", 473 | "exercise", 474 | "exit", 475 | "experience", 476 | "expert", 477 | "explanation", 478 | "expression", 479 | "extension", 480 | "extent", 481 | "external", 482 | "extreme", 483 | "eye", 484 | "face", 485 | "fact", 486 | "factor", 487 | "fail", 488 | "failure", 489 | "fall", 490 | "familiar", 491 | "family", 492 | "fan", 493 | "farm", 494 | "farmer", 495 | "fat", 496 | "father", 497 | "fault", 498 | "fear", 499 | "feature", 500 | "fee", 501 | "feed", 502 | "feedback", 503 | "feel", 504 | "feeling", 505 | "female", 506 | "few", 507 | "field", 508 | "fight", 509 | "figure", 510 | "file", 511 | "fill", 512 | "film", 513 | "final", 514 | "finance", 515 | "finding", 516 | "finger", 517 | "finish", 518 | "fire", 519 | "fish", 520 | "fishing", 521 | "fix", 522 | "flight", 523 | "floor", 524 | "flow", 525 | "flower", 526 | "fly", 527 | "focus", 528 | "fold", 529 | "following", 530 | "food", 531 | "foot", 532 | "football", 533 | "force", 534 | "forever", 535 | "form", 536 | "formal", 537 | "fortune", 538 | "foundation", 539 | "frame", 540 | "freedom", 541 | "friend", 542 | "friendship", 543 | "front", 544 | "fruit", 545 | "fuel", 546 | "fun", 547 | "function", 548 | "funeral", 549 | "funny", 550 | "future", 551 | "gain", 552 | "game", 553 | "gap", 554 | "garage", 555 | "garbage", 556 | "garden", 557 | "gas", 558 | "gate", 559 | "gather", 560 | "gear", 561 | "gene", 562 | "general", 563 | "gift", 564 | "girl", 565 | "girlfriend", 566 | "give", 567 | "glad", 568 | "glass", 569 | "glove", 570 | "go", 571 | "goal", 572 | "god", 573 | "gold", 574 | "golf", 575 | "good", 576 | "government", 577 | "grab", 578 | "grade", 579 | "grand", 580 | "grandfather", 581 | "grandmother", 582 | "grass", 583 | "great", 584 | "green", 585 | "grocery", 586 | "ground", 587 | "group", 588 | "growth", 589 | "guarantee", 590 | "guard", 591 | "guess", 592 | "guest", 593 | "guidance", 594 | "guide", 595 | "guitar", 596 | "guy", 597 | "habit", 598 | "hair", 599 | "half", 600 | "hall", 601 | "hand", 602 | "handle", 603 | "hang", 604 | "harm", 605 | "hat", 606 | "hate", 607 | "head", 608 | "health", 609 | "hearing", 610 | "heart", 611 | "heat", 612 | "heavy", 613 | "height", 614 | "hell", 615 | "hello", 616 | "help", 617 | "hide", 618 | "high", 619 | "highlight", 620 | "highway", 621 | "hire", 622 | "historian", 623 | "history", 624 | "hit", 625 | "hold", 626 | "hole", 627 | "holiday", 628 | "home", 629 | "homework", 630 | "honey", 631 | "hook", 632 | "hope", 633 | "horror", 634 | "horse", 635 | "hospital", 636 | "host", 637 | "hotel", 638 | "hour", 639 | "house", 640 | "housing", 641 | "human", 642 | "hunt", 643 | "hurry", 644 | "hurt", 645 | "husband", 646 | "ice", 647 | "idea", 648 | "ideal", 649 | "if", 650 | "illegal", 651 | "image", 652 | "imagination", 653 | "impact", 654 | "implement", 655 | "importance", 656 | "impress", 657 | "impression", 658 | "improvement", 659 | "incident", 660 | "income", 661 | "increase", 662 | "independence", 663 | "independent", 664 | "indication", 665 | "individual", 666 | "industry", 667 | "inevitable", 668 | "inflation", 669 | "influence", 670 | "information", 671 | "initial", 672 | "initiative", 673 | "injury", 674 | "insect", 675 | "inside", 676 | "inspection", 677 | "inspector", 678 | "instance", 679 | "instruction", 680 | "insurance", 681 | "intention", 682 | "interaction", 683 | "interest", 684 | "internal", 685 | "international", 686 | "internet", 687 | "interview", 688 | "introduction", 689 | "investment", 690 | "invite", 691 | "iron", 692 | "island", 693 | "issue", 694 | "it", 695 | "item", 696 | "jacket", 697 | "job", 698 | "join", 699 | "joint", 700 | "joke", 701 | "judge", 702 | "judgment", 703 | "juice", 704 | "jump", 705 | "junior", 706 | "jury", 707 | "keep", 708 | "key", 709 | "kick", 710 | "kid", 711 | "kill", 712 | "kind", 713 | "king", 714 | "kiss", 715 | "kitchen", 716 | "knee", 717 | "knife", 718 | "knowledge", 719 | "lab", 720 | "lack", 721 | "ladder", 722 | "lady", 723 | "lake", 724 | "land", 725 | "landscape", 726 | "language", 727 | "laugh", 728 | "law", 729 | "lawyer", 730 | "lay", 731 | "layer", 732 | "lead", 733 | "leader", 734 | "leadership", 735 | "leading", 736 | "league", 737 | "leather", 738 | "leave", 739 | "lecture", 740 | "leg", 741 | "length", 742 | "lesson", 743 | "let", 744 | "letter", 745 | "level", 746 | "library", 747 | "lie", 748 | "life", 749 | "lift", 750 | "light", 751 | "limit", 752 | "line", 753 | "link", 754 | "lip", 755 | "list", 756 | "listen", 757 | "literature", 758 | "living", 759 | "load", 760 | "loan", 761 | "local", 762 | "location", 763 | "lock", 764 | "log", 765 | "long", 766 | "look", 767 | "loss", 768 | "love", 769 | "low", 770 | "luck", 771 | "lunch", 772 | "machine", 773 | "magazine", 774 | "mail", 775 | "main", 776 | "maintenance", 777 | "major", 778 | "make", 779 | "male", 780 | "mall", 781 | "man", 782 | "management", 783 | "manager", 784 | "manner", 785 | "manufacturer", 786 | "many", 787 | "map", 788 | "march", 789 | "mark", 790 | "market", 791 | "marketing", 792 | "marriage", 793 | "master", 794 | "match", 795 | "mate", 796 | "material", 797 | "math", 798 | "matter", 799 | "maximum", 800 | "maybe", 801 | "meal", 802 | "meaning", 803 | "measurement", 804 | "meat", 805 | "media", 806 | "medicine", 807 | "medium", 808 | "meet", 809 | "meeting", 810 | "member", 811 | "membership", 812 | "memory", 813 | "mention", 814 | "menu", 815 | "mess", 816 | "message", 817 | "metal", 818 | "method", 819 | "middle", 820 | "midnight", 821 | "might", 822 | "milk", 823 | "mind", 824 | "mine", 825 | "minimum", 826 | "minor", 827 | "minute", 828 | "mirror", 829 | "miss", 830 | "mission", 831 | "mistake", 832 | "mix", 833 | "mixture", 834 | "mobile", 835 | "mode", 836 | "model", 837 | "mom", 838 | "moment", 839 | "money", 840 | "monitor", 841 | "month", 842 | "mood", 843 | "morning", 844 | "mortgage", 845 | "most", 846 | "mother", 847 | "motor", 848 | "mountain", 849 | "mouse", 850 | "mouth", 851 | "move", 852 | "movie", 853 | "mud", 854 | "muscle", 855 | "music", 856 | "nail", 857 | "name", 858 | "nasty", 859 | "nation", 860 | "national", 861 | "native", 862 | "natural", 863 | "nature", 864 | "neat", 865 | "necessary", 866 | "neck", 867 | "negative", 868 | "negotiation", 869 | "nerve", 870 | "net", 871 | "network", 872 | "news", 873 | "newspaper", 874 | "night", 875 | "nobody", 876 | "noise", 877 | "normal", 878 | "north", 879 | "nose", 880 | "note", 881 | "nothing", 882 | "notice", 883 | "novel", 884 | "number", 885 | "nurse", 886 | "object", 887 | "objective", 888 | "obligation", 889 | "occasion", 890 | "offer", 891 | "office", 892 | "officer", 893 | "official", 894 | "oil", 895 | "one", 896 | "opening", 897 | "operation", 898 | "opinion", 899 | "opportunity", 900 | "opposite", 901 | "option", 902 | "orange", 903 | "order", 904 | "ordinary", 905 | "organization", 906 | "original", 907 | "other", 908 | "outcome", 909 | "outside", 910 | "oven", 911 | "owner", 912 | "pace", 913 | "pack", 914 | "package", 915 | "page", 916 | "pain", 917 | "paint", 918 | "painting", 919 | "pair", 920 | "panic", 921 | "paper", 922 | "parent", 923 | "park", 924 | "parking", 925 | "part", 926 | "particular", 927 | "partner", 928 | "party", 929 | "pass", 930 | "passage", 931 | "passenger", 932 | "passion", 933 | "past", 934 | "path", 935 | "patience", 936 | "patient", 937 | "pattern", 938 | "pause", 939 | "pay", 940 | "payment", 941 | "peace", 942 | "peak", 943 | "pen", 944 | "penalty", 945 | "pension", 946 | "people", 947 | "percentage", 948 | "perception", 949 | "performance", 950 | "period", 951 | "permission", 952 | "permit", 953 | "person", 954 | "personal", 955 | "personality", 956 | "perspective", 957 | "phase", 958 | "philosophy", 959 | "phone", 960 | "photo", 961 | "phrase", 962 | "physical", 963 | "physics", 964 | "piano", 965 | "pick", 966 | "picture", 967 | "pie", 968 | "piece", 969 | "pin", 970 | "pipe", 971 | "pitch", 972 | "pizza", 973 | "place", 974 | "plan", 975 | "plane", 976 | "plant", 977 | "plastic", 978 | "plate", 979 | "platform", 980 | "play", 981 | "player", 982 | "pleasure", 983 | "plenty", 984 | "poem", 985 | "poet", 986 | "poetry", 987 | "point", 988 | "police", 989 | "policy", 990 | "politics", 991 | "pollution", 992 | "pool", 993 | "pop", 994 | "population", 995 | "position", 996 | "positive", 997 | "possession", 998 | "possibility", 999 | "possible", 1000 | "post", 1001 | "pot", 1002 | "potato", 1003 | "potential", 1004 | "pound", 1005 | "power", 1006 | "practice", 1007 | "preference", 1008 | "preparation", 1009 | "presence", 1010 | "present", 1011 | "presentation", 1012 | "president", 1013 | "press", 1014 | "pressure", 1015 | "price", 1016 | "pride", 1017 | "priest", 1018 | "primary", 1019 | "principle", 1020 | "print", 1021 | "prior", 1022 | "priority", 1023 | "private", 1024 | "prize", 1025 | "problem", 1026 | "procedure", 1027 | "process", 1028 | "produce", 1029 | "product", 1030 | "profession", 1031 | "professional", 1032 | "professor", 1033 | "profile", 1034 | "profit", 1035 | "program", 1036 | "progress", 1037 | "project", 1038 | "promise", 1039 | "promotion", 1040 | "prompt", 1041 | "proof", 1042 | "property", 1043 | "proposal", 1044 | "protection", 1045 | "psychology", 1046 | "public", 1047 | "pull", 1048 | "punch", 1049 | "purchase", 1050 | "purple", 1051 | "purpose", 1052 | "push", 1053 | "put", 1054 | "quality", 1055 | "quantity", 1056 | "quarter", 1057 | "queen", 1058 | "question", 1059 | "quiet", 1060 | "quit", 1061 | "quote", 1062 | "race", 1063 | "radio", 1064 | "rain", 1065 | "raise", 1066 | "range", 1067 | "rate", 1068 | "ratio", 1069 | "raw", 1070 | "reach", 1071 | "reaction", 1072 | "read", 1073 | "reading", 1074 | "reality", 1075 | "reason", 1076 | "reception", 1077 | "recipe", 1078 | "recognition", 1079 | "recommendation", 1080 | "record", 1081 | "recording", 1082 | "recover", 1083 | "red", 1084 | "reference", 1085 | "reflection", 1086 | "refrigerator", 1087 | "refuse", 1088 | "region", 1089 | "register", 1090 | "regret", 1091 | "regular", 1092 | "relation", 1093 | "relationship", 1094 | "relative", 1095 | "release", 1096 | "relief", 1097 | "remote", 1098 | "remove", 1099 | "rent", 1100 | "repair", 1101 | "repeat", 1102 | "replacement", 1103 | "reply", 1104 | "report", 1105 | "representative", 1106 | "republic", 1107 | "reputation", 1108 | "request", 1109 | "requirement", 1110 | "research", 1111 | "reserve", 1112 | "resident", 1113 | "resist", 1114 | "resolution", 1115 | "resolve", 1116 | "resort", 1117 | "resource", 1118 | "respect", 1119 | "respond", 1120 | "response", 1121 | "responsibility", 1122 | "rest", 1123 | "restaurant", 1124 | "result", 1125 | "return", 1126 | "reveal", 1127 | "revenue", 1128 | "review", 1129 | "revolution", 1130 | "reward", 1131 | "rice", 1132 | "rich", 1133 | "ride", 1134 | "ring", 1135 | "rip", 1136 | "rise", 1137 | "risk", 1138 | "river", 1139 | "road", 1140 | "rock", 1141 | "role", 1142 | "roll", 1143 | "roof", 1144 | "room", 1145 | "rope", 1146 | "rough", 1147 | "round", 1148 | "routine", 1149 | "row", 1150 | "royal", 1151 | "rub", 1152 | "ruin", 1153 | "rule", 1154 | "run", 1155 | "rush", 1156 | "sad", 1157 | "safe", 1158 | "safety", 1159 | "sail", 1160 | "salad", 1161 | "salary", 1162 | "sale", 1163 | "salt", 1164 | "sample", 1165 | "sand", 1166 | "sandwich", 1167 | "satisfaction", 1168 | "save", 1169 | "savings", 1170 | "scale", 1171 | "scene", 1172 | "schedule", 1173 | "scheme", 1174 | "school", 1175 | "science", 1176 | "score", 1177 | "scratch", 1178 | "screen", 1179 | "screw", 1180 | "script", 1181 | "sea", 1182 | "search", 1183 | "season", 1184 | "seat", 1185 | "second", 1186 | "secret", 1187 | "secretary", 1188 | "section", 1189 | "sector", 1190 | "security", 1191 | "selection", 1192 | "self", 1193 | "sell", 1194 | "senior", 1195 | "sense", 1196 | "sensitive", 1197 | "sentence", 1198 | "series", 1199 | "serve", 1200 | "service", 1201 | "session", 1202 | "set", 1203 | "setting", 1204 | "sex", 1205 | "shake", 1206 | "shame", 1207 | "shape", 1208 | "share", 1209 | "she", 1210 | "shelter", 1211 | "shift", 1212 | "shine", 1213 | "ship", 1214 | "shirt", 1215 | "shock", 1216 | "shoe", 1217 | "shoot", 1218 | "shop", 1219 | "shopping", 1220 | "shot", 1221 | "shoulder", 1222 | "show", 1223 | "shower", 1224 | "sick", 1225 | "side", 1226 | "sign", 1227 | "signal", 1228 | "signature", 1229 | "significance", 1230 | "silly", 1231 | "silver", 1232 | "simple", 1233 | "sing", 1234 | "singer", 1235 | "single", 1236 | "sink", 1237 | "sir", 1238 | "sister", 1239 | "site", 1240 | "situation", 1241 | "size", 1242 | "skill", 1243 | "skin", 1244 | "skirt", 1245 | "sky", 1246 | "sleep", 1247 | "slice", 1248 | "slide", 1249 | "slip", 1250 | "smell", 1251 | "smile", 1252 | "smoke", 1253 | "snow", 1254 | "society", 1255 | "sock", 1256 | "soft", 1257 | "software", 1258 | "soil", 1259 | "solid", 1260 | "solution", 1261 | "somewhere", 1262 | "son", 1263 | "song", 1264 | "sort", 1265 | "sound", 1266 | "soup", 1267 | "source", 1268 | "south", 1269 | "space", 1270 | "spare", 1271 | "speaker", 1272 | "special", 1273 | "specialist", 1274 | "specific", 1275 | "speech", 1276 | "speed", 1277 | "spell", 1278 | "spend", 1279 | "spirit", 1280 | "spiritual", 1281 | "spite", 1282 | "split", 1283 | "sport", 1284 | "spot", 1285 | "spray", 1286 | "spread", 1287 | "spring", 1288 | "square", 1289 | "stable", 1290 | "staff", 1291 | "stage", 1292 | "stand", 1293 | "standard", 1294 | "star", 1295 | "start", 1296 | "state", 1297 | "statement", 1298 | "station", 1299 | "status", 1300 | "stay", 1301 | "steak", 1302 | "steal", 1303 | "step", 1304 | "stick", 1305 | "still", 1306 | "stock", 1307 | "stomach", 1308 | "stop", 1309 | "storage", 1310 | "store", 1311 | "storm", 1312 | "story", 1313 | "strain", 1314 | "stranger", 1315 | "strategy", 1316 | "street", 1317 | "strength", 1318 | "stress", 1319 | "stretch", 1320 | "strike", 1321 | "string", 1322 | "strip", 1323 | "stroke", 1324 | "structure", 1325 | "struggle", 1326 | "student", 1327 | "studio", 1328 | "study", 1329 | "stuff", 1330 | "stupid", 1331 | "style", 1332 | "subject", 1333 | "substance", 1334 | "success", 1335 | "suck", 1336 | "sugar", 1337 | "suggestion", 1338 | "suit", 1339 | "summer", 1340 | "sun", 1341 | "supermarket", 1342 | "support", 1343 | "surgery", 1344 | "surprise", 1345 | "surround", 1346 | "survey", 1347 | "suspect", 1348 | "sweet", 1349 | "swim", 1350 | "swimming", 1351 | "swing", 1352 | "switch", 1353 | "sympathy", 1354 | "system", 1355 | "table", 1356 | "tackle", 1357 | "tale", 1358 | "talk", 1359 | "tank", 1360 | "tap", 1361 | "target", 1362 | "task", 1363 | "taste", 1364 | "tax", 1365 | "tea", 1366 | "teach", 1367 | "teacher", 1368 | "teaching", 1369 | "team", 1370 | "tear", 1371 | "technology", 1372 | "telephone", 1373 | "television", 1374 | "tell", 1375 | "temperature", 1376 | "temporary", 1377 | "tennis", 1378 | "tension", 1379 | "term", 1380 | "test", 1381 | "text", 1382 | "thanks", 1383 | "theme", 1384 | "theory", 1385 | "thing", 1386 | "thought", 1387 | "throat", 1388 | "ticket", 1389 | "tie", 1390 | "till", 1391 | "time", 1392 | "tip", 1393 | "title", 1394 | "today", 1395 | "toe", 1396 | "tomorrow", 1397 | "tone", 1398 | "tongue", 1399 | "tonight", 1400 | "tool", 1401 | "tooth", 1402 | "top", 1403 | "topic", 1404 | "total", 1405 | "touch", 1406 | "tough", 1407 | "tour", 1408 | "tourist", 1409 | "towel", 1410 | "tower", 1411 | "town", 1412 | "track", 1413 | "trade", 1414 | "tradition", 1415 | "traffic", 1416 | "train", 1417 | "trainer", 1418 | "training", 1419 | "transition", 1420 | "transportation", 1421 | "trash", 1422 | "travel", 1423 | "treat", 1424 | "tree", 1425 | "trick", 1426 | "trip", 1427 | "trouble", 1428 | "truck", 1429 | "trust", 1430 | "truth", 1431 | "try", 1432 | "tune", 1433 | "turn", 1434 | "twist", 1435 | "two", 1436 | "type", 1437 | "uncle", 1438 | "understanding", 1439 | "union", 1440 | "unique", 1441 | "unit", 1442 | "university", 1443 | "upper", 1444 | "upstairs", 1445 | "use", 1446 | "user", 1447 | "usual", 1448 | "vacation", 1449 | "valuable", 1450 | "value", 1451 | "variation", 1452 | "variety", 1453 | "vast", 1454 | "vegetable", 1455 | "vehicle", 1456 | "version", 1457 | "video", 1458 | "view", 1459 | "village", 1460 | "virus", 1461 | "visit", 1462 | "visual", 1463 | "voice", 1464 | "volume", 1465 | "wait", 1466 | "wake", 1467 | "walk", 1468 | "wall", 1469 | "war", 1470 | "warning", 1471 | "wash", 1472 | "watch", 1473 | "water", 1474 | "wave", 1475 | "way", 1476 | "weakness", 1477 | "wealth", 1478 | "wear", 1479 | "weather", 1480 | "web", 1481 | "wedding", 1482 | "week", 1483 | "weekend", 1484 | "weight", 1485 | "weird", 1486 | "welcome", 1487 | "west", 1488 | "western", 1489 | "wheel", 1490 | "whereas", 1491 | "while", 1492 | "white", 1493 | "whole", 1494 | "wife", 1495 | "will", 1496 | "win", 1497 | "wind", 1498 | "window", 1499 | "wine", 1500 | "wing", 1501 | "winner", 1502 | "winter", 1503 | "wish", 1504 | "witness", 1505 | "woman", 1506 | "wonder", 1507 | "wood", 1508 | "word", 1509 | "work", 1510 | "worker", 1511 | "working", 1512 | "world", 1513 | "worry", 1514 | "worth", 1515 | "wrap", 1516 | "writer", 1517 | "writing", 1518 | "yard", 1519 | "year", 1520 | "yellow", 1521 | "yesterday", 1522 | "you", 1523 | "young", 1524 | "youth", 1525 | "zone", 1526 | ] 1527 | adjectives = [ 1528 | "abandoned", 1529 | "able", 1530 | "absolute", 1531 | "academic", 1532 | "acceptable", 1533 | "acclaimed", 1534 | "accomplished", 1535 | "accurate", 1536 | "aching", 1537 | "acidic", 1538 | "acrobatic", 1539 | "active", 1540 | "actual", 1541 | "adept", 1542 | "admirable", 1543 | "admired", 1544 | "adolescent", 1545 | "adorable", 1546 | "adorable", 1547 | "adored", 1548 | "advanced", 1549 | "adventurous", 1550 | "affectionate", 1551 | "afraid", 1552 | "aged", 1553 | "aggravating", 1554 | "aggressive", 1555 | "agile", 1556 | "agitated", 1557 | "agonizing", 1558 | "agreeable", 1559 | "ajar", 1560 | "alarmed", 1561 | "alarming", 1562 | "alert", 1563 | "alienated", 1564 | "alive", 1565 | "all", 1566 | "altruistic", 1567 | "amazing", 1568 | "ambitious", 1569 | "ample", 1570 | "amused", 1571 | "amusing", 1572 | "anchored", 1573 | "ancient", 1574 | "angelic", 1575 | "angry", 1576 | "anguished", 1577 | "animated", 1578 | "annual", 1579 | "another", 1580 | "antique", 1581 | "anxious", 1582 | "any", 1583 | "apprehensive", 1584 | "appropriate", 1585 | "apt", 1586 | "arctic", 1587 | "arid", 1588 | "aromatic", 1589 | "artistic", 1590 | "ashamed", 1591 | "assured", 1592 | "astonishing", 1593 | "athletic", 1594 | "attached", 1595 | "attentive", 1596 | "attractive", 1597 | "austere", 1598 | "authentic", 1599 | "authorized", 1600 | "automatic", 1601 | "avaricious", 1602 | "average", 1603 | "aware", 1604 | "awesome", 1605 | "awful", 1606 | "awkward", 1607 | "babyish", 1608 | "back", 1609 | "bad", 1610 | "baggy", 1611 | "bare", 1612 | "barren", 1613 | "basic", 1614 | "beautiful", 1615 | "belated", 1616 | "beloved", 1617 | "beneficial", 1618 | "best", 1619 | "better", 1620 | "bewitched", 1621 | "big", 1622 | "big-hearted", 1623 | "biodegradable", 1624 | "bite-sized", 1625 | "bitter", 1626 | "black", 1627 | "black-and-white", 1628 | "bland", 1629 | "blank", 1630 | "blaring", 1631 | "bleak", 1632 | "blind", 1633 | "blissful", 1634 | "blond", 1635 | "blue", 1636 | "blushing", 1637 | "bogus", 1638 | "boiling", 1639 | "bold", 1640 | "bony", 1641 | "boring", 1642 | "bossy", 1643 | "both", 1644 | "bouncy", 1645 | "bountiful", 1646 | "bowed", 1647 | "brave", 1648 | "breakable", 1649 | "brief", 1650 | "bright", 1651 | "brilliant", 1652 | "brisk", 1653 | "broken", 1654 | "bronze", 1655 | "brown", 1656 | "bruised", 1657 | "bubbly", 1658 | "bulky", 1659 | "bumpy", 1660 | "buoyant", 1661 | "burdensome", 1662 | "burly", 1663 | "bustling", 1664 | "busy", 1665 | "buttery", 1666 | "buzzing", 1667 | "calculating", 1668 | "calm", 1669 | "candid", 1670 | "canine", 1671 | "capital", 1672 | "carefree", 1673 | "careful", 1674 | "careless", 1675 | "caring", 1676 | "cautious", 1677 | "cavernous", 1678 | "celebrated", 1679 | "charming", 1680 | "cheap", 1681 | "cheerful", 1682 | "cheery", 1683 | "chief", 1684 | "chilly", 1685 | "chubby", 1686 | "circular", 1687 | "classic", 1688 | "clean", 1689 | "clear", 1690 | "clear-cut", 1691 | "clever", 1692 | "close", 1693 | "closed", 1694 | "cloudy", 1695 | "clueless", 1696 | "clumsy", 1697 | "cluttered", 1698 | "coarse", 1699 | "cold", 1700 | "colorful", 1701 | "colorless", 1702 | "colossal", 1703 | "comfortable", 1704 | "common", 1705 | "compassionate", 1706 | "competent", 1707 | "complete", 1708 | "complex", 1709 | "complicated", 1710 | "composed", 1711 | "concerned", 1712 | "concrete", 1713 | "confused", 1714 | "conscious", 1715 | "considerate", 1716 | "constant", 1717 | "content", 1718 | "conventional", 1719 | "cooked", 1720 | "cool", 1721 | "cooperative", 1722 | "coordinated", 1723 | "corny", 1724 | "corrupt", 1725 | "costly", 1726 | "courageous", 1727 | "courteous", 1728 | "crafty", 1729 | "crazy", 1730 | "creamy", 1731 | "creative", 1732 | "creepy", 1733 | "criminal", 1734 | "crisp", 1735 | "critical", 1736 | "crooked", 1737 | "crowded", 1738 | "cruel", 1739 | "crushing", 1740 | "cuddly", 1741 | "cultivated", 1742 | "cultured", 1743 | "cumbersome", 1744 | "curly", 1745 | "curvy", 1746 | "cute", 1747 | "cylindrical", 1748 | "damaged", 1749 | "damp", 1750 | "dangerous", 1751 | "dapper", 1752 | "daring", 1753 | "dark", 1754 | "darling", 1755 | "dazzling", 1756 | "dead", 1757 | "deadly", 1758 | "deafening", 1759 | "dear", 1760 | "dearest", 1761 | "decent", 1762 | "decimal", 1763 | "decisive", 1764 | "deep", 1765 | "defenseless", 1766 | "defensive", 1767 | "defiant", 1768 | "deficient", 1769 | "definite", 1770 | "definitive", 1771 | "delayed", 1772 | "delectable", 1773 | "delicious", 1774 | "delightful", 1775 | "delirious", 1776 | "demanding", 1777 | "dense", 1778 | "dental", 1779 | "dependable", 1780 | "dependent", 1781 | "descriptive", 1782 | "deserted", 1783 | "detailed", 1784 | "determined", 1785 | "devoted", 1786 | "different", 1787 | "difficult", 1788 | "digital", 1789 | "diligent", 1790 | "dim", 1791 | "dimpled", 1792 | "dimwitted", 1793 | "direct", 1794 | "dirty", 1795 | "disastrous", 1796 | "discrete", 1797 | "disfigured", 1798 | "disguised", 1799 | "disgusting", 1800 | "dishonest", 1801 | "disloyal", 1802 | "dismal", 1803 | "dismal", 1804 | "distant", 1805 | "distant", 1806 | "distinct", 1807 | "distorted", 1808 | "dizzy", 1809 | "dopey", 1810 | "doting", 1811 | "double", 1812 | "downright", 1813 | "downright", 1814 | "drab", 1815 | "drafty", 1816 | "dramatic", 1817 | "dreary", 1818 | "dreary", 1819 | "droopy", 1820 | "dry", 1821 | "dual", 1822 | "dull", 1823 | "dutiful", 1824 | "each", 1825 | "eager", 1826 | "early", 1827 | "earnest", 1828 | "easy", 1829 | "easy-going", 1830 | "ecstatic", 1831 | "edible", 1832 | "educated", 1833 | "elaborate", 1834 | "elastic", 1835 | "elated", 1836 | "elderly", 1837 | "electric", 1838 | "elegant", 1839 | "elementary", 1840 | "elliptical", 1841 | "embarrassed", 1842 | "embellished", 1843 | "eminent", 1844 | "emotional", 1845 | "empty", 1846 | "enchanted", 1847 | "enchanting", 1848 | "energetic", 1849 | "enlightened", 1850 | "enormous", 1851 | "enraged", 1852 | "entire", 1853 | "envious", 1854 | "equal", 1855 | "equatorial", 1856 | "essential", 1857 | "esteemed", 1858 | "ethical", 1859 | "euphoric", 1860 | "even", 1861 | "evergreen", 1862 | "everlasting", 1863 | "every", 1864 | "evil", 1865 | "exalted", 1866 | "excellent", 1867 | "excitable", 1868 | "excited", 1869 | "exciting", 1870 | "exemplary", 1871 | "exhausted", 1872 | "exotic", 1873 | "expensive", 1874 | "experienced", 1875 | "expert", 1876 | "extra-large", 1877 | "extra-small", 1878 | "extraneous", 1879 | "extroverted", 1880 | "fabulous", 1881 | "failing", 1882 | "faint", 1883 | "fair", 1884 | "faithful", 1885 | "fake", 1886 | "false", 1887 | "familiar", 1888 | "famous", 1889 | "fancy", 1890 | "fantastic", 1891 | "far", 1892 | "far-flung", 1893 | "far-off", 1894 | "faraway", 1895 | "fast", 1896 | "fat", 1897 | "fatal", 1898 | "fatherly", 1899 | "favorable", 1900 | "favorite", 1901 | "fearful", 1902 | "fearless", 1903 | "feisty", 1904 | "feline", 1905 | "female", 1906 | "feminine", 1907 | "few", 1908 | "fickle", 1909 | "filthy", 1910 | "fine", 1911 | "finished", 1912 | "firm", 1913 | "first", 1914 | "firsthand", 1915 | "fitting", 1916 | "fixed", 1917 | "flaky", 1918 | "flamboyant", 1919 | "flashy", 1920 | "flat", 1921 | "flawed", 1922 | "flawless", 1923 | "flickering", 1924 | "flimsy", 1925 | "flippant", 1926 | "flowery", 1927 | "fluffy", 1928 | "fluid", 1929 | "flustered", 1930 | "focused", 1931 | "fond", 1932 | "foolhardy", 1933 | "foolish", 1934 | "forceful", 1935 | "forked", 1936 | "formal", 1937 | "forsaken", 1938 | "forthright", 1939 | "fortunate", 1940 | "fragrant", 1941 | "frail", 1942 | "frank", 1943 | "frayed", 1944 | "free", 1945 | "French", 1946 | "frequent", 1947 | "fresh", 1948 | "friendly", 1949 | "frightened", 1950 | "frightening", 1951 | "frigid", 1952 | "frilly", 1953 | "frivolous", 1954 | "frizzy", 1955 | "front", 1956 | "frosty", 1957 | "frozen", 1958 | "frugal", 1959 | "fruitful", 1960 | "full", 1961 | "fumbling", 1962 | "functional", 1963 | "funny", 1964 | "fussy", 1965 | "fuzzy", 1966 | "gargantuan", 1967 | "gaseous", 1968 | "general", 1969 | "generous", 1970 | "gentle", 1971 | "genuine", 1972 | "giant", 1973 | "giddy", 1974 | "gifted", 1975 | "gigantic", 1976 | "giving", 1977 | "glamorous", 1978 | "glaring", 1979 | "glass", 1980 | "gleaming", 1981 | "gleeful", 1982 | "glistening", 1983 | "glittering", 1984 | "gloomy", 1985 | "glorious", 1986 | "glossy", 1987 | "glum", 1988 | "golden", 1989 | "good", 1990 | "good-natured", 1991 | "gorgeous", 1992 | "graceful", 1993 | "gracious", 1994 | "grand", 1995 | "grandiose", 1996 | "granular", 1997 | "grateful", 1998 | "grave", 1999 | "gray", 2000 | "great", 2001 | "greedy", 2002 | "green", 2003 | "gregarious", 2004 | "grim", 2005 | "grimy", 2006 | "gripping", 2007 | "grizzled", 2008 | "gross", 2009 | "grotesque", 2010 | "grouchy", 2011 | "grounded", 2012 | "growing", 2013 | "growling", 2014 | "grown", 2015 | "grubby", 2016 | "gruesome", 2017 | "grumpy", 2018 | "guilty", 2019 | "gullible", 2020 | "gummy", 2021 | "hairy", 2022 | "half", 2023 | "handmade", 2024 | "handsome", 2025 | "handy", 2026 | "happy", 2027 | "happy-go-lucky", 2028 | "hard", 2029 | "hard-to-find", 2030 | "harmful", 2031 | "harmless", 2032 | "harmonious", 2033 | "harsh", 2034 | "hasty", 2035 | "hateful", 2036 | "haunting", 2037 | "healthy", 2038 | "heartfelt", 2039 | "hearty", 2040 | "heavenly", 2041 | "heavy", 2042 | "hefty", 2043 | "helpful", 2044 | "helpless", 2045 | "hidden", 2046 | "hideous", 2047 | "high", 2048 | "high-level", 2049 | "hilarious", 2050 | "hoarse", 2051 | "hollow", 2052 | "homely", 2053 | "honest", 2054 | "honorable", 2055 | "honored", 2056 | "hopeful", 2057 | "horrible", 2058 | "hospitable", 2059 | "hot", 2060 | "huge", 2061 | "humble", 2062 | "humiliating", 2063 | "humming", 2064 | "humongous", 2065 | "hungry", 2066 | "hurtful", 2067 | "husky", 2068 | "icky", 2069 | "icy", 2070 | "ideal", 2071 | "idealistic", 2072 | "identical", 2073 | "idiotic", 2074 | "idle", 2075 | "idolized", 2076 | "ignorant", 2077 | "ill", 2078 | "ill-fated", 2079 | "ill-informed", 2080 | "illegal", 2081 | "illiterate", 2082 | "illustrious", 2083 | "imaginary", 2084 | "imaginative", 2085 | "immaculate", 2086 | "immaterial", 2087 | "immediate", 2088 | "immense", 2089 | "impartial", 2090 | "impassioned", 2091 | "impeccable", 2092 | "imperfect", 2093 | "imperturbable", 2094 | "impish", 2095 | "impolite", 2096 | "important", 2097 | "impossible", 2098 | "impractical", 2099 | "impressionable", 2100 | "impressive", 2101 | "improbable", 2102 | "impure", 2103 | "inborn", 2104 | "incomparable", 2105 | "incompatible", 2106 | "incomplete", 2107 | "inconsequential", 2108 | "incredible", 2109 | "indelible", 2110 | "indolent", 2111 | "inexperienced", 2112 | "infamous", 2113 | "infantile", 2114 | "infatuated", 2115 | "inferior", 2116 | "infinite", 2117 | "informal", 2118 | "innocent", 2119 | "insecure", 2120 | "insidious", 2121 | "insignificant", 2122 | "insistent", 2123 | "instructive", 2124 | "insubstantial", 2125 | "intelligent", 2126 | "intent", 2127 | "intentional", 2128 | "interesting", 2129 | "internal", 2130 | "international", 2131 | "intrepid", 2132 | "ironclad", 2133 | "irresponsible", 2134 | "irritating", 2135 | "itchy", 2136 | "jaded", 2137 | "jagged", 2138 | "jam-packed", 2139 | "jaunty", 2140 | "jealous", 2141 | "jittery", 2142 | "joint", 2143 | "jolly", 2144 | "jovial", 2145 | "joyful", 2146 | "joyous", 2147 | "jubilant", 2148 | "judicious", 2149 | "juicy", 2150 | "jumbo", 2151 | "jumpy", 2152 | "junior", 2153 | "juvenile", 2154 | "kaleidoscopic", 2155 | "keen", 2156 | "key", 2157 | "kind", 2158 | "kindhearted", 2159 | "kindly", 2160 | "klutzy", 2161 | "knobby", 2162 | "knotty", 2163 | "knowing", 2164 | "knowledgeable", 2165 | "known", 2166 | "kooky", 2167 | "kosher", 2168 | "lame", 2169 | "lanky", 2170 | "large", 2171 | "last", 2172 | "lasting", 2173 | "late", 2174 | "lavish", 2175 | "lawful", 2176 | "lazy", 2177 | "leading", 2178 | "leafy", 2179 | "lean", 2180 | "left", 2181 | "legal", 2182 | "legitimate", 2183 | "light", 2184 | "lighthearted", 2185 | "likable", 2186 | "likely", 2187 | "limited", 2188 | "limp", 2189 | "limping", 2190 | "linear", 2191 | "lined", 2192 | "liquid", 2193 | "little", 2194 | "live", 2195 | "lively", 2196 | "livid", 2197 | "loathsome", 2198 | "lone", 2199 | "lonely", 2200 | "long", 2201 | "long-term", 2202 | "loose", 2203 | "lopsided", 2204 | "lost", 2205 | "loud", 2206 | "lovable", 2207 | "lovely", 2208 | "loving", 2209 | "low", 2210 | "loyal", 2211 | "lucky", 2212 | "lumbering", 2213 | "luminous", 2214 | "lumpy", 2215 | "lustrous", 2216 | "luxurious", 2217 | "mad", 2218 | "made-up", 2219 | "magnificent", 2220 | "majestic", 2221 | "major", 2222 | "male", 2223 | "mammoth", 2224 | "married", 2225 | "marvelous", 2226 | "masculine", 2227 | "massive", 2228 | "mature", 2229 | "meager", 2230 | "mealy", 2231 | "mean", 2232 | "measly", 2233 | "meaty", 2234 | "medical", 2235 | "mediocre", 2236 | "medium", 2237 | "meek", 2238 | "mellow", 2239 | "melodic", 2240 | "memorable", 2241 | "menacing", 2242 | "merry", 2243 | "messy", 2244 | "metallic", 2245 | "mild", 2246 | "milky", 2247 | "mindless", 2248 | "miniature", 2249 | "minor", 2250 | "minty", 2251 | "miserable", 2252 | "miserly", 2253 | "misguided", 2254 | "misty", 2255 | "mixed", 2256 | "modern", 2257 | "modest", 2258 | "moist", 2259 | "monstrous", 2260 | "monthly", 2261 | "monumental", 2262 | "moral", 2263 | "mortified", 2264 | "motherly", 2265 | "motionless", 2266 | "mountainous", 2267 | "muddy", 2268 | "muffled", 2269 | "multicolored", 2270 | "mundane", 2271 | "murky", 2272 | "mushy", 2273 | "musty", 2274 | "muted", 2275 | "mysterious", 2276 | "naive", 2277 | "narrow", 2278 | "nasty", 2279 | "natural", 2280 | "naughty", 2281 | "nautical", 2282 | "near", 2283 | "neat", 2284 | "necessary", 2285 | "needy", 2286 | "negative", 2287 | "neglected", 2288 | "negligible", 2289 | "neighboring", 2290 | "nervous", 2291 | "new", 2292 | "next", 2293 | "nice", 2294 | "nifty", 2295 | "nimble", 2296 | "nippy", 2297 | "nocturnal", 2298 | "noisy", 2299 | "nonstop", 2300 | "normal", 2301 | "notable", 2302 | "noted", 2303 | "noteworthy", 2304 | "novel", 2305 | "noxious", 2306 | "numb", 2307 | "nutritious", 2308 | "nutty", 2309 | "obedient", 2310 | "obese", 2311 | "oblong", 2312 | "oblong", 2313 | "obvious", 2314 | "occasional", 2315 | "odd", 2316 | "oddball", 2317 | "offbeat", 2318 | "offensive", 2319 | "official", 2320 | "oily", 2321 | "old", 2322 | "old-fashioned", 2323 | "only", 2324 | "open", 2325 | "optimal", 2326 | "optimistic", 2327 | "opulent", 2328 | "orange", 2329 | "orderly", 2330 | "ordinary", 2331 | "organic", 2332 | "original", 2333 | "ornate", 2334 | "ornery", 2335 | "other", 2336 | "our", 2337 | "outgoing", 2338 | "outlandish", 2339 | "outlying", 2340 | "outrageous", 2341 | "outstanding", 2342 | "oval", 2343 | "overcooked", 2344 | "overdue", 2345 | "overjoyed", 2346 | "overlooked", 2347 | "palatable", 2348 | "pale", 2349 | "paltry", 2350 | "parallel", 2351 | "parched", 2352 | "partial", 2353 | "passionate", 2354 | "past", 2355 | "pastel", 2356 | "peaceful", 2357 | "peppery", 2358 | "perfect", 2359 | "perfumed", 2360 | "periodic", 2361 | "perky", 2362 | "personal", 2363 | "pertinent", 2364 | "pesky", 2365 | "pessimistic", 2366 | "petty", 2367 | "phony", 2368 | "physical", 2369 | "piercing", 2370 | "pink", 2371 | "pitiful", 2372 | "plain", 2373 | "plaintive", 2374 | "plastic", 2375 | "playful", 2376 | "pleasant", 2377 | "pleased", 2378 | "pleasing", 2379 | "plump", 2380 | "plush", 2381 | "pointed", 2382 | "pointless", 2383 | "poised", 2384 | "polished", 2385 | "polite", 2386 | "political", 2387 | "poor", 2388 | "popular", 2389 | "portly", 2390 | "posh", 2391 | "positive", 2392 | "possible", 2393 | "potable", 2394 | "powerful", 2395 | "powerless", 2396 | "practical", 2397 | "precious", 2398 | "precious", 2399 | "present", 2400 | "prestigious", 2401 | "pretty", 2402 | "previous", 2403 | "pricey", 2404 | "prickly", 2405 | "primary", 2406 | "prime", 2407 | "pristine", 2408 | "private", 2409 | "prize", 2410 | "probable", 2411 | "productive", 2412 | "profitable", 2413 | "profuse", 2414 | "proper", 2415 | "proud", 2416 | "prudent", 2417 | "punctual", 2418 | "pungent", 2419 | "puny", 2420 | "pure", 2421 | "purple", 2422 | "pushy", 2423 | "putrid", 2424 | "puzzled", 2425 | "puzzling", 2426 | "quaint", 2427 | "qualified", 2428 | "quarrelsome", 2429 | "quarterly", 2430 | "queasy", 2431 | "querulous", 2432 | "questionable", 2433 | "quick", 2434 | "quick-witted", 2435 | "quiet", 2436 | "quintessential", 2437 | "quirky", 2438 | "quixotic", 2439 | "quizzical", 2440 | "radiant", 2441 | "ragged", 2442 | "rapid", 2443 | "rare", 2444 | "rash", 2445 | "raw", 2446 | "ready", 2447 | "real", 2448 | "realistic", 2449 | "reasonable", 2450 | "recent", 2451 | "reckless", 2452 | "rectangular", 2453 | "red", 2454 | "reflecting", 2455 | "regal", 2456 | "regular", 2457 | "reliable", 2458 | "relieved", 2459 | "remarkable", 2460 | "remorseful", 2461 | "remote", 2462 | "repentant", 2463 | "repulsive", 2464 | "required", 2465 | "respectful", 2466 | "responsible", 2467 | "revolving", 2468 | "rewarding", 2469 | "rich", 2470 | "right", 2471 | "rigid", 2472 | "ringed", 2473 | "ripe", 2474 | "roasted", 2475 | "robust", 2476 | "rosy", 2477 | "rotating", 2478 | "rotten", 2479 | "rough", 2480 | "round", 2481 | "rowdy", 2482 | "royal", 2483 | "rubbery", 2484 | "ruddy", 2485 | "rude", 2486 | "rundown", 2487 | "runny", 2488 | "rural", 2489 | "rusty", 2490 | "sad", 2491 | "safe", 2492 | "salty", 2493 | "same", 2494 | "sandy", 2495 | "sane", 2496 | "sarcastic", 2497 | "sardonic", 2498 | "satisfied", 2499 | "scaly", 2500 | "scarce", 2501 | "scared", 2502 | "scary", 2503 | "scented", 2504 | "scholarly", 2505 | "scientific", 2506 | "scornful", 2507 | "scratchy", 2508 | "scrawny", 2509 | "second", 2510 | "second-hand", 2511 | "secondary", 2512 | "secret", 2513 | "self-assured", 2514 | "self-reliant", 2515 | "selfish", 2516 | "sentimental", 2517 | "separate", 2518 | "serene", 2519 | "serious", 2520 | "serpentine", 2521 | "several", 2522 | "severe", 2523 | "shabby", 2524 | "shadowy", 2525 | "shady", 2526 | "shallow", 2527 | "shameful", 2528 | "shameless", 2529 | "sharp", 2530 | "shimmering", 2531 | "shiny", 2532 | "shocked", 2533 | "shocking", 2534 | "shoddy", 2535 | "short", 2536 | "short-term", 2537 | "showy", 2538 | "shrill", 2539 | "shy", 2540 | "sick", 2541 | "silent", 2542 | "silky", 2543 | "silly", 2544 | "silver", 2545 | "similar", 2546 | "simple", 2547 | "simplistic", 2548 | "sinful", 2549 | "single", 2550 | "sizzling", 2551 | "skeletal", 2552 | "skinny", 2553 | "sleepy", 2554 | "slight", 2555 | "slim", 2556 | "slimy", 2557 | "slippery", 2558 | "slow", 2559 | "slushy", 2560 | "small", 2561 | "smart", 2562 | "smoggy", 2563 | "smooth", 2564 | "smug", 2565 | "snappy", 2566 | "snarling", 2567 | "sneaky", 2568 | "sniveling", 2569 | "snoopy", 2570 | "sociable", 2571 | "soft", 2572 | "soggy", 2573 | "solid", 2574 | "somber", 2575 | "some", 2576 | "sophisticated", 2577 | "sore", 2578 | "sorrowful", 2579 | "soulful", 2580 | "soupy", 2581 | "sour", 2582 | "Spanish", 2583 | "sparkling", 2584 | "sparse", 2585 | "specific", 2586 | "spectacular", 2587 | "speedy", 2588 | "spherical", 2589 | "spicy", 2590 | "spiffy", 2591 | "spirited", 2592 | "spiteful", 2593 | "splendid", 2594 | "spotless", 2595 | "spotted", 2596 | "spry", 2597 | "square", 2598 | "squeaky", 2599 | "squiggly", 2600 | "stable", 2601 | "staid", 2602 | "stained", 2603 | "stale", 2604 | "standard", 2605 | "starchy", 2606 | "stark", 2607 | "starry", 2608 | "steel", 2609 | "steep", 2610 | "sticky", 2611 | "stiff", 2612 | "stimulating", 2613 | "stingy", 2614 | "stormy", 2615 | "straight", 2616 | "strange", 2617 | "strict", 2618 | "strident", 2619 | "striking", 2620 | "striped", 2621 | "strong", 2622 | "studious", 2623 | "stunning", 2624 | "stupendous", 2625 | "stupid", 2626 | "sturdy", 2627 | "stylish", 2628 | "subdued", 2629 | "submissive", 2630 | "substantial", 2631 | "subtle", 2632 | "suburban", 2633 | "sudden", 2634 | "sugary", 2635 | "sunny", 2636 | "super", 2637 | "superb", 2638 | "superficial", 2639 | "superior", 2640 | "supportive", 2641 | "sure-footed", 2642 | "surprised", 2643 | "suspicious", 2644 | "svelte", 2645 | "sweaty", 2646 | "sweet", 2647 | "sweltering", 2648 | "swift", 2649 | "sympathetic", 2650 | "talkative", 2651 | "tall", 2652 | "tame", 2653 | "tan", 2654 | "tangible", 2655 | "tart", 2656 | "tasty", 2657 | "tattered", 2658 | "taut", 2659 | "tedious", 2660 | "teeming", 2661 | "tempting", 2662 | "tender", 2663 | "tense", 2664 | "tepid", 2665 | "terrible", 2666 | "terrific", 2667 | "testy", 2668 | "thankful", 2669 | "that", 2670 | "these", 2671 | "thick", 2672 | "thin", 2673 | "third", 2674 | "thirsty", 2675 | "this", 2676 | "thorny", 2677 | "thorough", 2678 | "those", 2679 | "thoughtful", 2680 | "threadbare", 2681 | "thrifty", 2682 | "thunderous", 2683 | "tidy", 2684 | "tight", 2685 | "timely", 2686 | "tinted", 2687 | "tiny", 2688 | "tired", 2689 | "torn", 2690 | "total", 2691 | "tough", 2692 | "tragic", 2693 | "trained", 2694 | "traumatic", 2695 | "treasured", 2696 | "tremendous", 2697 | "tremendous", 2698 | "triangular", 2699 | "tricky", 2700 | "trifling", 2701 | "trim", 2702 | "trivial", 2703 | "troubled", 2704 | "true", 2705 | "trusting", 2706 | "trustworthy", 2707 | "trusty", 2708 | "truthful", 2709 | "tubby", 2710 | "turbulent", 2711 | "twin", 2712 | "ugly", 2713 | "ultimate", 2714 | "unacceptable", 2715 | "unaware", 2716 | "uncomfortable", 2717 | "uncommon", 2718 | "unconscious", 2719 | "understated", 2720 | "unequaled", 2721 | "uneven", 2722 | "unfinished", 2723 | "unfit", 2724 | "unfolded", 2725 | "unfortunate", 2726 | "unhappy", 2727 | "unhealthy", 2728 | "uniform", 2729 | "unimportant", 2730 | "unique", 2731 | "united", 2732 | "unkempt", 2733 | "unknown", 2734 | "unlawful", 2735 | "unlined", 2736 | "unlucky", 2737 | "unnatural", 2738 | "unpleasant", 2739 | "unrealistic", 2740 | "unripe", 2741 | "unruly", 2742 | "unselfish", 2743 | "unsightly", 2744 | "unsteady", 2745 | "unsung", 2746 | "untidy", 2747 | "untimely", 2748 | "untried", 2749 | "untrue", 2750 | "unused", 2751 | "unusual", 2752 | "unwelcome", 2753 | "unwieldy", 2754 | "unwilling", 2755 | "unwitting", 2756 | "unwritten", 2757 | "upbeat", 2758 | "upright", 2759 | "upset", 2760 | "urban", 2761 | "usable", 2762 | "used", 2763 | "useful", 2764 | "useless", 2765 | "utilized", 2766 | "utter", 2767 | "vacant", 2768 | "vague", 2769 | "vain", 2770 | "valid", 2771 | "valuable", 2772 | "vapid", 2773 | "variable", 2774 | "vast", 2775 | "velvety", 2776 | "venerated", 2777 | "vengeful", 2778 | "verifiable", 2779 | "vibrant", 2780 | "vicious", 2781 | "victorious", 2782 | "vigilant", 2783 | "vigorous", 2784 | "villainous", 2785 | "violent", 2786 | "violet", 2787 | "virtual", 2788 | "virtuous", 2789 | "visible", 2790 | "vital", 2791 | "vivacious", 2792 | "vivid", 2793 | "voluminous", 2794 | "wan", 2795 | "warlike", 2796 | "warm", 2797 | "warmhearted", 2798 | "warped", 2799 | "wary", 2800 | "wasteful", 2801 | "watchful", 2802 | "waterlogged", 2803 | "watery", 2804 | "wavy", 2805 | "weak", 2806 | "wealthy", 2807 | "weary", 2808 | "webbed", 2809 | "wee", 2810 | "weekly", 2811 | "weepy", 2812 | "weighty", 2813 | "weird", 2814 | "welcome", 2815 | "well-documented", 2816 | "well-groomed", 2817 | "well-informed", 2818 | "well-lit", 2819 | "well-made", 2820 | "well-off", 2821 | "well-to-do", 2822 | "well-worn", 2823 | "wet", 2824 | "which", 2825 | "whimsical", 2826 | "whirlwind", 2827 | "whispered", 2828 | "white", 2829 | "whole", 2830 | "whopping", 2831 | "wicked", 2832 | "wide", 2833 | "wide-eyed", 2834 | "wiggly", 2835 | "wild", 2836 | "willing", 2837 | "wilted", 2838 | "winding", 2839 | "windy", 2840 | "winged", 2841 | "wiry", 2842 | "wise", 2843 | "witty", 2844 | "wobbly", 2845 | "woeful", 2846 | "wonderful", 2847 | "wooden", 2848 | "woozy", 2849 | "wordy", 2850 | "worldly", 2851 | "worn", 2852 | "worried", 2853 | "worrisome", 2854 | "worse", 2855 | "worst", 2856 | "worthless", 2857 | "worthwhile", 2858 | "worthy", 2859 | "wrathful", 2860 | "wretched", 2861 | "writhing", 2862 | "wrong", 2863 | "wry", 2864 | "yawning", 2865 | "yearly", 2866 | "yellow", 2867 | "yellowish", 2868 | "young", 2869 | "youthful", 2870 | "yummy", 2871 | "zany", 2872 | "zealous", 2873 | "zesty", 2874 | "zigzag", 2875 | ] 2876 | -------------------------------------------------------------------------------- /tftui/debug_log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def setup_logging(log_level=None): 5 | logger = logging.getLogger(__name__) 6 | if log_level is not None: 7 | numeric_level = getattr(logging, log_level.upper(), None) 8 | logger.setLevel(numeric_level) 9 | if not logger.handlers: 10 | formatter = logging.Formatter( 11 | "%(asctime)s [%(module)s:%(funcName)s] %(message)s", 12 | datefmt="%Y-%m-%d %H:%M:%S", 13 | ) 14 | file_handler = logging.FileHandler("tftui.log") 15 | file_handler.setFormatter(formatter) 16 | logger.addHandler(file_handler) 17 | 18 | return logger 19 | -------------------------------------------------------------------------------- /tftui/modal.py: -------------------------------------------------------------------------------- 1 | from rich.text import Text 2 | from textual.app import ComposeResult 3 | from textual.containers import Grid 4 | from textual.screen import ModalScreen 5 | from textual.widgets import ( 6 | Button, 7 | RichLog, 8 | Input, 9 | Checkbox, 10 | Static, 11 | DataTable, 12 | OptionList, 13 | ) 14 | from textual.containers import Horizontal, Vertical 15 | 16 | 17 | class WorkspaceModal(ModalScreen): 18 | workspaces = [] 19 | current = "" 20 | options = None 21 | 22 | def __init__(self, workspaces: list, current: str, *args, **kwargs): 23 | self.current = current 24 | self.workspaces = workspaces 25 | super().__init__(*args, **kwargs) 26 | 27 | def compose(self) -> ComposeResult: 28 | question = Static( 29 | Text("Select workspace to switch to:\n", "bold"), 30 | id="question", 31 | ) 32 | self.options = OptionList(*self.workspaces) 33 | self.options.highlighted = self.workspaces.index(self.current) 34 | yield Vertical( 35 | question, 36 | self.options, 37 | Button("OK", id="ok"), 38 | id="workspaces", 39 | ) 40 | 41 | def on_key(self, event) -> None: 42 | if event.key == "enter": 43 | self.dismiss(self.workspaces[self.options.highlighted]) 44 | elif event.key == "escape": 45 | self.dismiss(None) 46 | 47 | 48 | class FullTextModal(ModalScreen): 49 | contents = None 50 | is_resource = False 51 | 52 | def __init__(self, contents: str, is_resource: bool, *args, **kwargs): 53 | self.contents = contents 54 | self.is_resource = is_resource 55 | super().__init__(*args, **kwargs) 56 | 57 | def compose(self) -> ComposeResult: 58 | fullscreen = RichLog(auto_scroll=False) 59 | if self.is_resource: 60 | fullscreen.highlight = True 61 | fullscreen.markup = True 62 | fullscreen.wrap = True 63 | 64 | fullscreen.write(self.contents) 65 | yield fullscreen 66 | 67 | def on_key(self, event) -> None: 68 | if event.key in ("f", "escape"): 69 | self.app.pop_screen() 70 | 71 | 72 | class YesNoModal(ModalScreen): 73 | contents = None 74 | 75 | def __init__(self, contents: str, *args, **kwargs): 76 | self.contents = contents 77 | super().__init__(*args, **kwargs) 78 | 79 | def compose(self) -> ComposeResult: 80 | question = RichLog(id="question", auto_scroll=False, wrap=True) 81 | question.write(self.contents) 82 | yield Grid( 83 | question, 84 | Button("Yes", variant="primary", id="yes"), 85 | Button("No", id="no"), 86 | id="yesno", 87 | ) 88 | 89 | def on_button_pressed(self, event: Button.Pressed) -> None: 90 | if event.button.id == "yes": 91 | self.dismiss(True) 92 | else: 93 | self.dismiss(False) 94 | 95 | def on_key(self, event) -> None: 96 | if event.key == "y": 97 | self.dismiss(True) 98 | elif event.key == "n" or event.key == "escape": 99 | self.dismiss(False) 100 | 101 | 102 | class PlanInputsModal(ModalScreen): 103 | input = None 104 | checkbox = None 105 | var_file = None 106 | 107 | def __init__(self, var_file, targets=False, *args, **kwargs): 108 | super().__init__(*args, **kwargs) 109 | self.var_file = var_file 110 | self.input = Input(id="varfile", placeholder="Optional") 111 | self.checkbox = Checkbox( 112 | "Target only selected resources", 113 | id="plantarget", 114 | value=targets, 115 | ) 116 | 117 | def compose(self) -> ComposeResult: 118 | question = Static( 119 | Text("Would you like to create a terraform plan?", "bold"), id="question" 120 | ) 121 | if self.var_file: 122 | self.input.value = self.var_file 123 | yield Grid( 124 | question, 125 | Horizontal(Static("Var-file:", id="varfilelabel"), self.input), 126 | self.checkbox, 127 | Button("Yes", variant="primary", id="yes"), 128 | Button("No", id="no"), 129 | id="tfvars", 130 | ) 131 | self.input.focus() 132 | 133 | def on_button_pressed(self, event: Button.Pressed) -> None: 134 | if event.button.id == "yes": 135 | self.dismiss((self.input.value, self.checkbox.value)) 136 | else: 137 | self.dismiss(None) 138 | 139 | def on_key(self, event) -> None: 140 | if event.key == "y": 141 | self.dismiss((self.input.value, self.checkbox.value)) 142 | elif event.key == "n" or event.key == "escape": 143 | self.dismiss(None) 144 | 145 | def on_input_submitted(self, event: Input.Submitted) -> None: 146 | self.dismiss((self.input.value, self.checkbox.value)) 147 | 148 | 149 | class HelpModal(ModalScreen): 150 | help_message = ( 151 | ("ENTER", "View resource details"), 152 | ("ESC", "Go back"), 153 | ("S / Space", "Select current resource (toggle)"), 154 | ("F", "Show resource/plan on full screen; Hold SHIFT/OPTIONS to copy text"), 155 | ("X", "Expose sensitive values in resource screen"), 156 | ("D", "Delete selected resources, or highlighted resource if none is selected"), 157 | ("T", "Taint selected resources, or highlighted resource if none is selected"), 158 | ( 159 | "U", 160 | "Untaint selected resources, or highlighted resource if none is selected", 161 | ), 162 | ("C", "Copy selected resource's name or description to clipboard"), 163 | ("R", "Refresh state tree"), 164 | ( 165 | "P", 166 | "Create execution plan, with an optional var-file and target list", 167 | ), 168 | ( 169 | "Ctrl+D", 170 | "Create destruction plan, with an optional var-file and target list", 171 | ), 172 | ("A", "Apply current plan, available only if a valid plan was created"), 173 | ("/", "Filter tree based on text inside resources names and descriptions"), 174 | ("0-9", "Collapse the state tree to the selected level, 0 expands all nodes"), 175 | ("W", "Switch workspace"), 176 | ("M", "Toggle dark mode"), 177 | ("Q", "Quit"), 178 | ) 179 | 180 | def compose(self) -> ComposeResult: 181 | button = Button("OK") 182 | table = DataTable( 183 | show_cursor=False, 184 | zebra_stripes=True, 185 | ) 186 | table.add_columns( 187 | Text("Key", "bold", justify="center"), 188 | Text("Action", "bold", justify="center"), 189 | ) 190 | for row in self.help_message: 191 | styled_row = [Text(str(row[0]), justify="center"), Text(row[1])] 192 | table.add_row(*styled_row) 193 | yield Grid(table, button, id="help") 194 | button.focus() 195 | 196 | def on_button_pressed(self, event: Button.Pressed) -> None: 197 | self.dismiss(None) 198 | 199 | def on_key(self, event) -> None: 200 | if event.key == "escape" or event.key == "enter": 201 | self.dismiss(None) 202 | -------------------------------------------------------------------------------- /tftui/plan.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from tftui.debug_log import setup_logging 3 | from textual import work 4 | from textual.widgets import RichLog 5 | from textual.worker import Worker 6 | from rich.text import Text 7 | 8 | logger = setup_logging() 9 | 10 | 11 | class PlanScreen(RichLog): 12 | executable = None 13 | active_plan = None 14 | fulltext = None 15 | 16 | BINDINGS = [] 17 | 18 | def __init__(self, id, executable, *args, **kwargs): 19 | super().__init__(id=id, *args, **kwargs) 20 | self.executable = executable 21 | self.active_plan = "" 22 | self.wrap = True 23 | 24 | @work(exclusive=True) 25 | async def create_plan(self, varfile, targets, destroy="") -> None: 26 | self.active_plan = Text("") 27 | self.auto_scroll = False 28 | self.parent.loading = True 29 | self.fulltext = None 30 | self.app.switcher.border_title = "" 31 | self.clear() 32 | command = [ 33 | self.executable, 34 | "plan", 35 | "-no-color", 36 | "-input=false", 37 | "-out=tftui.plan", 38 | "-detailed-exitcode", 39 | ] 40 | if varfile: 41 | command.append(f"-var-file={varfile}") 42 | if destroy: 43 | command.append("-destroy") 44 | if targets: 45 | for target in targets: 46 | command.append(f"-target={target}") 47 | 48 | logger.debug(f"Executing command: {command}") 49 | proc = await asyncio.create_subprocess_exec( 50 | *command, 51 | stdout=asyncio.subprocess.PIPE, 52 | stderr=asyncio.subprocess.STDOUT, 53 | ) 54 | 55 | block_color = "" 56 | 57 | try: 58 | while True: 59 | data = await proc.stdout.readline() 60 | if not data: 61 | break 62 | 63 | self.parent.loading = False 64 | stripped_line = data.decode("utf-8").rstrip() 65 | stylzed_line = Text(stripped_line) 66 | 67 | if ( 68 | stripped_line.startswith("No changes.") 69 | or stripped_line == "Terraform will perform the following actions:" 70 | ): 71 | self.clear() 72 | self.auto_scroll = False 73 | self.fulltext = Text("") 74 | 75 | if stripped_line == "": 76 | block_color = "" 77 | elif stripped_line.startswith("Plan:"): 78 | stylzed_line.stylize("bold") 79 | self.active_plan = self.active_plan.assemble( 80 | stylzed_line, 81 | Text("\n\n"), 82 | self.active_plan, 83 | ) 84 | elif stripped_line.startswith(" #"): 85 | if stripped_line.endswith( 86 | "will be destroyed" 87 | ) or stripped_line.endswith("must be replaced"): 88 | block_color = "red" 89 | elif stripped_line.endswith("will be created"): 90 | block_color = "green3" 91 | elif stripped_line.endswith("will be updated in-place"): 92 | block_color = "yellow3" 93 | stylzed_line.stylize(f"bold {block_color}") 94 | self.active_plan = self.active_plan.assemble( 95 | self.active_plan, 96 | stylzed_line, 97 | Text("\n"), 98 | ) 99 | elif stripped_line.strip().startswith("-"): 100 | stylzed_line.stylize("red") 101 | elif stripped_line.strip().startswith("+"): 102 | stylzed_line.stylize("green3") 103 | elif stripped_line.strip().startswith("~") and "->" in stripped_line: 104 | stylzed_line = Text.assemble( 105 | (stripped_line[: stripped_line.find("=") + 1], block_color), 106 | ( 107 | stripped_line[ 108 | stripped_line.find("=") + 1 : stripped_line.find("->") 109 | ], 110 | "red", 111 | ), 112 | (stripped_line[stripped_line.find("->") :], "green3"), 113 | ) 114 | else: 115 | stylzed_line.stylize(block_color) 116 | 117 | self.write(stylzed_line) 118 | 119 | if self.fulltext is not None: 120 | self.fulltext += stylzed_line + Text("\n") 121 | 122 | finally: 123 | await proc.wait() 124 | if proc.returncode != 2: 125 | self.active_plan = None 126 | 127 | if self.active_plan: 128 | self.app.switcher.border_title = self.active_plan.plain.split("\n")[0] 129 | 130 | self.focus() 131 | 132 | @work(exclusive=True) 133 | async def execute_apply(self) -> None: 134 | self.parent.loading = True 135 | self.auto_scroll = True 136 | self.fulltext = Text("") 137 | command = [self.executable, "apply", "-no-color", "tftui.plan"] 138 | 139 | logger.debug(f"Executing command: {command}") 140 | proc = await asyncio.create_subprocess_exec( 141 | *command, 142 | stdout=asyncio.subprocess.PIPE, 143 | stderr=asyncio.subprocess.STDOUT, 144 | ) 145 | 146 | self.clear() 147 | 148 | try: 149 | while True: 150 | data = await proc.stdout.readline() 151 | if not data: 152 | break 153 | self.parent.loading = False 154 | text = Text(data.decode("utf-8").rstrip()) 155 | if text.plain.startswith("Apply complete!"): 156 | text.stylize("bold white") 157 | self.write(text) 158 | self.fulltext += text + Text("\n") 159 | finally: 160 | await proc.wait() 161 | self.active_plan = "" 162 | 163 | def on_hide(self) -> None: 164 | self.active_plan = "" 165 | self.clear() 166 | 167 | async def on_worker_state_changed(self, event: Worker.StateChanged) -> None: 168 | if ( 169 | event.worker.name == "execute_apply" 170 | and event.worker.state.name == "SUCCESS" 171 | ): 172 | self.app.tree.refresh_state(focus=False) 173 | -------------------------------------------------------------------------------- /tftui/state.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | import logging 4 | import json 5 | from collections import Counter 6 | from tftui.debug_log import setup_logging 7 | 8 | logger = setup_logging() 9 | 10 | 11 | async def execute_async(*command: str) -> tuple[str, str]: 12 | command = [word for phrase in command for word in phrase.split()] 13 | 14 | proc = await asyncio.create_subprocess_exec( 15 | *command, 16 | stdout=asyncio.subprocess.PIPE, 17 | stderr=asyncio.subprocess.STDOUT, 18 | ) 19 | 20 | stdout, strerr = await proc.communicate() 21 | response = stdout.decode("utf-8") 22 | logger.debug( 23 | "Executed command: %s", 24 | json.dumps({"command": command, "return_code": proc.returncode}, indent=2), 25 | ) 26 | return (proc.returncode, response) 27 | 28 | 29 | def extract_sensitive_values(stateTree: dict) -> dict[str, dict[str, str]]: 30 | sensitive_values = {} 31 | if isinstance(stateTree, dict): 32 | if ( 33 | stateTree.get("mode") in ["managed", "data"] 34 | and stateTree.get("sensitive_values") is not None 35 | ): 36 | sensitive_keys = stateTree.get("sensitive_values") 37 | if sensitive_keys: 38 | secrets = { 39 | k: v 40 | for k, v in stateTree.get("values").items() 41 | if k in sensitive_keys 42 | } 43 | sensitive_values[stateTree.get("address")] = secrets 44 | for value in stateTree.values(): 45 | sensitive_values.update(extract_sensitive_values(value)) 46 | elif isinstance(stateTree, list): 47 | for item in stateTree: 48 | sensitive_values.update(extract_sensitive_values(item)) 49 | return sensitive_values 50 | 51 | 52 | def split_resource_name(fullname: str) -> list[str]: 53 | # Thanks Chatgpt, couldn't do this without you; please don't become sentient and kill us all 54 | pattern = r"\.(?=(?:[^\[\]]*\[[^\[\]]*\])*[^\[\]]*$)" 55 | return re.split(pattern, fullname) 56 | 57 | 58 | class Block: 59 | TYPE_RESOURCE = "resource" 60 | TYPE_DATASOURCE = "data" 61 | 62 | type = None 63 | name = None 64 | submodule = None 65 | contents = "" 66 | is_tainted = False 67 | 68 | def __init__(self, submodule: str, name: str, type: str, is_tainted: bool): 69 | self.type = type 70 | self.name = name 71 | self.submodule = submodule 72 | self.is_tainted = is_tainted 73 | 74 | 75 | class State: 76 | state_tree = {} 77 | executable = "" 78 | no_init = False 79 | 80 | def __init__(self, executable="terraform", no_init=False): 81 | self.executable = executable 82 | self.no_init = no_init 83 | 84 | def parse_block(line: str) -> tuple[str, str, str]: 85 | fullname = line[2 : line.rindex(":")] 86 | is_tainted = line.endswith("(tainted)") 87 | parts = split_resource_name(fullname) 88 | if fullname.startswith("data") or ".data." in fullname: 89 | name = ".".join(parts[-3:]) 90 | submodule = ".".join(parts[:-3]) 91 | type = Block.TYPE_DATASOURCE 92 | else: 93 | name = ".".join(parts[-2:]) 94 | submodule = ".".join(parts[:-2]) 95 | type = Block.TYPE_RESOURCE 96 | 97 | return (fullname, name, submodule, type, is_tainted) 98 | 99 | async def refresh_state(self) -> None: 100 | returncode, stdout = await execute_async(self.executable, "show -no-color") 101 | if returncode != 0: 102 | raise Exception(stdout) 103 | 104 | self.state_tree = {} 105 | state_output = stdout.splitlines() 106 | logger.debug(f"state show line count: {len(state_output)}") 107 | 108 | contents = "" 109 | for line in state_output: 110 | if line.startswith("#"): 111 | (fullname, name, submodule, type, is_tainted) = State.parse_block(line) 112 | contents = "" 113 | elif line.startswith("}"): 114 | contents += line.rstrip() + "\n" 115 | block = Block(submodule, name, type, is_tainted) 116 | block.contents = contents 117 | self.state_tree[fullname] = block 118 | else: 119 | contents += line.rstrip() + "\n" 120 | 121 | if logger.isEnabledFor(logging.DEBUG): 122 | for key, block in self.state_tree.items(): 123 | logger.debug( 124 | "Parsed block: %s", 125 | json.dumps( 126 | { 127 | "fullname": key, 128 | "module": block.submodule, 129 | "name": block.name, 130 | "lines": block.contents.count("\n"), 131 | "tainted": block.is_tainted, 132 | }, 133 | indent=2, 134 | ), 135 | ) 136 | logger.debug( 137 | "Total blocks: %s", 138 | json.dumps( 139 | Counter(block.type for block in self.state_tree.values()), indent=2 140 | ), 141 | ) 142 | 143 | 144 | if __name__ == "__main__": 145 | state = State() 146 | try: 147 | asyncio.run(state.refresh_state()) 148 | for block in state.state_tree.values(): 149 | print(block.submodule, block.name, block.type, block.is_tainted) 150 | except Exception as e: 151 | print(e) 152 | -------------------------------------------------------------------------------- /tftui/ui.tcss: -------------------------------------------------------------------------------- 1 | ModalScreen { 2 | align: center middle; 3 | } 4 | 5 | #header { 6 | border: double lightblue; 7 | border-title-align: center; 8 | grid-columns: 20 1fr 50; 9 | grid-size: 3; 10 | height: 7; 11 | layout: grid; 12 | } 13 | 14 | #resource { 15 | margin: 0 0 0 1; 16 | } 17 | 18 | #plan { 19 | background: $surface; 20 | margin: 0 0 0 1; 21 | } 22 | 23 | #commandoutput { 24 | margin: 0 0 0 1; 25 | } 26 | 27 | #search { 28 | background: $surface; 29 | border: double lightblue; 30 | height: 3; 31 | } 32 | 33 | #switcher { 34 | border: double lightblue; 35 | border_title_align: center; 36 | } 37 | 38 | #tree { 39 | background: $surface; 40 | margin: 0 0 0 1; 41 | } 42 | 43 | Horizontal { 44 | align: center middle; 45 | column-span: 2; 46 | } 47 | 48 | Screen { 49 | color: $text; 50 | overflow-y: hidden; 51 | } 52 | 53 | .header-box { 54 | margin: 0 0 0 3; 55 | text-align: left; 56 | } 57 | 58 | Button { 59 | width: 100%; 60 | } 61 | 62 | #help { 63 | grid-gutter: 1 2; 64 | grid-rows: 1fr 3; 65 | padding: 0 1; 66 | width: 88; 67 | height: 25; 68 | border: thick $background 80%; 69 | background: $surface; 70 | } 71 | 72 | #question { 73 | column-span: 2; 74 | border: none; 75 | } 76 | 77 | #yesno { 78 | grid-size: 2; 79 | grid-gutter: 1 2; 80 | grid-rows: 1fr 3; 81 | padding: 1 2; 82 | width: 80%; 83 | height: 20; 84 | border: thick $background 80%; 85 | background: $surface; 86 | } 87 | 88 | #varfilelabel { 89 | height: 3; 90 | width: 14; 91 | content-align: center middle; 92 | } 93 | 94 | #varfile { 95 | height: 3; 96 | width: 1fr; 97 | border: none; 98 | content-align: center middle; 99 | } 100 | 101 | #plantarget { 102 | column-span: 2; 103 | height: 5; 104 | width: 1fr; 105 | border: none; 106 | background: $surface; 107 | } 108 | 109 | #tfvars { 110 | grid-size: 2; 111 | grid-gutter: 0 1; 112 | padding: 1 2; 113 | width: 80%; 114 | border: thick $background 80%; 115 | background: $surface; 116 | height: 20; 117 | } 118 | 119 | OptionList { 120 | grid-gutter: 1 2; 121 | height: 8; 122 | margin-bottom: 1; 123 | } 124 | 125 | Vertical { 126 | align: center middle; 127 | column-span: 1; 128 | } 129 | 130 | #workspaces { 131 | grid-size: 2; 132 | padding: 1 2; 133 | width: 30%; 134 | max-height: 18; 135 | border: thick $background 80%; 136 | background: $surface; 137 | } 138 | --------------------------------------------------------------------------------