├── .editorconfig ├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── build-python.yaml │ ├── cd.yaml │ ├── check-python.yaml │ └── pr.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── ROADMAP.md ├── debug.py ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── scripts ├── chocolatey │ └── algokit │ │ ├── README.md │ │ ├── algokit.nuspec │ │ ├── algorand-logo-512.png │ │ └── tools │ │ ├── LICENSE.txt │ │ ├── VERIFICATION.txt │ │ ├── chocolateyinstall.ps1 │ │ └── chocolateyuninstall.ps1 ├── install-poetry.sh ├── install-pyenv.sh ├── install-python.sh ├── setup.ps1 ├── setup.sh ├── update-brew-cask.sh ├── update-chocolatey-package.ps1 └── utilities.psm1 ├── src ├── algorun │ ├── __init__.py │ ├── __main__.py │ ├── cli │ │ ├── __init__.py │ │ ├── config.py │ │ ├── goal.py │ │ ├── reset.py │ │ ├── start.py │ │ └── stop.py │ ├── core │ │ ├── __init__.py │ │ ├── atomic_write.py │ │ ├── conf.py │ │ ├── doctor.py │ │ ├── init.py │ │ ├── log_handlers.py │ │ ├── proc.py │ │ ├── questionary_extensions.py │ │ ├── sandbox.py │ │ ├── utils.py │ │ └── version_prompt.py │ └── py.typed ├── config.json └── docker-compose.yml └── tests ├── __init__.py ├── conftest.py ├── goal ├── __init__.py ├── test_goal.py ├── test_goal.test_goal_complex_args.approved.txt ├── test_goal.test_goal_console.approved.txt ├── test_goal.test_goal_console_failed.approved.txt ├── test_goal.test_goal_console_failed_algod_not_created.approved.txt ├── test_goal.test_goal_help.approved.txt ├── test_goal.test_goal_no_args.approved.txt ├── test_goal.test_goal_simple_args.approved.txt ├── test_goal.test_goal_start_without_docker.approved.txt └── test_goal.test_goal_start_without_docker_engine_running.approved.txt ├── test_root.py ├── test_root.test_help.approved.txt ├── utils ├── __init__.py ├── app_dir_mock.py ├── approvals.py ├── click_invoker.py ├── proc_mock.py └── which_mock.py └── version_check ├── __init__.py ├── test_version_check.py ├── test_version_check.test_version_check_disable_version_check.approved.txt ├── test_version_check.test_version_check_enable_version_check.approved.txt ├── test_version_check.test_version_check_queries_github_when_cache_out_of_date.approved.txt ├── test_version_check.test_version_check_queries_github_when_no_cache.approved.txt ├── test_version_check.test_version_check_respects_disable_config.approved.txt ├── test_version_check.test_version_check_respects_skip_option.approved.txt └── test_version_check.test_version_check_uses_cache.approved.txt /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.py] 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | ## Proposed Changes 4 | 5 | - 6 | - 7 | - 8 | -------------------------------------------------------------------------------- /.github/workflows/build-python.yaml: -------------------------------------------------------------------------------- 1 | name: Build, Test and Publish Python 2 | 3 | on: [workflow_call] 4 | 5 | jobs: 6 | build-python: 7 | strategy: 8 | matrix: 9 | #os: ["ubuntu-latest", "macos-latest", "windows-latest"] 10 | # Mac and Windows chew through build minutes - waiting until repo is public to enable 11 | os: ["ubuntu-latest", "windows-latest"] 12 | python: ["3.10"] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - name: Checkout source code 16 | uses: actions/checkout@v3 17 | 18 | - name: Install poetry 19 | run: pipx install poetry 20 | 21 | - name: Set up Python ${{ matrix.python }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python }} 25 | cache: "poetry" 26 | 27 | - name: Install dependencies 28 | run: poetry install --no-interaction 29 | 30 | - name: Build Wheel 31 | run: poetry build --format wheel 32 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Delivery of Python package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | inputs: 9 | production_release: 10 | description: "Production release?" 11 | type: boolean 12 | required: true 13 | default: true 14 | 15 | concurrency: release 16 | 17 | permissions: 18 | contents: write 19 | packages: read 20 | 21 | jobs: 22 | ci-check-python: 23 | name: Check Python 24 | uses: ./.github/workflows/check-python.yaml 25 | 26 | ci-build-python: 27 | name: Build Python 28 | uses: ./.github/workflows/build-python.yaml 29 | needs: ci-check-python 30 | 31 | release: 32 | name: Release Library 33 | needs: ci-build-python 34 | runs-on: ubuntu-latest 35 | permissions: 36 | # IMPORTANT: this permission is mandatory for trusted publishing 37 | id-token: write 38 | contents: write 39 | packages: read 40 | 41 | steps: 42 | - uses: actions/checkout@v3 43 | with: 44 | # Fetch entire repository history so we can determine version number from it 45 | fetch-depth: 0 46 | 47 | - name: Install poetry 48 | run: pipx install poetry 49 | 50 | - name: Set up Python 51 | uses: actions/setup-python@v4 52 | with: 53 | python-version: "3.10" 54 | cache: "poetry" 55 | 56 | - name: Install dependencies 57 | run: poetry install --no-interaction --no-root 58 | 59 | - name: Get branch name 60 | shell: bash 61 | run: echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT 62 | id: get_branch 63 | 64 | - name: Set Git user as GitHub actions 65 | run: git config --global user.email "actions@github.com" && git config --global user.name "github-actions" 66 | 67 | - name: Create Continuous Deployment - Beta (non-prod) 68 | if: steps.get_branch.outputs.branch == 'main' && !inputs.production_release 69 | run: | 70 | poetry run semantic-release \ 71 | -v DEBUG \ 72 | --prerelease \ 73 | --define=branch=main \ 74 | publish 75 | gh release edit --prerelease "v$(poetry run semantic-release print-version --current)" 76 | env: 77 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | 79 | - name: Create Continuous Deployment - Production 80 | if: steps.get_branch.outputs.branch == 'main' && inputs.production_release 81 | run: | 82 | poetry run semantic-release \ 83 | -v DEBUG \ 84 | --define=version_source="commit" \ 85 | --define=patch_without_tag=true \ 86 | --define=branch=main \ 87 | publish 88 | env: 89 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | 91 | - name: Publish package distributions to PyPI 92 | uses: pypa/gh-action-pypi-publish@release/v1 93 | -------------------------------------------------------------------------------- /.github/workflows/check-python.yaml: -------------------------------------------------------------------------------- 1 | name: Check Python Code 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | check-python: 8 | runs-on: "ubuntu-latest" 9 | steps: 10 | - name: Checkout source code 11 | uses: actions/checkout@v3 12 | 13 | - name: Install poetry 14 | run: pipx install poetry 15 | 16 | - name: Set up Python 3.10 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.10" 20 | cache: "poetry" 21 | 22 | - name: Install dependencies 23 | run: poetry install --no-interaction 24 | 25 | - name: Audit with pip-audit 26 | run: | 27 | # audit non dev dependencies, no exclusions 28 | poetry export --without=dev > requirements.txt && poetry run pip-audit -r requirements.txt 29 | 30 | # audit all dependencies, with exclusions. 31 | # If a vulnerability is found in a dev dependency without an available fix, 32 | # it can be temporarily ignored by adding --ignore-vuln e.g. 33 | # --ignore-vuln "GHSA-hcpj-qp55-gfph" # GitPython vulnerability, dev only dependency 34 | poetry run pip-audit 35 | 36 | - name: Check formatting with Black 37 | run: | 38 | # stop the build if there are files that don't meet formatting requirements 39 | poetry run black --check . 40 | 41 | - name: Check types with mypy 42 | run: poetry run mypy 43 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request validation 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | pr-check: 7 | name: Check Python 8 | uses: ./.github/workflows/check-python.yaml 9 | 10 | pr-build: 11 | name: Build and Test Python 12 | needs: pr-check 13 | uses: ./.github/workflows/build-python.yaml 14 | -------------------------------------------------------------------------------- /.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 | pytest-coverage.txt 54 | pytest-junit.xml 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Ruff (linter) 155 | .ruff_cache/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | .idea/ 162 | !.idea/runConfigurations 163 | 164 | # macOS 165 | .DS_Store 166 | 167 | # we use this file for quickly editing run/debug args, it shouldn't be committed 168 | /args.in 169 | 170 | # Received approval test files 171 | *.received.* 172 | 173 | #Sphinx 174 | .doctrees/ 175 | docs/cli/temp.md 176 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: black 6 | description: "Black: The uncompromising Python code formatter" 7 | entry: poetry run black 8 | language: system 9 | minimum_pre_commit_version: 2.9.2 10 | require_serial: true 11 | types_or: [ python, pyi ] 12 | - id: ruff 13 | name: ruff 14 | description: "Run 'ruff' for extremely fast Python linting" 15 | entry: poetry run ruff 16 | language: system 17 | 'types': [python] 18 | args: [--fix] 19 | require_serial: false 20 | additional_dependencies: [] 21 | minimum_pre_commit_version: '0' 22 | files: '^(src|tests)/' 23 | - id: mypy 24 | name: mypy 25 | description: '`mypy` will check Python types for correctness' 26 | entry: poetry run mypy 27 | language: system 28 | types_or: [ python, pyi ] 29 | require_serial: true 30 | additional_dependencies: [ ] 31 | minimum_pre_commit_version: '2.9.2' 32 | files: '^(src|tests)/' 33 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "ms-python.python", 5 | "ms-python.vscode-pylance", 6 | "charliermarsh.ruff", 7 | "bungcip.better-toml", 8 | "editorconfig.editorconfig", 9 | "emeraldwalk.runonsave", 10 | "matangover.mypy", 11 | "runarsf.platform-settings" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Module", 9 | "type": "python", 10 | "cwd": "${workspaceFolder}", 11 | "request": "launch", 12 | "module": "debug", 13 | "justMyCode": true, 14 | "console": "integratedTerminal" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // General - see also /.editorconfig 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | // Don't want to use isort because it conflicts with Ruff - see run on save below 6 | "source.organizeImports": false 7 | }, 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | "files.exclude": { 10 | "**/.git": true, 11 | "**/.DS_Store": true, 12 | "**/Thumbs.db": true, 13 | ".mypy_cache": true, 14 | ".pytest_cache": true, 15 | ".ruff_cache": true, 16 | "**/__pycache__": true, 17 | ".idea": true 18 | }, 19 | 20 | // Python 21 | "platformSettings.autoLoad": true, 22 | "platformSettings.platforms": { 23 | "default": { 24 | "nodes": { 25 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" 26 | } 27 | }, 28 | "win32": { 29 | "nodes": { 30 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe" 31 | } 32 | } 33 | }, 34 | "python.analysis.extraPaths": ["${workspaceFolder}/src"], 35 | "python.formatting.provider": "black", 36 | "[python]": { 37 | // https://dev.to/eegli/quick-guide-to-python-formatting-in-vs-code-2040 38 | "editor.defaultFormatter": null 39 | }, 40 | "python.analysis.typeCheckingMode": "basic", 41 | "python.linting.enabled": true, 42 | "python.linting.lintOnSave": true, 43 | "ruff.importStrategy": "fromEnvironment", 44 | "python.linting.pylintEnabled": false, 45 | "python.linting.mypyEnabled": false, 46 | "mypy.configFile": "pyproject.toml", 47 | // set to empty array to use config from project 48 | "mypy.targets": [], 49 | "mypy.runUsingActiveInterpreter": true, 50 | "python.linting.banditEnabled": false, 51 | "python.linting.prospectorEnabled": false, 52 | "python.linting.pydocstyleEnabled": false, 53 | "python.linting.pycodestyleEnabled": false, 54 | "python.testing.unittestEnabled": false, 55 | "python.testing.pytestEnabled": true, 56 | "emeraldwalk.runonsave": { 57 | "commands": [ 58 | // Run Ruff linter on save of Python file 59 | { 60 | "match": "\\.py$", 61 | "cmd": "${workspaceFolder}/.venv/bin/ruff ${file} --fix" 62 | } 63 | ] 64 | }, 65 | 66 | // PowerShell 67 | "[powershell]": { 68 | "editor.defaultFormatter": "ms-vscode.powershell" 69 | }, 70 | "powershell.codeFormatting.preset": "Stroustrup" 71 | } 72 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.1.0 (2023-06-30) 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Algorun for contributors 2 | 3 | ## Commits 4 | 5 | We are using the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) standard for commit messages. This allows us to automatically generate release notes and version numbers. We do this via [Python Semantic Release](https://python-semantic-release.readthedocs.io/en/latest/) and [GitHub actions](.github/workflows/cd.yaml). 6 | 7 | ## WIP 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 Algorand Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Algorun CLI 2 | 3 | THIS IS IN BETA 4 | 5 | ## Is this for me? 6 | 7 | This tool simplifies setting up and starting an Algorand mainnet node. You should know your way around a CLI if you're planning to use it 8 | 9 | # Install 10 | 11 | ## Prerequisites 12 | 13 | The key required dependency is Python 3.10+, but some of the installation options below will install that for you. 14 | 15 | Algorun also has some runtime dependencies that also need to be available for particular commands. 16 | 17 | - Docker - Docker Compose (and by association, Docker) is used to run the Algorand mainnet container, we require Docker Compose 2.5.0+ 18 | 19 | - Pipx - a better package manager than pip that you'll use to install the cli 20 | 21 | ## Install Algorun with pipx on any Mac, Linux and Windows subsystem for Linux 22 | 23 | 1. Ensure desired prerequisites are installed 24 | 25 | - [Python 3.10+](https://www.python.org/downloads/) 26 | - [pipx](https://pypa.github.io/pipx/installation/) 27 | - [Docker](https://docs.docker.com/get-docker/) 28 | 29 | 2. Install using pipx `pipx install algorun` 30 | 3. Restart the terminal to ensure Algorun is available on the path 31 | 4. [Verify installation](#verify-installation) 32 | 33 | ### Maintenance 34 | 35 | - To update Algorun: `pipx upgrade algorun` 36 | - To remove Algorun: `pipx uninstall algorun` 37 | 38 | ## Verify installation 39 | 40 | Verify Algorun is installed correctly by running `algorun --version` and you should see output similar to: 41 | 42 | ``` 43 | algorun, version 0.1 44 | ``` 45 | 46 | ## Usage 47 | 48 | Create a directory where you're comfortable keeping the node config and files, we suggest naming it `algorand`, open that directory in a terminal 49 | 50 | - `algorun start` will start your node by creating `docker-compose.yml`, `config.json` files and a `data` directory where your node will persist. 51 | - `algorun stop` will shut down your node 52 | - `algorun goal` is a wrapper for the [Goal CLI](https://developer.algorand.org/docs/clis/goal/goal/) 53 | - typing `algorun goal node status` will return your nodes status, typing `algorun goal node status -w 1000` instead will keep giving you node status updates every 1 second 54 | 55 | > **Note** 56 | > If you get receive one of the following errors: 57 | > 58 | > - `command not found: algorun` (bash/zsh) 59 | > - `The term 'algorun' is not recognized as the name of a cmdlet, function, script file, or operable program.` (PowerShell) 60 | > 61 | > Then ensure that `algorun` is available on the PATH by running `pipx ensurepath` and restarting the terminal. 62 | 63 | If you're experiencing issues with algorun [raise an issue](https://github.com/algorandfoundation/algorun/issues/new). 64 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Algorun CLI Roadmap 2 | 3 | ## Road to v1 4 | 5 | - Guides on how to register for consensus 6 | - Implement `status` command that wraps `goal node status -w 1000` so users don't have to remember that command 7 | - Implement `log` command for users to see container logs 8 | - Implement a tui, possibly [node ui](https://github.com/algorand/node-ui) or maybe a gui 9 | -------------------------------------------------------------------------------- /debug.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script is for invoking algokit from your IDE with a dynamic set of args, 3 | defined in args.in (which is in .gitignore) 4 | """ 5 | 6 | import os 7 | import subprocess 8 | import sys 9 | from pathlib import Path 10 | 11 | try: 12 | import click 13 | except ImportError: 14 | print( # noqa: T201 15 | "ERROR: Couldn't import click, make sure you've run 'poetry install' and activated the virtual environment.\n" 16 | "For tips on getting started with developing AlgoKit CLI itself see CONTRIBUTING.md.\n", 17 | file=sys.stderr, 18 | ) 19 | raise 20 | 21 | if sys.prefix == sys.base_prefix: 22 | click.echo( 23 | click.style( 24 | "WARNING: virtualenv not activated, this is unexpected and you probably want to activate it first", 25 | fg="red", 26 | ), 27 | err=True, 28 | ) 29 | 30 | vcs_root = Path(__file__).parent 31 | args_file = vcs_root / "args.in" 32 | if not args_file.exists(): 33 | click.echo( 34 | click.style( 35 | "arg.in does not exist, creating an empty file.\n" 36 | "Edit this file to change what runs - each line should contain the command line arguments to algokit.\n" 37 | "\n", 38 | fg="yellow", 39 | ), 40 | err=True, 41 | ) 42 | args_file.touch(exist_ok=False) 43 | 44 | commands_sequence = args_file.read_text().splitlines() 45 | 46 | # change to src directory so algokit is in path 47 | os.chdir(vcs_root / "src") 48 | for command in commands_sequence or [""]: 49 | click.echo(click.style(f"> algorun -v {command}", bold=True), err=True) 50 | run_result = subprocess.run([sys.executable, "-m", "algorun", "-v", *command.split()]) 51 | if run_result.returncode != 0: 52 | click.echo( 53 | click.style( 54 | f"command failed, return code was: {run_result.returncode}", 55 | bold=True, 56 | fg="red", 57 | ), 58 | err=True, 59 | ) 60 | sys.exit(run_result.returncode) 61 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "algorun" 3 | version = "0.2.1" 4 | description = "Algorand development kit command-line interface" 5 | authors = ["Algorand Foundation "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | click = "^8.1.3" 12 | httpx = "^0.23.1" 13 | copier = "^7.1.0" 14 | questionary = "^1.10.0" 15 | pyclip = "^0.7.0" 16 | shellingham = "^1.5.0.post1" 17 | algokit-client-generator = "^1.0.1" 18 | tomli = { version = "^2.0.1", python = "<3.11" } 19 | 20 | [tool.poetry.group.dev.dependencies] 21 | pytest = "^7.2.0" 22 | black = {extras = ["d"], version = "^23.1.0"} 23 | ruff = "^0.0.257" 24 | pip-audit = "^2.4.7" 25 | approvaltests = "^7.2.0" 26 | pytest-mock = "^3.10.0" 27 | mypy = "^1.0.0" 28 | pytest-httpx = "^0.21.2" 29 | python-semantic-release = "^7.32.2" 30 | pytest-cov = "^4.0.0" 31 | pre-commit = "^2.20.0" 32 | sphinx = "^6.0.0" 33 | sphinx-click = "^4.4.0" 34 | sphinxnotes-markdown-builder = "^0.5.6" 35 | poethepoet = "^0.17.1" 36 | gfm-toc = "^0.0.7" 37 | 38 | [build-system] 39 | requires = ["poetry-core"] 40 | build-backend = "poetry.core.masonry.api" 41 | 42 | [tool.poetry.scripts] 43 | algorun = "algorun.cli:algorun" 44 | 45 | [tool.poe.tasks] 46 | docs_generate = "sphinx-build -b markdown -E docs/sphinx docs/cli" 47 | docs_toc = "gfm-toc docs/cli/index.md -e 3" 48 | docs_title = {shell = "(echo \"# AlgoKit CLI Reference Documentation\\n\\n\"; cat docs/cli/index.md) > docs/cli/temp.md && mv docs/cli/temp.md docs/cli/index.md"} 49 | docs = ["docs_generate", "docs_toc", "docs_title"] 50 | 51 | [tool.ruff] 52 | line-length = 120 53 | select = [ 54 | # all possible codes as of this ruff version are listed here, 55 | # ones we don't want/need are commented out to make it clear 56 | # which have been omitted on purpose vs which ones get added 57 | # in new ruff releases and should be considered for enabling 58 | "F", # pyflakes 59 | "E", "W", # pycodestyle 60 | "C90", # mccabe 61 | "I", # isort 62 | "N", # PEP8 naming 63 | "UP", # pyupgrade 64 | "YTT", # flake8-2020 65 | "ANN", # flake8-annotations 66 | # "S", # flake8-bandit 67 | # "BLE", # flake8-blind-except 68 | "FBT", # flake8-boolean-trap 69 | "B", # flake8-bugbear 70 | "A", # flake8-builtins 71 | # "COM", # flake8-commas 72 | "C4", # flake8-comprehensions 73 | "DTZ", # flake8-datetimez 74 | "T10", # flake8-debugger 75 | # "DJ", # flake8-django 76 | # "EM", # flake8-errmsg 77 | # "EXE", # flake8-executable 78 | "ISC", # flake8-implicit-str-concat 79 | "ICN", # flake8-import-conventions 80 | # "G", # flake8-logging-format 81 | # "INP", # flake8-no-pep420 82 | "PIE", # flake8-pie 83 | "T20", # flake8-print 84 | "PYI", # flake8-pyi 85 | "PT", # flake8-pytest-style 86 | "Q", # flake8-quotes 87 | "RSE", # flake8-raise 88 | "RET", # flake8-return 89 | "SLF", # flake8-self 90 | "SIM", # flake8-simplify 91 | "TID", # flake8-tidy-imports 92 | "TCH", # flake8-type-checking 93 | "ARG", # flake8-unused-arguments 94 | "PTH", # flake8-use-pathlib 95 | "ERA", # eradicate 96 | # "PD", # pandas-vet 97 | "PGH", # pygrep-hooks 98 | "PL", # pylint 99 | # "TRY", # tryceratops 100 | # "NPY", # NumPy-specific rules 101 | "RUF", # Ruff-specific rules 102 | ] 103 | ignore = [ 104 | "ANN101", # no type for self 105 | "ANN102", # no type for cls 106 | "SIM108", # allow if-else in place of ternary 107 | "RET505", # allow else after return 108 | ] 109 | # Exclude a variety of commonly ignored directories. 110 | exclude = [ 111 | ".direnv", 112 | ".git", 113 | ".mypy_cache", 114 | ".ruff_cache", 115 | ".venv", 116 | "__pypackages__", 117 | "_build", 118 | "build", 119 | "dist", 120 | "node_modules", 121 | "venv", 122 | "docs/sphinx", 123 | ] 124 | # Allow unused variables when underscore-prefixed. 125 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 126 | # Assume Python 3.10. 127 | target-version = "py310" 128 | 129 | [tool.ruff.per-file-ignores] 130 | "tests/**/test_*.py" = ["PLR0913"] # too many args 131 | 132 | [tool.ruff.flake8-annotations] 133 | allow-star-arg-any = true 134 | suppress-none-returning = true 135 | 136 | [tool.black] 137 | line-length = 120 138 | 139 | [tool.pytest.ini_options] 140 | pythonpath = ["src", "tests"] 141 | markers = [ 142 | "mock_platform_system", 143 | ] 144 | [tool.mypy] 145 | files = ["src", "tests"] 146 | exclude = ["dist"] 147 | python_version = "3.10" 148 | warn_unused_ignores = true 149 | warn_redundant_casts = true 150 | warn_unused_configs = true 151 | warn_unreachable = true 152 | warn_return_any = true 153 | strict = true 154 | disallow_untyped_decorators = true 155 | disallow_any_generics = false 156 | implicit_reexport = false 157 | 158 | [[tool.mypy.overrides]] 159 | module = "approvaltests.*" 160 | ignore_missing_imports = true 161 | 162 | [tool.semantic_release] 163 | version_toml = "pyproject.toml:tool.poetry.version" 164 | remove_dist = false 165 | build_command = "poetry build --format wheel" 166 | version_source = "tag" 167 | major_on_zero = true 168 | upload_to_repository = false 169 | tag_commit = true 170 | branch = "main" 171 | commit_message = "{version}\n\nskip-checks: true" 172 | -------------------------------------------------------------------------------- /scripts/chocolatey/algokit/README.md: -------------------------------------------------------------------------------- 1 | # Chocolatey Package 2 | 3 | This directory contains the nuspec file to define the [Chocolatey](https://chocolatey.org/) repository package for Windows and various associate powershell scripts. 4 | 5 | # Installing 6 | 7 | > __Note__ 8 | > This will install the most recent python3 version through chocolatey. If you already have python installed, you may prefer to use `pipx install algokit` as explained in [Installing](../../../README.md). 9 | 10 | 1. Ensure chocolatey is installed - https://chocolatey.org/install 11 | 2. Run `choco install algokit` from an administrator powershell/cmd/terminal window 12 | 3. Test algokit is installed `algokit --version` 13 | 14 | # Development 15 | 16 | ## Building and publishing locally 17 | 18 | 1. Ensure wheel file is built `poetry build` (make sure there's only a single file in _dist_ directory) 19 | 2. Set version field in _algokit.nuspec_ 20 | > __Note__ 21 | > Versions with a pre-release suffix such as 1.2.3-beta are automatically designated as pre-release packages by chocolatey 22 | 3. `cd .\scripts\chocolatey\algokit` 23 | 4. `choco pack` 24 | 5. `choco apikey --api-key [API_KEY_HERE] -source https://push.chocolatey.org/` 25 | 6. `choco push --source https://push.chocolatey.org/` 26 | 27 | Also see [Chocolatey docs](https://docs.chocolatey.org/en-us/create/create-packages). 28 | 29 | ## Installing from local packages 30 | 31 | - `cd .\scripts\chocolatey\algokit` 32 | - Install - `choco install algokit -pre --source "'.;https://community.chocolatey.org/api/v2/'" -y` 33 | - Uninstall - `choco uninstall algokit -y` 34 | - Upgrade - `choco upgrade algokit -pre --source "'.;https://community.chocolatey.org/api/v2/'" -y` 35 | 36 | # Issues 37 | 38 | - Chocolatey doesn't support full sematic release v2 versions yet. This means version identifiers with dot notation are not supported (1.2.3-beta.12). See [this issue](https://github.com/chocolatey/choco/issues/1610). 39 | - Installing using the `-pre` tag on will also install pre-release verisons of dependencies. At the time of development, this installed python 3.12.0-a2 which has errors installing algokit. Solution is to `choco install python3` prior to `choco install algokit -pre`. See [nuspec](https://learn.microsoft.com/en-us/nuget/concepts/package-versioning#pre-release-versions) for clarification. 40 | -------------------------------------------------------------------------------- /scripts/chocolatey/algokit/algokit.nuspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | algokit 6 | 0.1.0-beta 7 | https://github.com/algorandfoundation/algokit-cli/blob/main/scripts/chocolatey 8 | algorand-foundation 9 | AlgoKit 10 | Algorand Foundation 11 | https://www.algorand.foundation/developers 12 | https://rawcdn.githack.com/algorandfoundation/algokit-cli/733cbc7714db7d4786cb9c0de60c991c63dcc3b7/scripts/chocolatey/algokit/algorand-logo-512.png?min=1 13 | https://github.com/algorandfoundation/algokit-cli/blob/main/LICENSE 14 | false 15 | https://github.com/algorandfoundation/algokit-cli 16 | https://github.com/algorandfoundation/algokit-cli/blob/main/README.md 17 | https://github.com/algorandfoundation/algokit-cli/issues 18 | https://github.com/algorandfoundation/algokit-cli/blob/main/CHANGELOG.md 19 | algokit algorand developers python typescript hello-world smart-contract beaker 20 | The Algorand AlgoKit CLI is the one-stop shop tool for developers building on the Algorand network. 21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /scripts/chocolatey/algokit/algorand-logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algorandfoundation/algorun/bd1ad2cc3b441b7804eccc07fc6f07ba83623c83/scripts/chocolatey/algokit/algorand-logo-512.png -------------------------------------------------------------------------------- /scripts/chocolatey/algokit/tools/LICENSE.txt: -------------------------------------------------------------------------------- 1 | From: https://github.com/algorandfoundation/algokit-cli/blob/main/LICENSE 2 | MIT License 3 | 4 | Copyright (c) 2022 Algorand Foundation 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /scripts/chocolatey/algokit/tools/VERIFICATION.txt: -------------------------------------------------------------------------------- 1 | VERIFICATION 2 | Verification is intended to assist the Chocolatey moderators and community 3 | in verifying that this package's contents are trustworthy. 4 | 5 | To verify this package download the .whl from the URL specified below and compare it's SHA-256 checksum with the 6 | below. 7 | 8 | Wheel: {wheel_url} 9 | SHA256: {sha256} 10 | 11 | Algorand Foundation are the authors of this software and maintainers of the Chocolatey package. 12 | -------------------------------------------------------------------------------- /scripts/chocolatey/algokit/tools/chocolateyinstall.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | 3 | $toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" 4 | 5 | # ensure pipx is installed 6 | python -m pip install --disable-pip-version-check --no-warn-script-location pipx 7 | if ($LASTEXITCODE -ne 0) { 8 | Throw "Error installing pipx" 9 | } 10 | 11 | # work out the wheel name and make sure there wasn't a packaging error 12 | $wheelFileName = Get-ChildItem -File -Filter $env:ChocolateyPackageName*.whl $toolsDir 13 | if ($wheelFileName.count -ne 1) { 14 | Throw "Packaging error. nupkg contained $($wheelFile.count) wheel files" 15 | } 16 | 17 | # determine if the package is already installed. In which case, uninstall it first 18 | # Note - pipx upgrade does not work with local files 19 | $pipxListOutput = &{ 20 | # pipx outputs to stderr if there are no packages, so ignore that error 21 | $ErrorActionPreference = 'Continue' 22 | python -m pipx list 2>&1 23 | if ($LASTEXITCODE -ne 0) { 24 | Throw "Error searching for existing packages" 25 | } 26 | } 27 | 28 | # uninstall existing package if present 29 | if ($pipxListOutput -match "$env:ChocolateyPackageName.*") { 30 | &{ 31 | #pipx outputs to stderr as part of normal execution, so ignore stderr 32 | $ErrorActionPreference = 'Continue' 33 | python -m pipx uninstall $env:ChocolateyPackageName 2>&1 34 | if ($LASTEXITCODE -ne 0) { 35 | Throw "Error removing existing version" 36 | } 37 | } 38 | } 39 | 40 | # install the bundled wheel file. 41 | &{ 42 | #pipx outputs to stderr as part of normal execution, so ignore stderr 43 | $ErrorActionPreference = 'Continue' 44 | python -m pipx install $wheelFileName[0].FullName 2>&1 45 | if ($LASTEXITCODE -ne 0) { 46 | Throw "Error installing $($wheelFileName[0].FullName)" 47 | } 48 | } 49 | 50 | #setup shim 51 | $pipx_list = python -m pipx list --json | ConvertFrom-Json 52 | $algokit_path = $pipx_list.venvs.algokit.metadata.main_package.app_paths.__Path__ 53 | Install-BinFile -Name algokit -Path $algokit_path -------------------------------------------------------------------------------- /scripts/chocolatey/algokit/tools/chocolateyuninstall.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | 3 | # ensure pipx is installed. Just in case someone has removed it manually 4 | python -m pip install --disable-pip-version-check --no-warn-script-location --user pipx 5 | if ($LASTEXITCODE -ne 0) { 6 | Throw "Error configuring pipx for uninstalling" 7 | } 8 | 9 | # zap it 10 | &{ 11 | #pipx outputs to stderr as part of normal execution, so ignore stderr 12 | $ErrorActionPreference = 'Continue' 13 | python -m pipx uninstall $env:ChocolateyPackageName 2>&1 14 | if ($LASTEXITCODE -ne 0) { 15 | if ($cmdOutput -match "Nothing to uninstall" ) { 16 | Write-Output "$($env:ChocolateyPackageName) already uninstalled by pipx. Ignoring" 17 | } 18 | else { 19 | Throw "Error running pipx uninstall $($env:ChocolateyPackageName)" 20 | } 21 | } 22 | } 23 | 24 | Uninstall-BinFile -Name algokit -------------------------------------------------------------------------------- /scripts/install-poetry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function installPoetry () { 4 | pyenv="$(pyenv --version 2>/dev/null)" 5 | if [[ $? -ne 0 ]]; then 6 | echo "Error: can't install poetry, pyenv is not available ❌" 7 | return 1 8 | fi 9 | 10 | echo "Installing poetry..." 11 | 12 | pyenv exec pip install poetry 13 | if [[ $? -ne 0 ]]; then 14 | return 1 15 | fi 16 | } 17 | 18 | installPoetry 19 | -------------------------------------------------------------------------------- /scripts/install-pyenv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function installPyenvLinux () { 4 | # https://github.com/pyenv/pyenv-installer 5 | curl https://pyenv.run | bash 6 | if [[ $? -ne 0 ]]; then 7 | return 1 8 | fi 9 | 10 | # set up .bashrc for pyenv https://github.com/pyenv/pyenv-installer 11 | if ! grep -q "# pyenv config" ~/.bashrc; then 12 | echo '' >> ~/.bashrc 13 | echo '# pyenv config' >> ~/.bashrc 14 | echo 'export PATH="$HOME/.pyenv/bin:$PATH"' >> ~/.bashrc 15 | echo 'eval "$(pyenv init -)"' >> ~/.bashrc 16 | echo 'eval "$(pyenv virtualenv-init -)"' >> ~/.bashrc 17 | fi 18 | } 19 | 20 | function installPyenvMac() { 21 | brew install pyenv 22 | } 23 | 24 | echo "Installing pyenv..." 25 | 26 | if [ "$(uname -s)" == Darwin ]; then 27 | installPyenvMac 28 | else 29 | installPyenvLinux 30 | fi 31 | -------------------------------------------------------------------------------- /scripts/install-python.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function installPython () { 4 | 5 | if [ $# -ne 1 ]; then 6 | echo Error: missing python version argument, e.g.: install-python.sh 3.10.6 7 | return 1 8 | fi 9 | 10 | PYTHON_VERSION=$1 11 | 12 | if [ "$(uname -s)" == Linux ]; then 13 | echo "Installing python build pre-requisistes..." 14 | # install python build pre-requisites https://github.com/pyenv/pyenv/wiki#suggested-build-environment 15 | sudo apt-get update; sudo apt-get install -y make build-essential libssl-dev zlib1g-dev \ 16 | libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \ 17 | libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev 18 | fi 19 | 20 | pyenv="$(pyenv --version 2>/dev/null)" 21 | if [[ $? -ne 0 ]]; then 22 | echo "Error: can't install python, pyenv is not available (if it just installed you might need to restart your shell for environment to update) ❌" 23 | return 1 24 | fi 25 | 26 | echo "Installing python..." 27 | 28 | # install and use python via pyenv 29 | pyenv install ${PYTHON_VERSION} 30 | pyenv global ${PYTHON_VERSION} 31 | } 32 | 33 | installPython $1 34 | -------------------------------------------------------------------------------- /scripts/setup.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | AlgoKit CLI dev environment setup. 4 | .DESCRIPTION 5 | The goal of this script is that every dev can go from clean (or dirty :P) machine -> clone -> setup.ps1 -> open in IDE -> F5 debugging in minutes, cross-platform. 6 | If you find problems with your local environment after running this (or during running this!) be sure to contribute fixes :) 7 | This script is idempotent and re-entrant; you can safely execute it multiple times. 8 | .EXAMPLE 9 | ./setup.ps1 10 | This executes everything 11 | .EXAMPLE 12 | ./setup.ps1 -SkipPythonInstall 13 | This skips the install of Python and Poetry; ensure Python 3.10+ and Poetry is already installed and available via `python` 14 | #> 15 | [CmdletBinding(SupportsShouldProcess = $true)] 16 | Param( 17 | [Parameter(Mandatory = $false)] 18 | [switch] $SkipPythonInstall = $false, 19 | 20 | [Parameter()] 21 | [switch] 22 | $Force 23 | ) 24 | # https://dille.name/blog/2017/08/27/how-to-use-shouldprocess-in-powershell-functions/ 25 | Begin { 26 | if (-not $PSBoundParameters.ContainsKey('Verbose')) { 27 | $VerbosePreference = $PSCmdlet.SessionState.PSVariable.GetValue('VerbosePreference') 28 | } 29 | if (-not $PSBoundParameters.ContainsKey('Confirm')) { 30 | $ConfirmPreference = $PSCmdlet.SessionState.PSVariable.GetValue('ConfirmPreference') 31 | } 32 | if (-not $PSBoundParameters.ContainsKey('WhatIf')) { 33 | $WhatIfPreference = $PSCmdlet.SessionState.PSVariable.GetValue('WhatIfPreference') 34 | } 35 | Write-Verbose ('[{0}] ConfirmPreference={1} WhatIfPreference={2} VerbosePreference={3}' -f $MyInvocation.MyCommand, $ConfirmPreference, $WhatIfPreference, $VerbosePreference) 36 | } 37 | Process { 38 | $ScriptPath = Split-Path $MyInvocation.MyCommand.Path 39 | Import-Module (Join-Path $ScriptPath "utilities.psm1") -Force -Global 40 | 41 | #Requires -Version 7.0.0 42 | Set-StrictMode -Version "Latest" 43 | $ErrorActionPreference = "Stop" 44 | $LASTEXITCODE = 0 45 | 46 | Push-Location (Join-Path $PSScriptRoot ..) 47 | try { 48 | 49 | if (-not $IsWindows) { 50 | Write-Host "Not running in Windows; executing setup.sh via bash instead..." 51 | & bash ./setup.sh 52 | return 53 | } 54 | 55 | $pythonMinVersion = [version]'3.10' 56 | $pythonInstallVersion = '3.10.8' 57 | 58 | $isPyenvInstalled = $null -ne (Get-Command "pyenv" -ErrorAction SilentlyContinue) 59 | $isPoetryInstalled = $null -ne (Get-Command "poetry" -ErrorAction SilentlyContinue) 60 | $isPythonInstalled = $null -ne (Get-Command "python" -ErrorAction SilentlyContinue) -and ([version](((Invoke-Command { pyenv exec python --version }) -split ' ' -replace 'b', '')[1]) -ge $pythonMinVersion) 61 | 62 | $needsInstall = -not $isPoetryInstalled -or -not $isPythonInstalled 63 | 64 | if (-not $needsInstall) { 65 | Write-Header "All dependencies are installed (pyenv, poetry, python $pythonMinVersion+) ✅" 66 | } 67 | else { 68 | 69 | Write-Header "Detected some dependencies aren't installed (poetry and/or python $pythonMinVersion+), attempting to correct..." 70 | 71 | Invoke-Confirm "Install Chocolatey" $PSCmdlet { 72 | Install-Chocolatey 73 | } 74 | 75 | if (-not $SkipPythonInstall) { 76 | Write-Header "Install pyenv" 77 | Install-ChocolateyPackage -PackageName pyenv-win 78 | Test-ThrowIfNotSuccessful 79 | 80 | Write-Header "Install Python $pythonMinVersion+" 81 | if (-not $isPythonInstalled) { 82 | Set-StrictMode -Off # On Windows pyenv is a .ps1 and strict mode breaks it :P 83 | try { 84 | Invoke-Confirm "Install Python $pythonInstallVersion via pyenv" $PSCmdlet { 85 | Invoke-Expression "pyenv update" -ErrorAction SilentlyContinue 86 | Test-ThrowIfNotSuccessful 87 | Invoke-Expression "pyenv install $pythonInstallVersion" -ErrorAction SilentlyContinue 88 | Test-ThrowIfNotSuccessful 89 | Invoke-Expression "pyenv global $pythonInstallVersion" -ErrorAction SilentlyContinue 90 | Test-ThrowIfNotSuccessful 91 | } 92 | } 93 | finally { 94 | Set-StrictMode -Version "Latest" 95 | } 96 | } 97 | else { 98 | Write-Host "Python $pythonMinVersion+ already installed" 99 | } 100 | 101 | # Install Poetry 102 | Write-Header "Install Poetry" 103 | & pyenv exec pip install poetry 104 | Test-ThrowIfNotSuccessful 105 | } 106 | } 107 | 108 | # Set up file permissions for .venv (if you run as admin then this is important) 109 | if (-not (Test-Path .venv)) { 110 | Invoke-Confirm "Create .venv folder with rwx permissions for all users" $PSCmdlet { 111 | Write-Header "Setting up file permissions so normal users can rwx in .venv to avoid potential file permission issues" 112 | New-Item -ItemType Directory -Force -Path .venv 113 | icacls .venv /grant everyone:f 114 | } 115 | } 116 | 117 | Write-Header "Installing Python dependencies via Poetry" 118 | Invoke-Confirm "Run poetry install" $PSCmdlet { 119 | if ($isPoetryInstalled) { 120 | & poetry install 121 | Test-ThrowIfNotSuccessful 122 | } 123 | elseif ($isPyenvInstalled) { 124 | & pyenv exec poetry install 125 | Test-ThrowIfNotSuccessful 126 | } 127 | else { 128 | throw "Unable to run poetry install" 129 | } 130 | } 131 | 132 | # Windows doesn't have bin folder or python3 so setting up symlinks so the behaviour is mimic'd cross-platform and all of our IDE settings work properly 133 | Write-Header "Creating venv symlinks so .venv/bin works on Windows too" 134 | if (-not (Test-Administrator)) { 135 | Write-Warning "Re-run as Administrator to set up symlink for .venv/bin -> .venv/Scripts`r`nIf you don't it will still work, but you'll manually need to select the Python interpreter in VS Code.`r`n" 136 | } 137 | else { 138 | if (-not (Test-Path (Join-Path ".venv" "bin"))) { 139 | New-Item -ItemType symboliclink -path .venv -name bin -value (Resolve-Path (Join-Path .venv Scripts)) 140 | } 141 | if (-not (Test-Path (Join-Path ".venv" "bin" "python3.exe"))) { 142 | New-Item -ItemType symboliclink -path (Join-Path .venv bin) -name python3.exe -value (Resolve-Path (Join-Path .venv Scripts python.exe)) 143 | } 144 | if (-not (Test-Path (Join-Path ".venv" "bin" "python"))) { 145 | New-Item -ItemType symboliclink -path (Join-Path .venv bin) -name python -value (Resolve-Path (Join-Path .venv Scripts python.exe)) 146 | } 147 | } 148 | 149 | } 150 | finally { 151 | Pop-Location 152 | } 153 | 154 | } 155 | End { 156 | Write-Verbose ('Completed: [{0}]' -f $MyInvocation.MyCommand) 157 | } 158 | -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | 2 | function version { echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }'; } 3 | function splitExtractElement { 4 | IFS="$2" read -a ARRAY <<< "$1" 5 | echo "${ARRAY[$3]}" 6 | } 7 | 8 | function runInstalls () { 9 | 10 | PYTHON_INSTALL_VERSION='3.10.8' 11 | PYTHON_MIN_VERSION='3.10' 12 | 13 | touch -a ~/.bashrc 14 | 15 | pyenv="$(pyenv --version 2>/dev/null)" 16 | if [[ $? -eq 0 ]]; then 17 | echo $pyenv is already installed ✅ 18 | else 19 | . $SCRIPT_DIR/install-pyenv.sh 20 | 21 | if [[ $? -ne 0 ]]; then 22 | return 1 23 | fi 24 | 25 | source ~/.bashrc 26 | fi 27 | 28 | pythonv=$(splitExtractElement "$(pyenv version 2>/dev/null)" " " 0) 29 | if [ $(version $pythonv) -ge $(version "$PYTHON_MIN_VERSION") ]; then 30 | echo "Python $pythonv is already installed ✅" 31 | else 32 | . $SCRIPT_DIR/install-python.sh $PYTHON_INSTALL_VERSION 33 | 34 | if [[ $? -ne 0 ]]; then 35 | return 1 36 | fi 37 | 38 | source ~/.bashrc 39 | fi 40 | 41 | poetryv="$(pyenv exec poetry --version 2>/dev/null)" 42 | if [[ $? -eq 0 ]]; then 43 | echo $poetryv is already installed ✅ 44 | else 45 | . $SCRIPT_DIR/install-poetry.sh 46 | 47 | if [[ $? -ne 0 ]]; then 48 | return 1 49 | fi 50 | fi 51 | } 52 | 53 | function setup () { 54 | 55 | runInstalls 56 | if [[ $? -ne 0 ]]; then 57 | echo "Error: install failed, please check output" 58 | return 1 59 | fi 60 | 61 | echo "Run poetry install" 62 | pyenv exec poetry install 63 | } 64 | 65 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 66 | 67 | pushd "$SCRIPT_DIR/.." 68 | setup 69 | -------------------------------------------------------------------------------- /scripts/update-brew-cask.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #script arguments 4 | wheel_files=( $1 ) 5 | wheel_file=${wheel_files[0]} 6 | homebrew_tap_repo=$2 7 | 8 | #globals 9 | command=algorun 10 | 11 | #error codes 12 | MISSING_WHEEL=1 13 | CASK_GENERATION_FAILED=2 14 | PR_CREATION_FAILED=3 15 | 16 | if [[ ! -f $wheel_file ]]; then 17 | >&2 echo "$wheel_file not found. 🚫" 18 | exit $MISSING_WHEEL 19 | else 20 | echo "Found $wheel_file 🎉" 21 | fi 22 | 23 | get_metadata() { 24 | local field=$1 25 | grep "^$field:" $metadata | cut -f 2 -d : | xargs 26 | } 27 | 28 | create_cask() { 29 | repo="https://github.com/${GITHUB_REPOSITORY}" 30 | homepage="$repo" 31 | 32 | wheel=`basename $wheel_file` 33 | echo "Creating brew cask from $wheel_file" 34 | 35 | #determine package_name, version and release tag from .whl 36 | package_name=`echo $wheel | cut -d- -f1` 37 | 38 | version=None 39 | version_regex="-([0-9]+\.[0-9]+\.[0-9]+)b?([0-9]*)-" 40 | if [[ $wheel_file =~ $version_regex ]]; then 41 | version=${BASH_REMATCH[1]} 42 | version_beta=${BASH_REMATCH[2]} 43 | fi 44 | 45 | release_tag="v${version}" 46 | if [[ -n $version_beta ]]; then 47 | release_tag=${release_tag}-beta.${version_beta} 48 | fi 49 | 50 | echo Version: $version 51 | echo Release Tag: $release_tag 52 | 53 | url="$repo/releases/download/$release_tag/$wheel" 54 | #get other metadata from wheel 55 | unzip -o $wheel_file -d . >/dev/null 2>&1 56 | metadata=`echo $wheel | cut -f 1,2 -d "-"`.dist-info/METADATA 57 | 58 | desc=`get_metadata Summary` 59 | license=`get_metadata License` 60 | 61 | echo "Calculating sha256 of $url..." 62 | sha256=`curl -s -L $url | sha256sum | cut -f 1 -d ' '` 63 | 64 | ruby=${command}.rb 65 | 66 | echo "Outputting $ruby..." 67 | 68 | cat << EOF > $ruby 69 | # typed: false 70 | # frozen_string_literal: true 71 | 72 | cask "$command" do 73 | version "$version" 74 | sha256 "$sha256" 75 | 76 | url "$repo/releases/download/v#{version}/algokit-#{version}-py3-none-any.whl" 77 | name "$command" 78 | desc "$desc" 79 | homepage "$homepage" 80 | 81 | depends_on formula: "pipx" 82 | container type: :naked 83 | 84 | installer script: { 85 | executable: "pipx", 86 | args: ["install", "--force", "#{staged_path}/algokit-#{version}-py3-none-any.whl"], 87 | print_stderr: false, 88 | } 89 | installer script: { 90 | executable: "pipx", 91 | args: ["ensurepath"], 92 | } 93 | installer script: { 94 | executable: "bash", 95 | args: ["-c", "echo \$(which pipx) uninstall $package_name >#{staged_path}/uninstall.sh"], 96 | } 97 | 98 | uninstall script: { 99 | executable: "bash", 100 | args: ["#{staged_path}/uninstall.sh"], 101 | } 102 | end 103 | EOF 104 | 105 | if [[ ! -f $ruby ]]; then 106 | >&2 echo "Failed to generate $ruby 🚫" 107 | exit $CASK_GENERATION_FAILED 108 | else 109 | echo "Created $ruby 🎉" 110 | fi 111 | } 112 | 113 | create_pr() { 114 | local full_ruby=`realpath $ruby` 115 | echo "Cloning $homebrew_tap_repo..." 116 | clone_dir=`mktemp -d` 117 | git clone "https://oauth2:${TAP_GITHUB_TOKEN}@github.com/${homebrew_tap_repo}.git" $clone_dir 118 | 119 | echo "Commiting Casks/$ruby..." 120 | pushd $clone_dir 121 | dest_branch="$command-update-$version" 122 | git checkout -b $dest_branch 123 | mkdir -p $clone_dir/Casks 124 | cp $full_ruby $clone_dir/Casks 125 | message="Updating $command to $version" 126 | git add . 127 | git commit --message "$message" 128 | 129 | echo "Pushing $dest_branch..." 130 | git push -u origin HEAD:$dest_branch 131 | 132 | echo "Creating a pull request..." 133 | # can't use gh because it doesn't support fine grained access tokens yet https://github.com/github/roadmap/issues/622 134 | cat << EOF > pr_body.json 135 | { 136 | "title": "${message}", 137 | "head": "${dest_branch}", 138 | "base": "main" 139 | } 140 | EOF 141 | 142 | curl \ 143 | --fail \ 144 | -X POST \ 145 | -H "Accept: application/vnd.github+json" \ 146 | -H "Authorization: Bearer $TAP_GITHUB_TOKEN"\ 147 | -H "X-GitHub-Api-Version: 2022-11-28" \ 148 | https://api.github.com/repos/${homebrew_tap_repo}/pulls \ 149 | -d @pr_body.json 150 | pr_exit_code=$? 151 | 152 | popd 153 | 154 | echo "Cleanup." 155 | rm -rf $clone_dir 156 | 157 | if [[ $pr_exit_code != 0 ]]; then 158 | >&2 echo "PR creation failed 🚫" 159 | exit $PR_CREATION_FAILED 160 | else 161 | echo "PR creation successful 🎉" 162 | fi 163 | } 164 | 165 | create_cask 166 | create_pr 167 | 168 | echo Done. 169 | echo 170 | -------------------------------------------------------------------------------- /scripts/update-chocolatey-package.ps1: -------------------------------------------------------------------------------- 1 | # Re-create the version based on the wheel file name. 2 | # NOTE: x.y.x-beta.12 versions are not supported by chocolatey and need to be rewritten as x.y.z-beta12 (however this will likely change soon) 3 | # "special version part" requirements. <20 characters, no '.' no '+' 4 | $wheelFiles = Get-ChildItem -File -Filter algokit*-py3-none-any.whl dist 5 | if ($wheelFiles.count -ne 1) { 6 | Throw "Packaging error. build artifact contained $($wheelFileName.count) normally named wheel files" 7 | } 8 | $wheelFile = $wheelFiles[0] 9 | $wheelFileName = $wheelFile.Name 10 | if ($wheelFileName -Match '-([0-9]+\.[0-9]+\.[0-9]+)b?([0-9]*)(\+(.*?))?(.[0-9]+)?-') { 11 | $version_number = $Matches[1] 12 | $version_beta = $Matches[2] 13 | $version_branch = $Matches[4] 14 | $version_branch = $version_branch ? $version_branch.Replace(".", "") : "" # dots aren't valid here 15 | $version_betanumber = $Matches[5] 16 | $version_beta_truncated = "beta$($version_beta)$($version_branch)" 17 | #$version_beta_truncated = "beta$($version_beta)$($version_branch)$($version_betanumber)" # When chocolatey supports semver v2.0 18 | $version_beta_truncated = $version_beta_truncated.subString(0, [System.Math]::Min(20, $version_beta_truncated.Length - 1)) # chocolatey has a limit of 20 characters on "special version part" 19 | } 20 | else { 21 | Throw "Packaging error. Unrecognised file name pattern $($wheelFileName[0].Name)" 22 | } 23 | 24 | $version = $version_number 25 | $release_tag = "v${version_number}" 26 | if ($version_beta) { 27 | $version = "$($version)-$($version_beta_truncated)" 28 | $release_tag = "${release_tag}-beta.${version_beta}" 29 | } 30 | 31 | # Update VERIFICATION.txt with current URL & SHA256 32 | $verification = Get-Item -Path .\scripts\chocolatey\algokit\tools\VERIFICATION.txt 33 | $wheel_url = "https://github.com/algorandfoundation/algokit-cli/releases/download/${release_tag}/${wheelFileName}" 34 | $sha_256 = Get-FileHash $wheelFile 35 | (Get-Content $verification).replace("{wheel_url}", $wheel_url).replace("{sha256}", $sha_256.Hash) | Set-Content $verification 36 | 37 | echo "version=$version" | Tee-Object -Append -FilePath $env:GITHUB_OUTPUT 38 | echo "wheelFileName=${wheelFileName}" | Tee-Object -Append -FilePath $env:GITHUB_OUTPUT 39 | -------------------------------------------------------------------------------- /scripts/utilities.psm1: -------------------------------------------------------------------------------- 1 | # https://github.com/MRCollective/repave.psm1/blob/master/repave.psm1 2 | Function Test-Administrator() { 3 | $user = [Security.Principal.WindowsIdentity]::GetCurrent(); 4 | return (New-Object Security.Principal.WindowsPrincipal $user).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) 5 | } 6 | 7 | Function Test-ThrowIfNotSuccessful() { 8 | if ($LASTEXITCODE -ne 0) { 9 | throw "Error executing last command" 10 | } 11 | } 12 | 13 | Function Write-Header([string] $title) { 14 | Write-Host 15 | Write-Host "#########################" 16 | Write-Host "### $title" 17 | Write-Host "#########################" 18 | } 19 | 20 | Function Remove-Folder([string] $foldername) { 21 | if (Test-Path $foldername) { 22 | Invoke-VerboseCommand { 23 | Remove-Item -LiteralPath $foldername -Force -Recurse 24 | } 25 | } 26 | } 27 | 28 | Function Invoke-VerboseCommand { 29 | [CmdletBinding()] 30 | param ( 31 | [Parameter(Mandatory = $true)] 32 | [scriptblock] 33 | $ScriptBlock 34 | ) 35 | Begin { 36 | Write-Verbose "Running $Scriptblock" 37 | } 38 | Process { 39 | & $ScriptBlock 40 | } 41 | End { 42 | Write-Verbose "Completed running $Scriptblock" 43 | } 44 | } 45 | 46 | Function Invoke-Confirm { 47 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', scope = 'function')] 48 | [CmdletBinding()] 49 | param ( 50 | [Parameter(Mandatory = $true)] 51 | [string]$Description, 52 | [Parameter(Mandatory = $true)] 53 | [System.Management.Automation.Cmdlet]$ParentPSCmdlet, 54 | [Parameter(Mandatory = $true)] 55 | [scriptblock] 56 | $ScriptBlock 57 | ) 58 | Process { 59 | if ($Force -or $ParentPSCmdlet.ShouldProcess($Description)) { 60 | $ConfirmPreference = 'None' 61 | Write-Header $Description 62 | . $ScriptBlock 63 | } 64 | } 65 | } 66 | 67 | # https://github.com/MRCollective/repave.psm1/blob/master/repave.psm1 68 | function Install-Chocolatey() { 69 | try { 70 | (Invoke-Expression "choco list -lo") -Replace "^Reading environment variables.+$", "" | Set-Variable -Name "installedPackages" -Scope Global 71 | Write-Output "choco already installed with the following packages:`r`n" 72 | Write-Output $global:installedPackages 73 | Write-Output "`r`n" 74 | } 75 | catch { 76 | 77 | # Ensure running as admin 78 | Write-Header "Ensuring we are running as admin" 79 | if ($IsWindows -and -not (Test-Administrator)) { 80 | Write-Error "Re-run as Administrator`r`n" 81 | exit 1 82 | } 83 | 84 | Write-Output "Installing Chocolatey`r`n" 85 | Invoke-Expression ((new-object net.webclient).DownloadString('https://chocolatey.org/install.ps1')) 86 | [Environment]::SetEnvironmentVariable("Path", $env:Path + ";c:\programdata\chocolatey\bin", "Process") 87 | Write-Warning "If the next command fails then restart powershell and run the script again to update the path variables properly`r`n" 88 | } 89 | } 90 | 91 | # https://github.com/MRCollective/repave.psm1/blob/master/repave.psm1 92 | function Install-ChocolateyPackage { 93 | [CmdletBinding()] 94 | Param ( 95 | [String] $PackageName, 96 | [String] $InstallArgs, 97 | $RunIfInstalled 98 | ) 99 | 100 | if ($global:installedPackages -match "^$PackageName \d") { 101 | Write-Output "$PackageName already installed`r`n" 102 | } 103 | else { 104 | 105 | # Ensure running as admin 106 | Write-Header "Ensuring we are running as admin" 107 | if ($IsWindows -and -not (Test-Administrator)) { 108 | Write-Error "Re-run as Administrator`r`n" 109 | exit 1 110 | } 111 | 112 | Invoke-Confirm "Install $PackageName from Chocolatey" $PSCmdlet { 113 | if ($null -ne $InstallArgs -and "" -ne $InstallArgs) { 114 | Write-Output "choco install -y $PackageName -InstallArguments ""$InstallArgs""`r`n" 115 | Invoke-Expression "choco install -y $PackageName -InstallArguments ""$InstallArgs""" | Out-Default 116 | } 117 | else { 118 | Write-Output "choco install -y $PackageName`r`n" 119 | Invoke-Expression "choco install -y $PackageName" | Out-Default 120 | } 121 | 122 | $env:ChocolateyInstall = Convert-Path "$((Get-Command choco).Path)\..\.." 123 | Import-Module "$env:ChocolateyInstall\helpers\chocolateyProfile.psm1" 124 | Update-SessionEnvironment 125 | } 126 | 127 | if ($null -ne $RunIfInstalled) { 128 | &$RunIfInstalled 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/algorun/__init__.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | 4 | # this isn't beautiful, but to avoid confusing user errors we need this check before we start importing our own modules 5 | if sys.version_info < (3, 10, 0): 6 | print( # noqa: T201 7 | f"Unsupported CPython version: {platform.python_version()} detected.\n" 8 | "The minimum version of Python supported is CPython 3.10.\n" 9 | "If you need help installing then this is a good starting point: \n" 10 | "https://www.python.org/about/gettingstarted/", 11 | file=sys.stderr, 12 | ) 13 | sys.exit(-1) 14 | 15 | try: 16 | from algorun.core.log_handlers import initialise_logging, uncaught_exception_logging_handler 17 | except ImportError as ex: 18 | # the above should succeed both in importing "algorun" itself, and we also know that "click" will 19 | # be imported too, if those basic packages aren't present, something is very wrong 20 | print( # noqa: T201 21 | f"{ex}\nUnable to import require package(s), your install may be broken :(", 22 | file=sys.stderr, 23 | ) 24 | sys.exit(-1) 25 | 26 | 27 | initialise_logging() 28 | sys.excepthook = uncaught_exception_logging_handler 29 | 30 | 31 | if __name__ == "__main__": 32 | from algorun.cli import algorun 33 | 34 | algorun() 35 | -------------------------------------------------------------------------------- /src/algorun/__main__.py: -------------------------------------------------------------------------------- 1 | from algorun.cli import algorun 2 | 3 | algorun() 4 | -------------------------------------------------------------------------------- /src/algorun/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from algorun.cli.goal import goal_command 4 | from algorun.cli.start import start_command 5 | from algorun.cli.stop import stop_command 6 | from algorun.core.conf import PACKAGE_NAME 7 | from algorun.core.log_handlers import color_option, verbose_option 8 | from algorun.core.version_prompt import do_version_prompt, skip_version_check_option 9 | 10 | 11 | @click.group( 12 | context_settings={ 13 | "help_option_names": ["-h", "--help"], 14 | "max_content_width": 120, 15 | }, 16 | ) 17 | @click.version_option(package_name=PACKAGE_NAME) 18 | @verbose_option 19 | @color_option 20 | @skip_version_check_option 21 | def algorun(*, skip_version_check: bool) -> None: 22 | """ 23 | ########################################\n 24 | ### ALGORUN ###\n 25 | ######################################## 26 | 27 | Welcome to Algorun, your cli to run an Algorand mainnet node 28 | """ 29 | 30 | if not skip_version_check: 31 | do_version_prompt() 32 | 33 | 34 | algorun.add_command(start_command) 35 | algorun.add_command(stop_command) 36 | algorun.add_command(goal_command) 37 | -------------------------------------------------------------------------------- /src/algorun/cli/config.py: -------------------------------------------------------------------------------- 1 | import click 2 | from algorun.core.version_prompt import version_prompt_configuration_command 3 | 4 | 5 | @click.group("config", short_help="Configure algorun settings.") 6 | def config_group() -> None: 7 | """Configure settings used by algorun""" 8 | 9 | 10 | config_group.add_command(version_prompt_configuration_command) 11 | -------------------------------------------------------------------------------- /src/algorun/cli/goal.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | 5 | from algorun.core import proc 6 | from algorun.core.sandbox import ComposeSandbox 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @click.command( 12 | "goal", 13 | short_help="Run the Algorand goal CLI against your mainnet node.", 14 | context_settings={ 15 | "ignore_unknown_options": True, 16 | }, 17 | ) 18 | @click.option( 19 | "--console", 20 | is_flag=True, 21 | help="Open a Bash console so you can execute multiple goal commands and/or interact with a filesystem.", 22 | default=False, 23 | ) 24 | @click.argument("goal_args", nargs=-1, type=click.UNPROCESSED) 25 | def goal_command(*, console: bool, goal_args: list[str]) -> None: 26 | """ 27 | Run the Algorand goal CLI against the mainnet node. 28 | 29 | Look at https://developer.algorand.org/docs/clis/goal/goal/ for more information. 30 | """ 31 | try: 32 | proc.run(["docker", "version"], bad_return_code_error_message="Docker engine isn't running; please start it.") 33 | except OSError as ex: 34 | # an IOError (such as PermissionError or FileNotFoundError) will only occur if "docker" 35 | # isn't an executable in the user's path, which means docker isn't installed 36 | raise click.ClickException( 37 | "Docker not found; please install Docker and add to path.\n" 38 | "See https://docs.docker.com/get-docker/ for more information." 39 | ) from ex 40 | if console: 41 | if goal_args: 42 | logger.warning("--console opens an interactive shell, remaining arguments are being ignored") 43 | logger.info("Opening Bash console on the algod node; execute `exit` to return to original console") 44 | result = proc.run_interactive("docker exec -it -w /root mainnet-container bash".split()) 45 | else: 46 | cmd = "docker exec --interactive mainnet-container goal".split() 47 | cmd.extend(goal_args) 48 | result = proc.run( 49 | cmd, 50 | stdout_log_level=logging.INFO, 51 | prefix_process=False, 52 | pass_stdin=True, 53 | ) 54 | if result.exit_code != 0: 55 | sandbox = ComposeSandbox() 56 | ps_result = sandbox.ps("mainnet-container") 57 | match ps_result: 58 | case [{"State": "running"}]: 59 | pass # container is running, failure must have been with command 60 | case _: 61 | logger.warning( 62 | "mainnet-container does not appear to be running, " 63 | "ensure mainnet node is started by executing `algorun start`" 64 | ) 65 | raise click.exceptions.Exit(result.exit_code) # pass on the exit code 66 | -------------------------------------------------------------------------------- /src/algorun/cli/reset.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | 5 | from algorun.core.sandbox import ( 6 | ComposeFileStatus, 7 | ComposeSandbox, 8 | ) 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @click.command("reset", short_help="Reset your Algorand mainnet node") 14 | @click.option( 15 | "--update/--no-update", 16 | default=False, 17 | help="Enable or disable updating to the latest available Algod version, default: don't update", 18 | ) 19 | def reset_command(*, update: bool) -> None: 20 | sandbox = ComposeSandbox() 21 | compose_file_status = sandbox.compose_file_status() 22 | if compose_file_status is ComposeFileStatus.MISSING: 23 | logger.debug("Existing node not found; creating from scratch...") 24 | sandbox.write_compose_file() 25 | else: 26 | sandbox.down() 27 | if compose_file_status is not ComposeFileStatus.UP_TO_DATE: 28 | logger.info("Node definition is out of date; updating it to latest") 29 | sandbox.write_compose_file() 30 | if update: 31 | sandbox.pull() 32 | sandbox.up() 33 | -------------------------------------------------------------------------------- /src/algorun/cli/start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | from algorun.core import proc 5 | from algorun.core.sandbox import ( 6 | DOCKER_COMPOSE_MINIMUM_VERSION, 7 | DOCKER_COMPOSE_VERSION_COMMAND, 8 | ComposeFileStatus, 9 | ComposeSandbox, 10 | ) 11 | from algorun.core.utils import extract_version_triple, is_minimum_version 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @click.command("start", short_help="Start your Algorand mainnet node") 17 | def start_command() -> None: 18 | try: 19 | compose_version_result = proc.run(DOCKER_COMPOSE_VERSION_COMMAND) 20 | except OSError as ex: 21 | # an IOError (such as PermissionError or FileNotFoundError) will only occur if "docker" 22 | # isn't an executable in the user's path, which means docker isn't installed 23 | raise click.ClickException( 24 | "Docker not found; please install Docker and add to path.\n" 25 | "See https://docs.docker.com/get-docker/ for more information." 26 | ) from ex 27 | if compose_version_result.exit_code != 0: 28 | raise click.ClickException( 29 | "Docker Compose not found; please install Docker Compose and add to path.\n" 30 | "See https://docs.docker.com/compose/install/ for more information." 31 | ) 32 | 33 | try: 34 | compose_version_str = extract_version_triple(compose_version_result.output) 35 | compose_version_ok = is_minimum_version(compose_version_str, DOCKER_COMPOSE_MINIMUM_VERSION) 36 | except Exception: 37 | logger.warning( 38 | "Unable to extract docker compose version from output: \n" 39 | + compose_version_result.output 40 | + f"\nPlease ensure a minimum of compose v{DOCKER_COMPOSE_MINIMUM_VERSION} is used", 41 | exc_info=True, 42 | ) 43 | else: 44 | if not compose_version_ok: 45 | raise click.ClickException( 46 | f"Minimum docker compose version supported: v{DOCKER_COMPOSE_MINIMUM_VERSION}, " 47 | f"installed = v{compose_version_str}\n" 48 | "Please update your Docker install" 49 | ) 50 | 51 | proc.run(["docker", "version"], bad_return_code_error_message="Docker engine isn't running; please start it.") 52 | sandbox = ComposeSandbox() 53 | compose_file_status = sandbox.compose_file_status() 54 | if compose_file_status is ComposeFileStatus.MISSING: 55 | logger.debug("Docker compose file does not exist yet; writing it out for the first time") 56 | sandbox.write_compose_file() 57 | elif compose_file_status is ComposeFileStatus.UP_TO_DATE: 58 | logger.debug("Docker compose file does not require updating") 59 | else: 60 | logger.warning("Docker definition is out of date; please run algorun localnet reset") 61 | sandbox.up() 62 | -------------------------------------------------------------------------------- /src/algorun/cli/stop.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | 5 | from algorun.core.sandbox import ( 6 | ComposeFileStatus, 7 | ComposeSandbox, 8 | ) 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @click.command("stop", short_help="Stop your Algorand mainnet node") 14 | def stop_command() -> None: 15 | sandbox = ComposeSandbox() 16 | compose_file_status = sandbox.compose_file_status() 17 | if compose_file_status is ComposeFileStatus.MISSING: 18 | logger.debug("Docker compose file does not exist yet; run `algorun start` to start the maiinnet node") 19 | else: 20 | sandbox.stop() 21 | -------------------------------------------------------------------------------- /src/algorun/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algorandfoundation/algorun/bd1ad2cc3b441b7804eccc07fc6f07ba83623c83/src/algorun/core/__init__.py -------------------------------------------------------------------------------- /src/algorun/core/atomic_write.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import shutil 4 | import stat 5 | from pathlib import Path 6 | from typing import Literal 7 | 8 | 9 | def atomic_write(file_contents: str, target_file_path: Path, mode: Literal["a", "w"] = "w") -> None: 10 | # if target path is a symlink, we want to use the real path as the replacement target, 11 | # otherwise we'd just be overwriting the symlink 12 | target_file_path = target_file_path.resolve() 13 | temp_file_path = target_file_path.with_suffix(f"{target_file_path.suffix}.algorun~") 14 | try: 15 | # preserve file metadata if it already exists 16 | with contextlib.suppress(FileNotFoundError): 17 | _copy_with_metadata(target_file_path, temp_file_path) 18 | # write content to new temp file 19 | with temp_file_path.open(mode=mode, encoding="utf-8") as fp: 20 | fp.write(file_contents) 21 | # overwrite destination with the temp file 22 | temp_file_path.replace(target_file_path) 23 | finally: 24 | temp_file_path.unlink(missing_ok=True) 25 | 26 | 27 | def _copy_with_metadata(source: Path, target: Path) -> None: 28 | # copy content, stat-info (mode too), timestamps... 29 | shutil.copy2(source, target) 30 | # try copy owner+group if platform supports it 31 | if hasattr(os, "chown"): 32 | # copy owner and group 33 | st = source.stat() 34 | os.chown(target, st[stat.ST_UID], st[stat.ST_GID]) 35 | -------------------------------------------------------------------------------- /src/algorun/core/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | from importlib import metadata 4 | from pathlib import Path 5 | 6 | PACKAGE_NAME = "algorun" 7 | 8 | 9 | def get_app_config_dir() -> Path: 10 | """Get the application config files location - things that should persist, and potentially follow a user""" 11 | config_dir = os.getcwd() 12 | return Path(config_dir) 13 | 14 | 15 | def get_app_state_dir() -> Path: 16 | """Get the application state files location - things the user wouldn't normally interact with directly""" 17 | os_type = platform.system().lower() 18 | if os_type == "windows": 19 | state_dir = os.getenv("LOCALAPPDATA") 20 | elif os_type == "darwin": 21 | state_dir = "~/Library/Application Support" 22 | else: 23 | state_dir = os.getenv("XDG_STATE_HOME") 24 | if state_dir is None: 25 | state_dir = "~/.local/state" 26 | return _get_relative_app_path(state_dir) 27 | 28 | 29 | def _get_relative_app_path(base_dir: str) -> Path: 30 | path = Path(base_dir).expanduser() 31 | result = path / PACKAGE_NAME 32 | result.mkdir(parents=True, exist_ok=True) 33 | # resolve path in case of UWP sandbox redirection 34 | return result.resolve() 35 | 36 | 37 | def get_current_package_version() -> str: 38 | return metadata.version(PACKAGE_NAME) 39 | -------------------------------------------------------------------------------- /src/algorun/core/doctor.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import logging 3 | import re 4 | import traceback 5 | from shutil import which 6 | 7 | from algorun.core import proc 8 | from algorun.core.utils import extract_version_triple, is_minimum_version 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @dataclasses.dataclass 14 | class DoctorResult: 15 | ok: bool 16 | output: str 17 | extra_help: list[str] | None = None 18 | 19 | 20 | def check_dependency( 21 | cmd: list[str], 22 | *, 23 | missing_help: list[str] | None = None, 24 | include_location: bool = False, 25 | minimum_version: str | None = None, 26 | minimum_version_help: list[str] | None = None, 27 | ) -> DoctorResult: 28 | """Check a dependency by running a command. 29 | 30 | :param cmd: command to run 31 | :param missing_help: Optional additional text to display if command is not found 32 | :param include_location: Include the path to `command` in the output?` 33 | :param minimum_version: Optional value to check minimum version against. 34 | :param minimum_version_help: Custom help output if minimum version not met. 35 | """ 36 | result = _run_command(cmd, missing_help=missing_help) 37 | if result.ok: 38 | result = _process_version( 39 | run_output=result.output, 40 | minimum_version=minimum_version, 41 | minimum_version_help=minimum_version_help, 42 | ) 43 | if include_location: 44 | try: 45 | location = which(cmd[0]) 46 | except Exception as ex: 47 | logger.debug(f"Failed to locate {cmd[0]}: {ex}", exc_info=True) 48 | result.output += "f (location: unknown)" 49 | else: 50 | result.output += f" (location: {location})" 51 | return result 52 | 53 | 54 | def _run_command( 55 | cmd: list[str], 56 | *, 57 | missing_help: list[str] | None = None, 58 | ) -> DoctorResult: 59 | try: 60 | proc_result = proc.run(cmd) 61 | except FileNotFoundError: 62 | logger.debug("Command not found", exc_info=True) 63 | return DoctorResult(ok=False, output="Command not found!", extra_help=missing_help) 64 | except PermissionError: 65 | logger.debug("Permission denied running command", exc_info=True) 66 | return DoctorResult(ok=False, output="Permission denied attempting to run command") 67 | except Exception as ex: 68 | logger.debug(f"Unexpected exception running command: {ex}", exc_info=True) 69 | return DoctorResult( 70 | ok=False, 71 | output="Unexpected error running command", 72 | extra_help=_format_exception_only(ex), 73 | ) 74 | else: 75 | if proc_result.exit_code != 0: 76 | return DoctorResult( 77 | ok=False, 78 | output=f"Command exited with code: {proc_result.exit_code}", 79 | extra_help=proc_result.output.splitlines(), 80 | ) 81 | return DoctorResult(ok=True, output=proc_result.output) 82 | 83 | 84 | def _process_version( 85 | *, 86 | run_output: str, 87 | minimum_version: str | None, 88 | minimum_version_help: list[str] | None, 89 | ) -> DoctorResult: 90 | try: 91 | version_output = _get_version_or_first_non_blank_line(run_output) 92 | except Exception as ex: 93 | logger.debug(f"Unexpected error checking dependency: {ex}", exc_info=True) 94 | return DoctorResult( 95 | ok=False, 96 | output="Unexpected error checking dependency", 97 | extra_help=_format_exception_only(ex), 98 | ) 99 | if minimum_version is not None: 100 | try: 101 | version_triple = extract_version_triple(version_output) 102 | version_ok = is_minimum_version(version_triple, minimum_version) 103 | except Exception as ex: 104 | logger.debug(f"Unexpected error parsing version: {ex}", exc_info=True) 105 | return DoctorResult( 106 | ok=False, 107 | output=version_output, 108 | extra_help=[ 109 | f'Failed to parse version from: "{version_output}"', 110 | f"Error: {ex}", 111 | f"Unable to check against minimum version of {minimum_version}", 112 | ], 113 | ) 114 | if not version_ok: 115 | return DoctorResult( 116 | ok=False, 117 | output=version_output, 118 | extra_help=(minimum_version_help or [f"Minimum version required: {minimum_version}"]), 119 | ) 120 | return DoctorResult(ok=True, output=version_output) 121 | 122 | 123 | def _get_version_or_first_non_blank_line(output: str) -> str: 124 | match = re.search(r"\d+\.\d+\.\d+[^\s'\"(),]*", output) 125 | if match: 126 | return match.group() 127 | lines = output.splitlines() 128 | non_blank_lines = filter(None, (ln.strip() for ln in lines)) 129 | # return first non-blank line or empty string if all blank 130 | return next(non_blank_lines, "") 131 | 132 | 133 | def _format_exception_only(ex: Exception) -> list[str]: 134 | return [ln.rstrip("\n") for ln in traceback.format_exception_only(type(ex), ex)] 135 | -------------------------------------------------------------------------------- /src/algorun/core/init.py: -------------------------------------------------------------------------------- 1 | from copier.main import MISSING, AnswersMap, Question, Worker # type: ignore[import] 2 | 3 | 4 | def populate_default_answers(worker: Worker) -> None: 5 | """Helper function to pre-populate Worker.data with default answers, based on Worker.answers implementation (see 6 | https://github.com/copier-org/copier/blob/v7.1.0/copier/main.py#L363). 7 | 8 | Used as a work-around for the behaviour of Worker(default=True, ...) which in >=7.1 raises an error instead of 9 | prompting if no default is provided""" 10 | answers = AnswersMap( 11 | default=worker.template.default_answers, 12 | user_defaults=worker.user_defaults, 13 | init=worker.data, 14 | last=worker.subproject.last_answers, 15 | metadata=worker.template.metadata, 16 | ) 17 | 18 | for var_name, details in worker.template.questions_data.items(): 19 | if var_name in worker.data: 20 | continue 21 | question = Question( 22 | answers=answers, 23 | jinja_env=worker.jinja_env, 24 | var_name=var_name, 25 | **details, 26 | ) 27 | default_value = question.get_default() 28 | if default_value is not MISSING: 29 | worker.data[var_name] = default_value 30 | -------------------------------------------------------------------------------- /src/algorun/core/log_handlers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | from logging.handlers import RotatingFileHandler 5 | from types import TracebackType 6 | from typing import Any 7 | 8 | import click 9 | from click.globals import resolve_color_default 10 | 11 | from .conf import get_app_state_dir 12 | 13 | __all__ = [ 14 | "initialise_logging", 15 | "color_option", 16 | "verbose_option", 17 | "uncaught_exception_logging_handler", 18 | "EXTRA_EXCLUDE_FROM_CONSOLE", 19 | "EXTRA_EXCLUDE_FROM_LOGFILE", 20 | ] 21 | 22 | 23 | class ClickHandler(logging.Handler): 24 | """Handle console output with click.echo(...) 25 | 26 | Slightly special in that this class acts as both a sink and an additional formatter, 27 | but they're kind of intertwined for our use case of actually displaying things to the user. 28 | """ 29 | 30 | styles: dict[str, dict[str, Any]] = { 31 | "critical": {"fg": "red", "bold": True}, 32 | "error": {"fg": "red"}, 33 | "warning": {"fg": "yellow"}, 34 | "debug": {"fg": "cyan"}, 35 | } 36 | 37 | def emit(self, record: logging.LogRecord) -> None: 38 | try: 39 | msg = self.format(record) 40 | level = record.levelname.lower() 41 | if level in self.styles: 42 | # if user hasn't disabled colors/styling, just use that 43 | if resolve_color_default() is not False: 44 | level_style = self.styles[level] 45 | msg = click.style(msg, **level_style) 46 | # otherwise, prefix the level name 47 | else: 48 | msg = f"{level.upper()}: {msg}" 49 | click.echo(msg) 50 | except Exception: 51 | self.handleError(record) 52 | 53 | 54 | class NoExceptionFormatter(logging.Formatter): 55 | """Prevent automatically displaying exception/traceback info. 56 | (without interfering with other formatters that might later want to add such information) 57 | """ 58 | 59 | def formatException(self, *_args: Any) -> str: # noqa: N802 60 | return "" 61 | 62 | def formatStack(self, *_args: Any) -> str: # noqa: N802 63 | return "" 64 | 65 | 66 | CONSOLE_LOG_HANDLER_NAME = "console_log_handler" 67 | 68 | EXCLUDE_FROM_KEY = "exclude_from" 69 | EXCLUDE_FROM_CONSOLE_VALUE = "console" 70 | EXCLUDE_FROM_LOGFILE_VALUE = "logfile" 71 | 72 | EXTRA_EXCLUDE_FROM_CONSOLE = {EXCLUDE_FROM_KEY: EXCLUDE_FROM_CONSOLE_VALUE} 73 | EXTRA_EXCLUDE_FROM_LOGFILE = {EXCLUDE_FROM_KEY: EXCLUDE_FROM_LOGFILE_VALUE} 74 | 75 | 76 | class ManualExclusionFilter(logging.Filter): 77 | def __init__(self, exclude_value: str): 78 | super().__init__() 79 | self.exclude_value = exclude_value 80 | 81 | def filter(self, record: logging.LogRecord) -> bool: # noqa: A003 82 | return getattr(record, EXCLUDE_FROM_KEY, None) != self.exclude_value 83 | 84 | 85 | def initialise_logging() -> None: 86 | console_log_handler = ClickHandler() 87 | # default to INFO, this case be upgraded later based on -v flag 88 | console_log_handler.setLevel(logging.INFO) 89 | console_log_handler.name = CONSOLE_LOG_HANDLER_NAME 90 | console_log_handler.formatter = NoExceptionFormatter() 91 | console_log_handler.addFilter(ManualExclusionFilter(exclude_value=EXCLUDE_FROM_CONSOLE_VALUE)) 92 | 93 | file_log_handler = RotatingFileHandler( 94 | filename=get_app_state_dir() / "cli.log", 95 | maxBytes=1 * 1024 * 1024, 96 | backupCount=5, 97 | encoding="utf-8", 98 | ) 99 | file_log_handler.setLevel(logging.DEBUG) 100 | file_log_handler.formatter = logging.Formatter( 101 | "%(asctime)s.%(msecs)03d %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S" 102 | ) 103 | file_log_handler.addFilter(ManualExclusionFilter(exclude_value=EXCLUDE_FROM_LOGFILE_VALUE)) 104 | 105 | logging.basicConfig(level=logging.DEBUG, handlers=[console_log_handler, file_log_handler], force=True) 106 | 107 | 108 | def uncaught_exception_logging_handler( 109 | exc_type: type[BaseException], exc_value: BaseException, exc_traceback: TracebackType | None 110 | ) -> None: 111 | """Function to be used as sys.excepthook, which logs uncaught exceptions.""" 112 | if issubclass(exc_type, KeyboardInterrupt): 113 | # don't log ctrl-c or equivalents 114 | sys.__excepthook__(exc_type, exc_value, exc_traceback) 115 | else: 116 | logging.critical(f"Unhandled {exc_type.__name__}: {exc_value}", exc_info=(exc_type, exc_value, exc_traceback)) 117 | 118 | 119 | def _set_verbose(_ctx: click.Context, _param: click.Option, value: bool) -> None: # noqa: FBT001 120 | if value: 121 | for handler in logging.getLogger().handlers: 122 | if handler.name == CONSOLE_LOG_HANDLER_NAME: 123 | handler.setLevel(logging.DEBUG) 124 | return 125 | raise RuntimeError(f"Couldn't locate required logger named {CONSOLE_LOG_HANDLER_NAME}") 126 | 127 | 128 | def _set_force_styles_to(ctx: click.Context, _param: click.Option, value: bool | None) -> None: 129 | if value is not None: 130 | ctx.color = value 131 | 132 | 133 | verbose_option = click.option( 134 | "--verbose", 135 | "-v", 136 | is_flag=True, 137 | callback=_set_verbose, 138 | expose_value=False, 139 | help="Enable logging of DEBUG messages to the console.", 140 | ) 141 | 142 | color_option = click.option( 143 | "--color/--no-color", 144 | # support NO_COLOR (ref: https://no-color.org) env var as default value, 145 | default=lambda: False if os.getenv("NO_COLOR") else None, 146 | callback=_set_force_styles_to, 147 | expose_value=False, 148 | help="Force enable or disable of console output styling.", 149 | ) 150 | -------------------------------------------------------------------------------- /src/algorun/core/proc.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import logging 3 | import subprocess 4 | import sys 5 | from pathlib import Path 6 | from subprocess import Popen 7 | from subprocess import run as subprocess_run 8 | 9 | import click 10 | 11 | from algorun.core.log_handlers import EXTRA_EXCLUDE_FROM_CONSOLE 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @dataclasses.dataclass 17 | class RunResult: 18 | command: str 19 | exit_code: int 20 | output: str 21 | 22 | 23 | def run( 24 | command: list[str], 25 | *, 26 | cwd: Path | None = None, 27 | env: dict[str, str] | None = None, 28 | bad_return_code_error_message: str | None = None, 29 | prefix_process: bool = True, 30 | stdout_log_level: int = logging.DEBUG, 31 | pass_stdin: bool = False, 32 | ) -> RunResult: 33 | """Wraps subprocess.Popen() similarly to subprocess.run() but adds: logging and streaming (unicode) I/O capture 34 | 35 | Note that not all options or usage scenarios here are covered, just some common use cases 36 | """ 37 | command_str = " ".join(command) 38 | logger.debug(f"Running '{command_str}' in '{cwd or Path.cwd()}'") 39 | 40 | lines = [] 41 | exit_code = None 42 | with Popen( 43 | command, 44 | stdout=subprocess.PIPE, # capture stdout 45 | stderr=subprocess.STDOUT, # redirect stderr to stdout, so they're interleaved in the correct ordering 46 | stdin=sys.stdin if pass_stdin else None, 47 | text=True, # make all I/O in unicode/text 48 | cwd=cwd, 49 | env=env, 50 | bufsize=1, # line buffering, works because text=True 51 | ) as proc: 52 | assert proc.stdout # type narrowing 53 | while exit_code is None: 54 | line = proc.stdout.readline() 55 | if not line: 56 | # only poll if no output, so that we consume entire output stream 57 | exit_code = proc.poll() 58 | else: 59 | lines.append(line) 60 | logger.log( 61 | level=stdout_log_level, 62 | msg=(click.style(f"{command[0]}:", bold=True) if prefix_process else "") + f" {line.strip()}", 63 | ) 64 | if exit_code == 0: # type: ignore[unreachable] 65 | logger.debug(f"'{command_str}' completed successfully", extra=EXTRA_EXCLUDE_FROM_CONSOLE) 66 | else: 67 | logger.debug(f"'{command_str}' failed, exited with code = {exit_code}", extra=EXTRA_EXCLUDE_FROM_CONSOLE) 68 | if bad_return_code_error_message: 69 | raise click.ClickException(bad_return_code_error_message) 70 | output = "".join(lines) 71 | return RunResult(command=command_str, exit_code=exit_code, output=output) 72 | 73 | 74 | def run_interactive( 75 | command: list[str], 76 | *, 77 | cwd: Path | None = None, 78 | env: dict[str, str] | None = None, 79 | bad_return_code_error_message: str | None = None, 80 | ) -> RunResult: 81 | """Wraps subprocess.run() as an user interactive session and 82 | also adds logging of the command being executed, but not the output 83 | 84 | Note that not all options or usage scenarios here are covered, just some common use cases 85 | """ 86 | command_str = " ".join(command) 87 | logger.debug(f"Running '{command_str}' in '{cwd or Path.cwd()}'") 88 | 89 | result = subprocess_run(command, cwd=cwd, env=env) 90 | 91 | if result.returncode == 0: 92 | logger.debug(f"'{command_str}' completed successfully", extra=EXTRA_EXCLUDE_FROM_CONSOLE) 93 | else: 94 | logger.debug( 95 | f"'{command_str}' failed, exited with code = {result.returncode}", extra=EXTRA_EXCLUDE_FROM_CONSOLE 96 | ) 97 | if bad_return_code_error_message: 98 | raise click.ClickException(bad_return_code_error_message) 99 | return RunResult(command=command_str, exit_code=result.returncode, output="") 100 | -------------------------------------------------------------------------------- /src/algorun/core/questionary_extensions.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Sequence 2 | from typing import Any 3 | 4 | import prompt_toolkit.document 5 | import questionary 6 | from questionary.prompts.common import build_validator 7 | 8 | 9 | class NonEmptyValidator(questionary.Validator): 10 | def validate(self, document: prompt_toolkit.document.Document) -> None: 11 | value = document.text.strip() 12 | if not value: 13 | raise questionary.ValidationError(message="Please enter a value") 14 | 15 | 16 | class ChainedValidator(questionary.Validator): 17 | def __init__(self, *validators: questionary.Validator): 18 | self._validators = validators 19 | 20 | def validate(self, document: prompt_toolkit.document.Document) -> None: 21 | for validator in self._validators: 22 | validator.validate(document) 23 | 24 | 25 | def prompt_confirm(message: str, *, default: bool) -> bool: 26 | # note: we use unsafe_ask here (and everywhere else) so we don't have to 27 | # handle None returns for KeyboardInterrupt - click will handle these nicely enough for us 28 | # at the root level 29 | result = questionary.confirm( 30 | message, 31 | default=default, 32 | ).unsafe_ask() 33 | assert isinstance(result, bool) 34 | return result 35 | 36 | 37 | def prompt_text( 38 | message: str, 39 | *, 40 | validators: Sequence[type[questionary.Validator] | questionary.Validator | Callable[[str], bool]] | None = None, 41 | validate_while_typing: bool = False, 42 | ) -> str: 43 | if validators: 44 | validate, *others = filter(None, map(build_validator, validators)) 45 | if others: 46 | validate = ChainedValidator(validate, *others) 47 | else: 48 | validate = None 49 | result = questionary.text( 50 | message, 51 | validate=validate, 52 | validate_while_typing=validate_while_typing, 53 | ).unsafe_ask() 54 | assert isinstance(result, str) 55 | return result 56 | 57 | 58 | def prompt_select( 59 | message: str, 60 | *choices: str | questionary.Choice, 61 | ) -> Any: # noqa: ANN401 62 | return questionary.select( 63 | message, 64 | choices=choices, 65 | ).unsafe_ask() 66 | -------------------------------------------------------------------------------- /src/algorun/core/sandbox.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import json 3 | import logging 4 | from pathlib import Path 5 | from typing import Any, cast 6 | 7 | from algorun.core.conf import get_app_config_dir 8 | from algorun.core.proc import RunResult, run, run_interactive 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | DOCKER_COMPOSE_MINIMUM_VERSION = "2.5.0" 14 | 15 | 16 | class ComposeFileStatus(enum.Enum): 17 | MISSING = enum.auto() 18 | UP_TO_DATE = enum.auto() 19 | OUT_OF_DATE = enum.auto() 20 | 21 | 22 | class ComposeSandbox: 23 | def __init__(self) -> None: 24 | self.directory = get_app_config_dir() 25 | if not self.directory.exists(): 26 | logger.debug("Node directory does not exist yet; creating it") 27 | self.directory.mkdir() 28 | self._latest_yaml = get_docker_compose_yml() 29 | self._latest_config_json = get_config_json() 30 | 31 | @property 32 | def compose_file_path(self) -> Path: 33 | return self.directory / "docker-compose.yml" 34 | 35 | @property 36 | def algod_config_file_path(self) -> Path: 37 | return self.directory / "config.json" 38 | 39 | def compose_file_status(self) -> ComposeFileStatus: 40 | try: 41 | compose_content = self.compose_file_path.read_text() 42 | config_content = self.algod_config_file_path.read_text() 43 | except FileNotFoundError: 44 | # treat as out of date if compose file exists but algod config doesn't 45 | # so that existing setups aren't suddenly reset 46 | if self.compose_file_path.exists(): 47 | return ComposeFileStatus.OUT_OF_DATE 48 | return ComposeFileStatus.MISSING 49 | else: 50 | if compose_content == self._latest_yaml and config_content == self._latest_config_json: 51 | return ComposeFileStatus.UP_TO_DATE 52 | else: 53 | return ComposeFileStatus.OUT_OF_DATE 54 | 55 | def write_compose_file(self) -> None: 56 | self.compose_file_path.write_text(self._latest_yaml) 57 | self.algod_config_file_path.write_text(self._latest_config_json) 58 | 59 | def _run_compose_command( 60 | self, 61 | compose_args: str, 62 | stdout_log_level: int = logging.INFO, 63 | bad_return_code_error_message: str | None = None, 64 | ) -> RunResult: 65 | return run( 66 | ["docker", *compose_args.split()], 67 | cwd=self.directory, 68 | stdout_log_level=stdout_log_level, 69 | bad_return_code_error_message=bad_return_code_error_message, 70 | ) 71 | 72 | def up(self) -> None: 73 | logger.info("Starting Algorand mainnet node now...") 74 | self._run_compose_command( 75 | "compose up -d", 76 | bad_return_code_error_message="Failed to start node", 77 | ) 78 | logger.info("Started; the node is now catching up to the latest ledger state") 79 | 80 | def stop(self) -> None: 81 | logger.info("Stopping Algorand mainnet node now...") 82 | self._run_compose_command("compose stop", bad_return_code_error_message="Failed to stop node") 83 | logger.info("Node Stopped; execute `algorun start` to start it again.") 84 | 85 | def down(self) -> None: 86 | logger.info("Deleting current node...") 87 | self._run_compose_command("down", stdout_log_level=logging.DEBUG) 88 | 89 | def pull(self) -> None: 90 | logger.info("Looking for latest algod images from DockerHub...") 91 | self._run_compose_command("pull --ignore-pull-failures --quiet") 92 | 93 | def logs(self, *, follow: bool = False, no_color: bool = False, tail: str | None = None) -> None: 94 | compose_args = ["logs"] 95 | if follow: 96 | compose_args += ["--follow"] 97 | if no_color: 98 | compose_args += ["--no-color"] 99 | if tail is not None: 100 | compose_args += ["--tail", tail] 101 | run_interactive( 102 | ["docker", "compose", *compose_args], 103 | cwd=self.directory, 104 | bad_return_code_error_message="Failed to get logs, are the containers running?", 105 | ) 106 | 107 | def ps(self, service_name: str | None = None) -> list[dict[str, Any]]: 108 | run_results = self._run_compose_command( 109 | f"ps {service_name or ''} --format json", stdout_log_level=logging.DEBUG 110 | ) 111 | if run_results.exit_code != 0: 112 | return [] 113 | data = json.loads(run_results.output) 114 | assert isinstance(data, list) 115 | return cast(list[dict[str, Any]], data) 116 | 117 | 118 | def get_config_json() -> str: 119 | return '{"Version":27, "MaxCatchpointDownloadDuration": 604800000000000}' 120 | 121 | 122 | def get_docker_compose_yml() -> str: 123 | return """version: '3' 124 | 125 | services: 126 | algod: 127 | container_name: mainnet-container 128 | image: algorand/algod:latest 129 | ports: 130 | - 4190:8080 131 | environment: 132 | - NETWORK=mainnet 133 | - FAST_CATCHUP=1 134 | - PROFILE=participation 135 | volumes: 136 | - ${PWD}/data:/algod/data/:rw 137 | - ${PWD}/config.json:/etc/algorand/config.json:rw 138 | 139 | networks: 140 | host: 141 | external: true 142 | """ 143 | 144 | 145 | DOCKER_COMPOSE_VERSION_COMMAND = ["docker", "compose", "version", "--format", "json"] 146 | -------------------------------------------------------------------------------- /src/algorun/core/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def extract_version_triple(version_str: str) -> str: 5 | match = re.search(r"\d+\.\d+\.\d+", version_str) 6 | if not match: 7 | raise ValueError("Unable to parse version number") 8 | return match.group() 9 | 10 | 11 | def is_minimum_version(system_version: str, minimum_version: str) -> bool: 12 | system_version_as_tuple = tuple(map(int, system_version.split("."))) 13 | minimum_version_as_tuple = tuple(map(int, minimum_version.split("."))) 14 | return system_version_as_tuple >= minimum_version_as_tuple 15 | -------------------------------------------------------------------------------- /src/algorun/core/version_prompt.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from datetime import timedelta 5 | from time import time 6 | 7 | import click 8 | import httpx 9 | 10 | from algorun.core.conf import get_app_config_dir, get_app_state_dir, get_current_package_version 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | LATEST_URL = "https://api.github.com/repos/algorandfoundation/algorun/releases/latest" 15 | VERSION_CHECK_INTERVAL = timedelta(weeks=1).total_seconds() 16 | DISABLE_CHECK_MARKER = "disable-version-prompt" 17 | 18 | 19 | def do_version_prompt() -> None: 20 | if _skip_version_prompt(): 21 | logger.debug("Version prompt disabled") 22 | return 23 | 24 | current_version = get_current_package_version() 25 | latest_version = get_latest_version_or_cached() 26 | if latest_version is None: 27 | logger.debug("Could not determine latest version") 28 | return 29 | 30 | if _get_version_sequence(current_version) < _get_version_sequence(latest_version): 31 | logger.info(f"You are using algorun version {current_version}, however version {latest_version} is available.") 32 | else: 33 | logger.debug("Current version is up to date") 34 | 35 | 36 | def _get_version_sequence(version: str) -> list[int | str]: 37 | match = re.match(r"(\d+)\.(\d+)\.(\d+)(.*)", version) 38 | if match: 39 | return [int(x) for x in match.groups()[:3]] + [match.group(4)] 40 | return [version] 41 | 42 | 43 | def get_latest_version_or_cached() -> str | None: 44 | version_check_path = get_app_state_dir() / "last-version-check" 45 | 46 | try: 47 | last_checked = os.path.getmtime(version_check_path) 48 | version = version_check_path.read_text(encoding="utf-8") 49 | except OSError: 50 | logger.debug(f"{version_check_path} inaccessible") 51 | last_checked = 0 52 | version = None 53 | else: 54 | logger.debug(f"{version} found in cache {version_check_path}") 55 | 56 | if (time() - last_checked) > VERSION_CHECK_INTERVAL: 57 | try: 58 | version = get_latest_github_version() 59 | except Exception as ex: 60 | logger.debug("Checking for latest version failed", exc_info=ex) 61 | # update last checked time even if check failed 62 | version_check_path.touch() 63 | else: 64 | version_check_path.write_text(version, encoding="utf-8") 65 | # handle case where the first check failed, so we have an empty file 66 | return version or None 67 | 68 | 69 | def get_latest_github_version() -> str: 70 | headers = {"ACCEPT": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} 71 | 72 | response = httpx.get(LATEST_URL, headers=headers) 73 | response.raise_for_status() 74 | 75 | json = response.json() 76 | tag_name = json["tag_name"] 77 | logger.debug(f"Latest version tag: {tag_name}") 78 | match = re.match(r"v(\d+\.\d+\.\d+)", tag_name) 79 | if not match: 80 | raise ValueError(f"Unable to extract version from tag_name: {tag_name}") 81 | return match.group(1) 82 | 83 | 84 | def _skip_version_prompt() -> bool: 85 | disable_marker = get_app_config_dir() / DISABLE_CHECK_MARKER 86 | return disable_marker.exists() 87 | 88 | 89 | skip_version_check_option = click.option( 90 | "--skip-version-check", 91 | is_flag=True, 92 | show_default=False, 93 | default=False, 94 | help="Skip version checking and prompting.", 95 | ) 96 | 97 | 98 | @click.command( 99 | "version-prompt", short_help="Enables or disables checking and prompting if a new version of algorun is available" 100 | ) 101 | @click.argument("enable", required=False, type=click.Choice(["enable", "disable"]), default=None) 102 | def version_prompt_configuration_command(*, enable: str | None) -> None: 103 | """Controls whether algorun checks and prompts for new versions. 104 | Set to [disable] to prevent algorun performing this check permanently, or [enable] to resume checking. 105 | If no argument is provided then outputs current setting. 106 | 107 | Also see --skip-version-check which can be used to disable check for a single command.""" 108 | if enable is None: 109 | logger.info("disable" if _skip_version_prompt() else "enable") 110 | else: 111 | disable_marker = get_app_config_dir() / DISABLE_CHECK_MARKER 112 | if enable == "enable": 113 | disable_marker.unlink(missing_ok=True) 114 | logger.info("📡 Resuming check for new versions") 115 | else: 116 | disable_marker.touch() 117 | logger.info("🚫 Will stop checking for new versions") 118 | -------------------------------------------------------------------------------- /src/algorun/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algorandfoundation/algorun/bd1ad2cc3b441b7804eccc07fc6f07ba83623c83/src/algorun/py.typed -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | {"Version":27, "MaxCatchpointDownloadDuration": 604800000000000} -------------------------------------------------------------------------------- /src/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | algod: 5 | container_name: mainnet-container 6 | image: algorand/algod:latest 7 | ports: 8 | - 4190:8080 9 | environment: 10 | - NETWORK=mainnet 11 | - FAST_CATCHUP=1 12 | - PROFILE=participation 13 | volumes: 14 | - ${PWD}/data:/algod/data/:rw 15 | - ${PWD}/config.json:/etc/algorand/config.json:rw 16 | 17 | networks: 18 | host: 19 | external: true 20 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | def get_combined_verify_output(stdout: str, additional_name: str, additional_output: str) -> str: 2 | """Simple way to get output combined from two sources so that approval testing still works""" 3 | return f"""{stdout}---- 4 | {additional_name}: 5 | ---- 6 | {additional_output}""" 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import logging 4 | import os 5 | import typing 6 | from collections.abc import Callable, Sequence # noqa: RUF100, TCH003 7 | from pathlib import Path 8 | 9 | import pytest 10 | import questionary 11 | from algorun.core import questionary_extensions 12 | from approvaltests import Reporter, reporters, set_default_reporter 13 | from approvaltests.reporters.generic_diff_reporter_config import create_config 14 | from approvaltests.reporters.generic_diff_reporter_factory import GenericDiffReporter 15 | from prompt_toolkit.application import create_app_session 16 | from prompt_toolkit.input import PipeInput, create_pipe_input 17 | from prompt_toolkit.output import DummyOutput 18 | from pytest_mock import MockerFixture 19 | 20 | from tests.utils.app_dir_mock import AppDirs, tmp_app_dir 21 | from tests.utils.proc_mock import ProcMock 22 | 23 | 24 | @pytest.fixture() 25 | def proc_mock(mocker: MockerFixture) -> ProcMock: 26 | proc_mock = ProcMock() 27 | # add a default for docker compose version 28 | proc_mock.set_output(["docker", "compose", "version", "--format", "json"], [json.dumps({"version": "v2.5.0"})]) 29 | mocker.patch("algorun.core.proc.Popen").side_effect = proc_mock.popen 30 | return proc_mock 31 | 32 | 33 | def _do_platform_mock(platform_system: str, monkeypatch: pytest.MonkeyPatch) -> None: 34 | import platform 35 | 36 | monkeypatch.setattr(platform, "system", lambda: platform_system) 37 | monkeypatch.setattr(platform, "platform", lambda: f"{platform_system}-other-system-info") 38 | 39 | 40 | @pytest.fixture( 41 | params=[ 42 | pytest.param("Windows", id="windows"), 43 | pytest.param("Linux", id="linux"), 44 | pytest.param("Darwin", id="macOS"), 45 | ] 46 | ) 47 | def mock_platform_system(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str: 48 | platform_system: str = request.param 49 | _do_platform_mock(platform_system=platform_system, monkeypatch=monkeypatch) 50 | return platform_system 51 | 52 | 53 | @pytest.fixture(autouse=True) 54 | def _mock_platform_system_marker(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: 55 | marker = request.node.get_closest_marker("mock_platform_system") 56 | if marker is not None: 57 | _do_platform_mock(platform_system=marker.args[0], monkeypatch=monkeypatch) 58 | 59 | 60 | @pytest.fixture() 61 | def app_dir_mock(mocker: MockerFixture, tmp_path: Path) -> AppDirs: 62 | return tmp_app_dir(mocker, tmp_path) 63 | 64 | 65 | @pytest.fixture() 66 | def mock_questionary_input() -> typing.Iterator[PipeInput]: 67 | with create_pipe_input() as pipe_input, create_app_session(input=pipe_input, output=DummyOutput()): 68 | yield pipe_input 69 | 70 | 71 | @pytest.fixture(autouse=True) 72 | def _supress_copier_dependencies_debug_output() -> None: 73 | logging.getLogger("plumbum.local").setLevel("INFO") 74 | logging.getLogger("asyncio").setLevel("INFO") 75 | 76 | 77 | Params = typing.ParamSpec("Params") 78 | Result = typing.TypeVar("Result") 79 | 80 | 81 | def intercept( 82 | f: typing.Callable[Params, Result], interceptor: typing.Callable[Params, None] 83 | ) -> typing.Callable[Params, Result]: 84 | @functools.wraps(f) 85 | def wrapped(*args: Params.args, **kwargs: Params.kwargs) -> Result: 86 | interceptor(*args, **kwargs) 87 | return f(*args, **kwargs) 88 | 89 | return wrapped 90 | 91 | 92 | @pytest.fixture(autouse=True) 93 | def _patch_questionary_prompts(monkeypatch: pytest.MonkeyPatch) -> None: 94 | ValidatorsType = Sequence[type[questionary.Validator] | questionary.Validator | Callable[[str], bool]] # noqa: N806 95 | 96 | def log_prompt_text( 97 | message: str, 98 | *, 99 | validators: ValidatorsType | None = None, # noqa: ARG001 100 | validate_while_typing: bool = False, # noqa: ARG001 101 | ) -> None: 102 | print(f"? {message}") # noqa: T201 103 | 104 | def log_prompt_select( 105 | message: str, 106 | *choices: str | questionary.Choice, 107 | ) -> None: 108 | print(f"? {message}") # noqa: T201 109 | for choice in choices: 110 | print( # noqa: T201 111 | (choice.value if choice.title is None else "".join([token[1] for token in choice.title])) 112 | if isinstance(choice, questionary.Choice) 113 | else choice 114 | ) 115 | 116 | def log_prompt_confirm(message: str, *, default: bool) -> None: 117 | if default: 118 | default_text = "(Y/n)" 119 | else: 120 | default_text = "(y/N)" 121 | print(f"? {message} {default_text}") # noqa: T201 122 | 123 | monkeypatch.setattr( 124 | questionary_extensions, 125 | "prompt_text", 126 | intercept(questionary_extensions.prompt_text, log_prompt_text), 127 | ) 128 | monkeypatch.setattr( 129 | questionary_extensions, 130 | "prompt_select", 131 | intercept(questionary_extensions.prompt_select, log_prompt_select), 132 | ) 133 | monkeypatch.setattr( 134 | questionary_extensions, 135 | "prompt_confirm", 136 | intercept(questionary_extensions.prompt_confirm, log_prompt_confirm), 137 | ) 138 | 139 | 140 | if os.getenv("CI"): 141 | set_default_reporter(reporters.PythonNativeReporter()) 142 | else: 143 | default_reporters: list[Reporter] = ( 144 | [ 145 | GenericDiffReporter( 146 | create_config( 147 | [ 148 | os.getenv("APPROVAL_REPORTER"), 149 | os.getenv("APPROVAL_REPORTER_PATH"), 150 | os.getenv("APPROVAL_REPORTER_ARGS", "").split(), 151 | ] 152 | ) 153 | ) 154 | ] 155 | if os.getenv("APPROVAL_REPORTER") 156 | else [] 157 | ) 158 | default_reporters += [ 159 | GenericDiffReporter(create_config(["kdiff3", "/usr/bin/kdiff3"])), 160 | GenericDiffReporter(create_config(["DiffMerge", "/Applications/DiffMerge.app/Contents/MacOS/DiffMerge"])), 161 | GenericDiffReporter(create_config(["TortoiseGit", "{ProgramFiles}\\TortoiseGit\\bin\\TortoiseGitMerge.exe"])), 162 | GenericDiffReporter(create_config(["VSCodeInsiders", "code-insiders", ["-d"]])), 163 | reporters.ReportWithBeyondCompare(), 164 | reporters.ReportWithWinMerge(), 165 | reporters.ReportWithVSCode(), 166 | reporters.PythonNativeReporter(), 167 | ] 168 | set_default_reporter(reporters.FirstWorkingReporter(*default_reporters)) 169 | -------------------------------------------------------------------------------- /tests/goal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algorandfoundation/algorun/bd1ad2cc3b441b7804eccc07fc6f07ba83623c83/tests/goal/__init__.py -------------------------------------------------------------------------------- /tests/goal/test_goal.py: -------------------------------------------------------------------------------- 1 | import json 2 | from subprocess import CompletedProcess 3 | 4 | import pytest 5 | from pytest_mock import MockerFixture 6 | 7 | from tests.utils.app_dir_mock import AppDirs 8 | from tests.utils.approvals import verify 9 | from tests.utils.click_invoker import invoke 10 | from tests.utils.proc_mock import ProcMock 11 | 12 | 13 | def test_goal_help() -> None: 14 | result = invoke("goal -h") 15 | 16 | assert result.exit_code == 0 17 | verify(result.output) 18 | 19 | 20 | @pytest.mark.usefixtures("proc_mock") 21 | def test_goal_no_args() -> None: 22 | result = invoke("goal") 23 | 24 | assert result.exit_code == 0 25 | verify(result.output) 26 | 27 | 28 | @pytest.mark.usefixtures("proc_mock") 29 | def test_goal_console(mocker: MockerFixture) -> None: 30 | mocker.patch("algorun.core.proc.subprocess_run").return_value = CompletedProcess( 31 | ["docker", "exec"], 0, "STDOUT+STDERR" 32 | ) 33 | 34 | result = invoke("goal --console") 35 | 36 | assert result.exit_code == 0 37 | verify(result.output) 38 | 39 | 40 | def test_goal_console_failed(app_dir_mock: AppDirs, proc_mock: ProcMock, mocker: MockerFixture) -> None: 41 | (app_dir_mock.app_config_dir / "sandbox").mkdir() 42 | 43 | mocker.patch("algorun.core.proc.subprocess_run").return_value = CompletedProcess( 44 | ["docker", "exec"], 1, "STDOUT+STDERR" 45 | ) 46 | 47 | proc_mock.set_output( 48 | ["docker", "compose", "ps", "algod", "--format", "json"], 49 | output=[json.dumps([{"Name": "algorun_algod", "State": "running"}])], 50 | ) 51 | 52 | result = invoke("goal --console") 53 | 54 | assert result.exit_code == 1 55 | verify(result.output.replace(str(app_dir_mock.app_config_dir), "{app_config}").replace("\\", "/")) 56 | 57 | 58 | def test_goal_console_failed_algod_not_created( 59 | app_dir_mock: AppDirs, proc_mock: ProcMock, mocker: MockerFixture 60 | ) -> None: 61 | (app_dir_mock.app_config_dir / "sandbox").mkdir() 62 | 63 | mocker.patch("algorun.core.proc.subprocess_run").return_value = CompletedProcess( 64 | ["docker", "exec"], 1, "bad args to goal" 65 | ) 66 | 67 | proc_mock.set_output(["docker", "compose", "ps", "algod", "--format", "json"], output=[json.dumps([])]) 68 | 69 | result = invoke("goal --console") 70 | 71 | assert result.exit_code == 1 72 | verify(result.output.replace(str(app_dir_mock.app_config_dir), "{app_config}").replace("\\", "/")) 73 | 74 | 75 | @pytest.mark.usefixtures("proc_mock") 76 | def test_goal_simple_args() -> None: 77 | result = invoke("goal account list") 78 | 79 | assert result.exit_code == 0 80 | verify(result.output) 81 | 82 | 83 | @pytest.mark.usefixtures("proc_mock") 84 | def test_goal_complex_args() -> None: 85 | result = invoke("goal account export -a RKTAZY2ZLKUJBHDVVA3KKHEDK7PRVGIGOZAUUIZBNK2OEP6KQGEXKKUYUY") 86 | 87 | assert result.exit_code == 0 88 | verify(result.output) 89 | 90 | 91 | def test_goal_start_without_docker(proc_mock: ProcMock) -> None: 92 | proc_mock.should_fail_on("docker version") 93 | 94 | result = invoke("goal") 95 | 96 | assert result.exit_code == 1 97 | verify(result.output) 98 | 99 | 100 | def test_goal_start_without_docker_engine_running(proc_mock: ProcMock) -> None: 101 | proc_mock.should_bad_exit_on("docker version") 102 | 103 | result = invoke("goal") 104 | 105 | assert result.exit_code == 1 106 | verify(result.output) 107 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_complex_args.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Running 'docker version' in '{current_working_directory}' 2 | DEBUG: docker: STDOUT 3 | DEBUG: docker: STDERR 4 | DEBUG: Running 'docker exec --interactive --workdir /root mainnet-container goal account export -a RKTAZY2ZLKUJBHDVVA3KKHEDK7PRVGIGOZAUUIZBNK2OEP6KQGEXKKUYUY' in '{current_working_directory}' 5 | STDOUT 6 | STDERR 7 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_console.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Running 'docker version' in '{current_working_directory}' 2 | DEBUG: docker: STDOUT 3 | DEBUG: docker: STDERR 4 | Opening Bash console on the algod node; execute `exit` to return to original console 5 | DEBUG: Running 'docker exec -it -w /root algokit_algod bash' in '{current_working_directory}' 6 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_console_failed.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Running 'docker version' in '{current_working_directory}' 2 | DEBUG: docker: STDOUT 3 | DEBUG: docker: STDERR 4 | Opening Bash console on the algod node; execute `exit` to return to original console 5 | DEBUG: Running 'docker exec -it -w /root algokit_algod bash' in '{current_working_directory}' 6 | DEBUG: Running 'docker compose ps algod --format json' in '{app_config}/sandbox' 7 | DEBUG: docker: [{"Name": "algokit_algod", "State": "running"}] 8 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_console_failed_algod_not_created.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Running 'docker version' in '{current_working_directory}' 2 | DEBUG: docker: STDOUT 3 | DEBUG: docker: STDERR 4 | Opening Bash console on the algod node; execute `exit` to return to original console 5 | DEBUG: Running 'docker exec -it -w /root algokit_algod bash' in '{current_working_directory}' 6 | DEBUG: Running 'docker compose ps algod --format json' in '{app_config}/sandbox' 7 | DEBUG: docker: [] 8 | WARNING: algod container does not appear to be running, ensure localnet is started by executing `algokit localnet start` 9 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_help.approved.txt: -------------------------------------------------------------------------------- 1 | Usage: algokit goal [OPTIONS] [GOAL_ARGS]... 2 | 3 | Run the Algorand goal CLI against the AlgoKit LocalNet. 4 | 5 | Look at https://developer.algorand.org/docs/clis/goal/goal/ for more 6 | information. 7 | 8 | Options: 9 | --console Open a Bash console so you can execute multiple goal commands 10 | and/or interact with a filesystem. 11 | -h, --help Show this message and exit. 12 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_no_args.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Running 'docker version' in '{current_working_directory}' 2 | DEBUG: docker: STDOUT 3 | DEBUG: docker: STDERR 4 | DEBUG: Running 'docker exec --interactive --workdir /root mainnet-container goal' in '{current_working_directory}' 5 | STDOUT 6 | STDERR 7 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_simple_args.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Running 'docker version' in '{current_working_directory}' 2 | DEBUG: docker: STDOUT 3 | DEBUG: docker: STDERR 4 | DEBUG: Running 'docker exec --interactive --workdir /root algokit_algod goal account list' in '{current_working_directory}' 5 | STDOUT 6 | STDERR 7 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_start_without_docker.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Running 'docker version' in '{current_working_directory}' 2 | Error: Docker not found; please install Docker and add to path. 3 | See https://docs.docker.com/get-docker/ for more information. 4 | -------------------------------------------------------------------------------- /tests/goal/test_goal.test_goal_start_without_docker_engine_running.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Running 'docker version' in '{current_working_directory}' 2 | DEBUG: docker: STDOUT 3 | DEBUG: docker: STDERR 4 | Error: Docker engine isn't running; please start it. 5 | -------------------------------------------------------------------------------- /tests/test_root.py: -------------------------------------------------------------------------------- 1 | from approvaltests import verify 2 | 3 | from tests.utils.click_invoker import invoke 4 | 5 | 6 | def test_help() -> None: 7 | result = invoke("-h") 8 | 9 | assert result.exit_code == 0 10 | verify(result.output) 11 | 12 | 13 | def test_version() -> None: 14 | result = invoke("--version") 15 | 16 | assert result.exit_code == 0 17 | -------------------------------------------------------------------------------- /tests/test_root.test_help.approved.txt: -------------------------------------------------------------------------------- 1 | Usage: algorun [OPTIONS] COMMAND [ARGS]... 2 | 3 | ░█████╗░██╗░░░░░░██████╗░░█████╗░██████╗░░█████╗░███╗░░██╗██████╗░ 4 | ██╔══██╗██║░░░░░██╔════╝░██╔══██╗██╔══██╗██╔══██╗████╗░██║██╔══██╗ 5 | ███████║██║░░░░░██║░░██╗░██║░░██║██████╔╝███████║██╔██╗██║██║░░██║ 6 | ██╔══██║██║░░░░░██║░░╚██╗██║░░██║██╔══██╗██╔══██║██║╚████║██║░░██║ 7 | ██║░░██║███████╗╚██████╔╝╚█████╔╝██║░░██║██║░░██║██║░╚███║██████╔╝ 8 | ╚═╝░░╚═╝╚══════╝░╚═════╝░░╚════╝░╚═╝░░╚═╝╚═╝░░╚═╝╚═╝░░╚══╝╚═════╝░ 9 | 10 | Options: 11 | --version Show the version and exit. 12 | -v, --verbose Enable logging of DEBUG messages to the console. 13 | --color / --no-color Force enable or disable of console output styling. 14 | --skip-version-check Skip version checking and prompting. 15 | -h, --help Show this message and exit. 16 | 17 | Commands: 18 | bootstrap Bootstrap local dependencies in an algorun project; run from project root directory. 19 | goal Run the Algorand goal CLI against your mainnet node. 20 | start Start your Algorand mainnet node 21 | stop Stop your Algorand mainnet node 22 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algorandfoundation/algorun/bd1ad2cc3b441b7804eccc07fc6f07ba83623c83/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/app_dir_mock.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from pathlib import Path 3 | 4 | from pytest_mock import MockerFixture 5 | 6 | 7 | @dataclasses.dataclass 8 | class AppDirs: 9 | app_config_dir: Path 10 | app_state_dir: Path 11 | 12 | 13 | def tmp_app_dir(mocker: MockerFixture, tmp_path: Path) -> AppDirs: 14 | app_config_dir = tmp_path / "config" 15 | app_config_dir.mkdir() 16 | mocker.patch("algorun.core.sandbox.get_app_config_dir").return_value = app_config_dir 17 | 18 | app_state_dir = tmp_path / "state" 19 | app_state_dir.mkdir() 20 | 21 | return AppDirs(app_config_dir=app_config_dir, app_state_dir=app_state_dir) 22 | -------------------------------------------------------------------------------- /tests/utils/approvals.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any 3 | 4 | import approvaltests 5 | from approvaltests.scrubbers.scrubbers import Scrubber, combine_scrubbers 6 | 7 | __all__ = [ 8 | "TokenScrubber", 9 | "Scrubber", 10 | "combine_scrubbers", 11 | "normalize_path", 12 | "verify", 13 | ] 14 | 15 | 16 | def normalize_path(content: str, path: str, token: str) -> str: 17 | return re.sub( 18 | rf"{token}\S+", 19 | lambda m: m[0].replace("\\", "/"), 20 | content.replace(path, token).replace(path.replace("\\", "/"), token), 21 | ) 22 | 23 | 24 | class TokenScrubber(Scrubber): # type: ignore[misc] 25 | def __init__(self, tokens: dict[str, str]): 26 | self._tokens = tokens 27 | 28 | def __call__(self, data: str) -> str: 29 | result = data 30 | for token, search in self._tokens.items(): 31 | result = result.replace(search, "{" + token + "}") 32 | return result 33 | 34 | 35 | def verify( 36 | data: Any, # noqa: ANN401 37 | *, 38 | options: approvaltests.Options | None = None, 39 | scrubber: Scrubber | None = None, 40 | **kwargs: Any, 41 | ) -> None: 42 | options = options or approvaltests.Options() 43 | if scrubber is not None: 44 | options = options.add_scrubber(scrubber) 45 | kwargs.setdefault("encoding", "utf-8") 46 | normalised_data = str(data).replace("\r\n", "\n") 47 | approvaltests.verify( 48 | data=normalised_data, 49 | options=options, 50 | # Don't normalise newlines 51 | newline="", 52 | **kwargs, 53 | ) 54 | -------------------------------------------------------------------------------- /tests/utils/click_invoker.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import logging 3 | import os 4 | from collections.abc import Mapping 5 | from pathlib import Path 6 | 7 | import click 8 | import click.testing 9 | from click.testing import CliRunner 10 | 11 | from tests.utils.approvals import normalize_path 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @dataclasses.dataclass 17 | class ClickInvokeResult: 18 | exit_code: int 19 | output: str 20 | exception: BaseException | None 21 | 22 | 23 | def invoke( 24 | args: str, 25 | *, 26 | cwd: Path | None = None, 27 | skip_version_check: bool = True, 28 | env: Mapping[str, str | None] | None = None, 29 | ) -> ClickInvokeResult: 30 | from algorun.cli import algorun 31 | 32 | runner = CliRunner() 33 | prior_cwd = Path.cwd() 34 | assert isinstance(algorun, click.BaseCommand) 35 | if cwd is not None: 36 | os.chdir(cwd) 37 | try: 38 | test_args = "-v --no-color" 39 | if skip_version_check: 40 | test_args = f"{test_args} --skip-version-check" 41 | result = runner.invoke(algorun, f"{test_args} {args}", env=env) 42 | if result.exc_info and not isinstance(result.exc_info[1], SystemExit): 43 | logger.error("Click invocation error", exc_info=result.exc_info) 44 | output = normalize_path(result.stdout, str(cwd or prior_cwd), "{current_working_directory}") 45 | return ClickInvokeResult(exit_code=result.exit_code, output=output, exception=result.exception) 46 | finally: 47 | if cwd is not None: 48 | os.chdir(prior_cwd) 49 | -------------------------------------------------------------------------------- /tests/utils/proc_mock.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from collections.abc import Sequence 3 | from io import StringIO 4 | from typing import IO, Any, TypeVar 5 | 6 | 7 | class PopenMock: 8 | def __init__(self, stdout: str, returncode: int = 0, min_poll_calls: int = 1): 9 | self._returncode = returncode 10 | self._stdout = StringIO(stdout) 11 | self._remaining_poll_calls = min_poll_calls 12 | 13 | def __enter__(self) -> "PopenMock": 14 | return self 15 | 16 | def __exit__(self, *args: Any) -> None: 17 | # TODO: we should change the structure of this mocking a bit, 18 | # and check that I/O cleanup was called 19 | pass 20 | 21 | @property 22 | def returncode(self) -> int: 23 | return self._returncode or 0 24 | 25 | @property 26 | def stdout(self) -> IO[str] | None: 27 | return self._stdout 28 | 29 | def wait(self) -> int: 30 | return self._returncode 31 | 32 | def poll(self) -> int | None: 33 | if self._remaining_poll_calls > 0: 34 | self._remaining_poll_calls -= 1 35 | return None 36 | return self._returncode 37 | 38 | 39 | @dataclasses.dataclass 40 | class CommandMockData: 41 | raise_not_found: bool = False 42 | raise_permission_denied: bool = False 43 | exit_code: int = 0 44 | output_lines: list[str] = dataclasses.field(default_factory=lambda: ["STDOUT", "STDERR"]) 45 | 46 | 47 | class ProcMock: 48 | def __init__(self) -> None: 49 | self._mock_data: dict[tuple[str, ...], CommandMockData] = {} 50 | self.called: list[list[str]] = [] 51 | 52 | def _add_mock_data(self, cmd: list[str] | str, data: CommandMockData) -> None: 53 | cmd_list = tuple(cmd.split() if isinstance(cmd, str) else cmd) 54 | if cmd_list in self._mock_data: 55 | # update if exact match already exists 56 | self._mock_data[cmd_list] = data 57 | return 58 | # otherwise we quickly check to make sure we won't get surprising results due to ordering, 59 | # since if another command is a prefix of the one attempted to be added, and it comes before, this won't work 60 | without_overlapping_prefixes = { 61 | existing_cmd_prefix: data 62 | for existing_cmd_prefix, data in self._mock_data.items() 63 | if not sequence_starts_with(existing_cmd_prefix, cmd_list) 64 | } 65 | without_overlapping_prefixes[cmd_list] = data 66 | self._mock_data = without_overlapping_prefixes 67 | 68 | def should_fail_on(self, cmd: list[str] | str) -> None: 69 | self._add_mock_data(cmd, CommandMockData(raise_not_found=True)) 70 | 71 | def should_deny_on(self, cmd: list[str] | str) -> None: 72 | self._add_mock_data(cmd, CommandMockData(raise_permission_denied=True)) 73 | 74 | def should_bad_exit_on(self, cmd: list[str] | str, exit_code: int = -1, output: list[str] | None = None) -> None: 75 | if exit_code == 0: 76 | raise ValueError("zero is considered a good exit code") 77 | 78 | mock_data = CommandMockData( 79 | exit_code=exit_code, 80 | ) 81 | if output is not None: 82 | mock_data.output_lines = output 83 | self._add_mock_data(cmd, mock_data) 84 | 85 | def set_output(self, cmd: list[str] | str, output: list[str]) -> None: 86 | self._add_mock_data(cmd, CommandMockData(output_lines=output)) 87 | 88 | def popen(self, cmd: list[str], *_args: Any, **_kwargs: Any) -> PopenMock: 89 | self.called.append(cmd) 90 | for i in reversed(range(len(cmd))): 91 | prefix = cmd[: i + 1] 92 | try: 93 | mock_data = self._mock_data[tuple(prefix)] 94 | except KeyError: 95 | pass 96 | else: 97 | break 98 | else: 99 | mock_data = CommandMockData() 100 | 101 | if mock_data.raise_not_found: 102 | raise FileNotFoundError(f"No such file or directory: {cmd[0]}") 103 | if mock_data.raise_permission_denied: 104 | raise PermissionError(f"I'm sorry Dave I can't do {cmd[0]}") 105 | exit_code = mock_data.exit_code 106 | output = "\n".join(mock_data.output_lines) 107 | return PopenMock(output, exit_code) 108 | 109 | 110 | T = TypeVar("T") 111 | 112 | 113 | def sequence_starts_with(seq: Sequence[T], test: Sequence[T]) -> bool: 114 | """Like startswith, but for a generic sequence""" 115 | test_len = len(test) 116 | if len(seq) < test_len: 117 | return False 118 | return seq[:test_len] == test 119 | -------------------------------------------------------------------------------- /tests/utils/which_mock.py: -------------------------------------------------------------------------------- 1 | class WhichMock: 2 | def __init__(self) -> None: 3 | self.paths: dict[str, str] = {} 4 | 5 | def add(self, cmd: str, path: str | None = None) -> str: 6 | path = path or f"/bin/{cmd}" 7 | self.paths[cmd] = path 8 | return path 9 | 10 | def remove(self, cmd: str) -> None: 11 | self.paths.pop(cmd, None) 12 | 13 | def which(self, cmd: str) -> str | None: 14 | return self.paths.get(cmd) 15 | -------------------------------------------------------------------------------- /tests/version_check/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algorandfoundation/algorun/bd1ad2cc3b441b7804eccc07fc6f07ba83623c83/tests/version_check/__init__.py -------------------------------------------------------------------------------- /tests/version_check/test_version_check.py: -------------------------------------------------------------------------------- 1 | import os 2 | from importlib import metadata 3 | from time import time 4 | 5 | import pytest 6 | from algorun.core.conf import PACKAGE_NAME 7 | from algorun.core.version_prompt import LATEST_URL, VERSION_CHECK_INTERVAL 8 | from approvaltests.scrubbers.scrubbers import Scrubber, combine_scrubbers 9 | from pytest_httpx import HTTPXMock 10 | from pytest_mock import MockerFixture 11 | 12 | from tests.utils.app_dir_mock import AppDirs 13 | from tests.utils.approvals import normalize_path, verify 14 | from tests.utils.click_invoker import invoke 15 | 16 | CURRENT_VERSION = metadata.version(PACKAGE_NAME) 17 | NEW_VERSION = "999.99.99" 18 | 19 | 20 | def make_scrubber(app_dir_mock: AppDirs) -> Scrubber: 21 | return combine_scrubbers( 22 | lambda x: normalize_path(x, str(app_dir_mock.app_config_dir), "{app_config}"), 23 | lambda x: normalize_path(x, str(app_dir_mock.app_state_dir), "{app_state}"), 24 | lambda x: x.replace(CURRENT_VERSION, "{current_version}"), 25 | lambda x: x.replace(NEW_VERSION, "{new_version}"), 26 | ) 27 | 28 | 29 | @pytest.fixture(autouse=True) 30 | def _setup(mocker: MockerFixture, app_dir_mock: AppDirs) -> None: 31 | mocker.patch("algorun.core.version_prompt.get_app_config_dir").return_value = app_dir_mock.app_config_dir 32 | mocker.patch("algorun.core.version_prompt.get_app_state_dir").return_value = app_dir_mock.app_state_dir 33 | # make bootstrap env a no-op 34 | mocker.patch("algorun.cli.bootstrap.bootstrap_env") 35 | 36 | 37 | def test_version_check_queries_github_when_no_cache(app_dir_mock: AppDirs, httpx_mock: HTTPXMock) -> None: 38 | httpx_mock.add_response(url=LATEST_URL, json={"tag_name": f"v{NEW_VERSION}"}) 39 | 40 | # bootstrap env is a nice simple command we can use to test the version check side effects 41 | result = invoke("bootstrap env", skip_version_check=False) 42 | 43 | assert result.exit_code == 0 44 | verify(result.output, scrubber=make_scrubber(app_dir_mock)) 45 | 46 | 47 | @pytest.mark.parametrize( 48 | ("current_version", "latest_version", "warning_expected"), 49 | [ 50 | ("0.2.0", "0.3.0", True), 51 | ("0.25.0", "0.30.0", True), 52 | ("0.3.0", "0.29.0", True), 53 | ("999.99.99", "1000.00.00", True), 54 | ("999.99.99-beta", "1000.00.00", True), 55 | ("999.99.99-alpha", "999.99.99-beta", True), 56 | ("0.25.0", "1.0.0", True), 57 | ("0.29.0", "1.0.0", True), 58 | ("0.3.0", "1.0.0", True), 59 | ("0.3.0", "0.2.0", False), 60 | ("0.3.0", "0.3.0", False), 61 | ("0.30.0", "0.25.0", False), 62 | ("0.29.0", "0.3.0", False), 63 | ("0.30.0", "0.30.0", False), 64 | ("1.0.0", "0.25.0", False), 65 | ("1.0.0", "0.29.0", False), 66 | ("1.0.0", "0.3.0", False), 67 | ("1.0.0", "1.0.0", False), 68 | ("999.99.99", "998.0.0", False), 69 | ("999.99.99", "999.99.0", False), 70 | ("999.99.99", "999.99.99", False), 71 | ("999.99.99-beta", "998.99.99", False), 72 | ("999.99.99-beta", "999.99.99-alpha", False), 73 | ], 74 | ) 75 | def test_version_check_only_warns_if_newer_version_is_found( 76 | app_dir_mock: AppDirs, mocker: MockerFixture, current_version: str, latest_version: str, *, warning_expected: bool 77 | ) -> None: 78 | mocker.patch("algorun.core.version_prompt.get_current_package_version").return_value = current_version 79 | version_cache = app_dir_mock.app_state_dir / "last-version-check" 80 | version_cache.write_text(latest_version, encoding="utf-8") 81 | result = invoke("bootstrap env", skip_version_check=False) 82 | 83 | if warning_expected: 84 | assert f"version {latest_version} is available" in result.output 85 | else: 86 | assert f"version {latest_version} is available" not in result.output 87 | 88 | 89 | def test_version_check_uses_cache(app_dir_mock: AppDirs) -> None: 90 | version_cache = app_dir_mock.app_state_dir / "last-version-check" 91 | version_cache.write_text("1234.56.78", encoding="utf-8") 92 | result = invoke("bootstrap env", skip_version_check=False) 93 | 94 | assert result.exit_code == 0 95 | verify(result.output, scrubber=make_scrubber(app_dir_mock)) 96 | 97 | 98 | def test_version_check_queries_github_when_cache_out_of_date(app_dir_mock: AppDirs, httpx_mock: HTTPXMock) -> None: 99 | httpx_mock.add_response(url=LATEST_URL, json={"tag_name": f"v{NEW_VERSION}"}) 100 | version_cache = app_dir_mock.app_state_dir / "last-version-check" 101 | version_cache.write_text("1234.56.78", encoding="utf-8") 102 | modified_time = time() - VERSION_CHECK_INTERVAL - 1 103 | os.utime(version_cache, (modified_time, modified_time)) 104 | 105 | result = invoke("bootstrap env", skip_version_check=False) 106 | 107 | assert result.exit_code == 0 108 | verify(result.output, scrubber=make_scrubber(app_dir_mock)) 109 | 110 | 111 | def test_version_check_respects_disable_config(app_dir_mock: AppDirs) -> None: 112 | (app_dir_mock.app_config_dir / "disable-version-prompt").touch() 113 | result = invoke("bootstrap env", skip_version_check=False) 114 | 115 | assert result.exit_code == 0 116 | verify(result.output, scrubber=make_scrubber(app_dir_mock)) 117 | 118 | 119 | def test_version_check_respects_skip_option(app_dir_mock: AppDirs) -> None: 120 | result = invoke("--skip-version-check bootstrap env", skip_version_check=False) 121 | 122 | assert result.exit_code == 0 123 | verify(result.output, scrubber=make_scrubber(app_dir_mock)) 124 | 125 | 126 | def test_version_check_disable_version_check(app_dir_mock: AppDirs) -> None: 127 | disable_version_check_path = app_dir_mock.app_config_dir / "disable-version-prompt" 128 | result = invoke("config version-prompt disable") 129 | 130 | assert result.exit_code == 0 131 | assert disable_version_check_path.exists() 132 | verify(result.output, scrubber=make_scrubber(app_dir_mock)) 133 | 134 | 135 | def test_version_check_enable_version_check(app_dir_mock: AppDirs) -> None: 136 | disable_version_check_path = app_dir_mock.app_config_dir / "disable-version-prompt" 137 | disable_version_check_path.touch() 138 | result = invoke("config version-prompt enable") 139 | 140 | assert result.exit_code == 0 141 | assert not disable_version_check_path.exists() 142 | verify(result.output, scrubber=make_scrubber(app_dir_mock)) 143 | -------------------------------------------------------------------------------- /tests/version_check/test_version_check.test_version_check_disable_version_check.approved.txt: -------------------------------------------------------------------------------- 1 | 🚫 Will stop checking for new versions 2 | -------------------------------------------------------------------------------- /tests/version_check/test_version_check.test_version_check_enable_version_check.approved.txt: -------------------------------------------------------------------------------- 1 | 📡 Resuming check for new versions 2 | -------------------------------------------------------------------------------- /tests/version_check/test_version_check.test_version_check_queries_github_when_cache_out_of_date.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: 1234.56.78 found in cache {app_state}/last-version-check 2 | DEBUG: HTTP Request: GET https://api.github.com/repos/algorandfoundation/algokit-cli/releases/latest "HTTP/1.1 200 OK" 3 | DEBUG: Latest version tag: v{new_version} 4 | You are using Algorun version {current_version}, however version {new_version} is available. 5 | DEBUG: No .algorun.toml file found in the project directory. 6 | -------------------------------------------------------------------------------- /tests/version_check/test_version_check.test_version_check_queries_github_when_no_cache.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: {app_state}/last-version-check inaccessible 2 | DEBUG: HTTP Request: GET https://api.github.com/repos/algorandfoundation/algokit-cli/releases/latest "HTTP/1.1 200 OK" 3 | DEBUG: Latest version tag: v{new_version} 4 | You are using Algorun version {current_version}, however version {new_version} is available. 5 | DEBUG: No .algorun.toml file found in the project directory. 6 | -------------------------------------------------------------------------------- /tests/version_check/test_version_check.test_version_check_respects_disable_config.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: Version prompt disabled 2 | DEBUG: No .algorun.toml file found in the project directory. 3 | -------------------------------------------------------------------------------- /tests/version_check/test_version_check.test_version_check_respects_skip_option.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: No .algorun.toml file found in the project directory. 2 | -------------------------------------------------------------------------------- /tests/version_check/test_version_check.test_version_check_uses_cache.approved.txt: -------------------------------------------------------------------------------- 1 | DEBUG: 1234.56.78 found in cache {app_state}/last-version-check 2 | You are using Algorun version {current_version}, however version 1234.56.78 is available. 3 | DEBUG: No .algorun.toml file found in the project directory. 4 | --------------------------------------------------------------------------------