diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8f0bac9e..5a0fd473 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -128,7 +128,7 @@ jobs: - uses: actions/checkout@v4 - name: Build Examples - uses: SilverbackLtd/build-action@v1 + uses: SilverbackLtd/build-action@main with: push: ${{ github.event_name != 'pull_request' }} tag: latest diff --git a/ape-config.yaml b/ape-config.yaml deleted file mode 100644 index 889112a7..00000000 --- a/ape-config.yaml +++ /dev/null @@ -1,14 +0,0 @@ -plugins: - - name: etherscan - - name: tokens - -# Ensure we are configured for fork mode correct for example -ethereum: - mainnet-fork: - default_provider: foundry - -tokens: - default: CoinGecko - required: - - name: CoinGecko - uri: "https://tokens.coingecko.com/uniswap/all.json" diff --git a/pyproject.toml b/pyproject.toml index 3e881a86..ddaa8bec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,3 +41,18 @@ extend_skip = ["version.py"] [tool.mdformat] number = true + +[[tool.ape.plugins]] +name = "etherscan" +[[tool.ape.plugins]] +name = "tokens" + +# Ensure we are configured for fork mode correct for example +[tool.ape.ethereum.mainnet-fork] +default_provider = "foundry" + +[tool.ape.tokens] +default = "CoinGecko" +required = [ + {name = "CoinGecko", uri = "https://tokens.coingecko.com/uniswap/all.json"}, +] diff --git a/requirements-bot.txt b/requirements-bot.txt new file mode 100644 index 00000000..71730948 --- /dev/null +++ b/requirements-bot.txt @@ -0,0 +1 @@ +# NOTE: The existance of this file (or `requirements-bot.txt`) skips installing via `pyproject.toml` diff --git a/silverback/_build_utils.py b/silverback/_build_utils.py index a098704c..c9cbd431 100644 --- a/silverback/_build_utils.py +++ b/silverback/_build_utils.py @@ -2,6 +2,7 @@ from pathlib import Path import click +import tomlkit import yaml IMAGES_FOLDER_NAME = ".silverback-images" @@ -10,11 +11,11 @@ def dockerfile_template( bot_path: Path, sdk_version: str = "stable", - include_bot_dir: bool = False, - has_requirements_txt: bool = False, + requirements_txt_fname: str | None = None, has_pyproject_toml: bool = False, has_ape_config_yaml: bool = False, contracts_folder: str | None = None, + include_bot_dir: bool = False, ): dockerfile = [ f"FROM ghcr.io/apeworx/silverback:{sdk_version}", @@ -24,20 +25,22 @@ def dockerfile_template( "USER harambe", ] - if has_requirements_txt or has_pyproject_toml: - dockerfile.append("RUN pip install --upgrade pip") - - if has_requirements_txt: - dockerfile.append("COPY requirements.txt .") - dockerfile.append("RUN pip install -r requirements.txt") + if requirements_txt_fname: + dockerfile.append(f"COPY {requirements_txt_fname} requirements.txt") if has_pyproject_toml: dockerfile.append("COPY pyproject.toml .") - dockerfile.append("RUN pip install .") if has_ape_config_yaml: dockerfile.append("COPY ape-config.yaml .") + if requirements_txt_fname or has_pyproject_toml: + dockerfile.append("RUN pip install --upgrade pip") + + # NOTE: Only install project via `pyproject.toml` if `requirements-bot].txt` DNE + install_arg = "-r requirements.txt" if requirements_txt_fname else "." + dockerfile.append(f"RUN pip install {install_arg}") + if has_pyproject_toml or has_ape_config_yaml: dockerfile.append("RUN ape plugins install -U .") @@ -58,11 +61,29 @@ def generate_dockerfiles(path: Path, sdk_version: str = "stable"): contracts_folder: str | None = "contracts" if has_ape_config_yaml := (ape_config_path := Path.cwd() / "ape-config.yaml").exists(): contracts_folder = ( - yaml.safe_load(ape_config_path.read_text()) + yaml.safe_load(ape_config_path.read_text()).get("compiler", {}) + # NOTE: Should fall through to this last `.get` and use initial default if config DNE + .get("contracts_folder", contracts_folder) + ) + + if has_pyproject_toml := (pyproject_path := Path.cwd() / "pyproject.toml").exists(): + contracts_folder = ( + tomlkit.loads(pyproject_path.read_text()) + .get("tool", {}) + .get("ape", {}) .get("compiler", {}) + # NOTE: Should fall through to this last `.get` and use initial default if config DNE .get("contracts_folder", contracts_folder) ) + if not ( + # NOTE: Use this first so we can avoid using legitimate `requirements.txt` + (Path.cwd() / (requirements_txt_fname := "requirements-bot.txt")).exists() + or (Path.cwd() / (requirements_txt_fname := "requirements.txt")).exists() + ): + # NOTE: Doesn't exist so make it not be `requirements.txt` + requirements_txt_fname = None + assert contracts_folder # make mypy happy if not ((Path.cwd() / contracts_folder)).exists(): contracts_folder = None @@ -75,8 +96,8 @@ def generate_dockerfiles(path: Path, sdk_version: str = "stable"): bot, include_bot_dir=True, sdk_version=sdk_version, - has_requirements_txt=(Path.cwd() / "requirements.txt").exists(), - has_pyproject_toml=(Path.cwd() / "pyproject.toml").exists(), + requirements_txt_fname=requirements_txt_fname, + has_pyproject_toml=has_pyproject_toml, has_ape_config_yaml=has_ape_config_yaml, contracts_folder=contracts_folder, ) @@ -87,20 +108,38 @@ def generate_dockerfiles(path: Path, sdk_version: str = "stable"): dockerfile_template( path, sdk_version=sdk_version, - has_requirements_txt=(Path.cwd() / "requirements.txt").exists(), - has_pyproject_toml=(Path.cwd() / "pyproject.toml").exists(), + requirements_txt_fname=requirements_txt_fname, + has_pyproject_toml=has_pyproject_toml, has_ape_config_yaml=has_ape_config_yaml, contracts_folder=contracts_folder, ) ) -def build_docker_images(): - for dockerfile in (Path.cwd() / IMAGES_FOLDER_NAME).glob("Dockerfile.*"): - command = f"docker build -f {dockerfile.relative_to(Path.cwd())} ." - click.secho(f"{command}", fg="green") +def build_docker_images( + tag_base: str | None = None, + version: str = "latest", + push: bool = False, +): + built_tags = [] + build_root = Path.cwd() + for dockerfile in (build_root / IMAGES_FOLDER_NAME).glob("Dockerfile.*"): + bot_name = dockerfile.suffix.lstrip(".") or "bot" + tag = ( + f"{tag_base.lower()}-{bot_name.lower()}:{version}" + if tag_base is not None + else f"{build_root.name.lower()}-{bot_name.lower()}:{version}" + ) + command = ["docker", "build", "-f", str(dockerfile.relative_to(build_root)), "-t", tag, "."] + click.secho(" ".join(command), fg="green") try: - subprocess.run(command, shell=True, check=True) + subprocess.run(command, check=True) except subprocess.CalledProcessError as e: raise click.ClickException(str(e)) + + built_tags.append(tag) + + if push: + for tag in built_tags: + subprocess.run(["docker", "push", tag]) diff --git a/silverback/_cli.py b/silverback/_cli.py index 51228c4a..6c84b7c7 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -131,10 +131,48 @@ def run(cli_ctx, account, runner_class, recorder_class, max_exceptions, debug, b @cli.command(section="Local Commands") -@click.option("--generate", is_flag=True, default=False) +@click.option( + "-g", + "--generate", + is_flag=True, + default=False, + help="Generate Dockerfiles first. Defaults to false.", +) +@click.option( + "-t", + "--tag-base", + default=None, + help=( + "The base to use to tag the image. " + "The bot name (or 'bot') is appended to it, following a '-' separator. " + "Defaults to using the name of the folder you are building from." + ), +) +@click.option( + "--version", + default="latest", + metavar="VERSION", + help="Version to use in tag. Defaults to 'latest'.", +) +@click.option( + "--push", + is_flag=True, + default=False, + help="Push image to logged-in registry. Defaults to false.", +) @click.argument("path", required=False, default=None) -def build(generate, path): - """Generate Dockerfiles and build bot container images""" +def build(generate, tag_base, version, push, path): + """ + Generate Dockerfiles and build bot container images + + When '--tag-base' is used, you can control the base of the tag for the image. + For example, '--tag-base project' with bots 'botA.py' and 'botB.py' (under 'bots/') produces + '-t project-bota:latest' and '-t project-botb:latest' respectively. + + For building to push to a specific image registry, use '--tag-base' to correctly tag images. + Using '--tag-base ghcr.io/myorg/myproject' with the previous example + '-t ghcr.io/myorg/project-bota:latest' and '-t ghcr.io/myorg/project-botb:latest' respectively. + """ from silverback._build_utils import ( IMAGES_FOLDER_NAME, build_docker_images, @@ -166,7 +204,7 @@ def build(generate, path): "You can run `silverback build --generate` to generate it and build." ) - build_docker_images() + build_docker_images(tag_base=tag_base, version=version, push=push) @cli.command(cls=ConnectedProviderCommand, section="Local Commands") diff --git a/tests/test_utils.py b/tests/test_utils.py index 0cbd1cec..55111513 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,8 @@ +from pathlib import Path + import pytest +from silverback._build_utils import dockerfile_template from silverback.utils import decode_topics_from_string, encode_topics_to_string @@ -15,3 +18,34 @@ ) def test_topic_encoding(topics): assert decode_topics_from_string(encode_topics_to_string(topics)) == topics + + +@pytest.mark.parametrize( + "build_args", + [ + dict(), + dict(sdk_version="latest"), + dict(requirements_txt_fname="requirements.txt"), + dict(requirements_txt_fname="requirements-bot.txt"), + dict(has_pyproject_toml=True), + dict(has_ape_config_yaml=True), + dict(contracts_folder="src"), + dict(include_bot_dir=True), + ], +) +def test_dockerfile_generation(build_args): + dockerfile = dockerfile_template( + Path(__file__).parent.parent / "bots" / "example.py", **build_args + ) + assert "example.py" in dockerfile + assert build_args.get("sdk_version", "stable") in dockerfile + if requirements_txt_fname := build_args.get("requirements_txt_fname"): + assert requirements_txt_fname in dockerfile + if build_args.get("has_pyproject_toml"): + assert "pyproject.toml" in dockerfile + if build_args.get("has_ape_config_yaml"): + assert "ape-config.yaml" in dockerfile + if contracts_folder := build_args.get("contracts_folder"): + assert contracts_folder in dockerfile + if build_args.get("include_bot_dir"): + assert "bots/" in dockerfile