From 43503ec5a761ca63ff653ab3a99ddb919c540054 Mon Sep 17 00:00:00 2001 From: NotPeopling2day <32708219+NotPeopling2day@users.noreply.github.com> Date: Thu, 14 Sep 2023 09:51:55 -0500 Subject: [PATCH] feat: initial working code --- .github/release-drafter.yaml | 37 +++++++++ .github/workflows/commitlint.yaml | 27 +++++++ .github/workflows/draft.yaml | 17 +++++ .github/workflows/prtitle.yaml | 30 ++++++++ .github/workflows/test.yaml | 89 ++++++++++++++++++++++ .mdformat.toml | 1 + .pre-commit-config.yaml | 36 +++++++++ README.md | 24 +++++- dev-requirements.txt | 6 ++ main.py | 121 ++++++++++++++++++++++++++++++ pyproject.toml | 26 +++++++ requirements.txt | 8 ++ setup.cfg | 6 ++ tests/__init__.py | 0 tests/conftest.py | 33 ++++++++ tests/data/__init__.py | 0 tests/data/first_app.vy | 28 +++++++ tests/data/hello_world.vy | 12 +++ tests/data/wrong_file_type.txt | 1 + tests/test_app.py | 117 +++++++++++++++++++++++++++++ 20 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 .github/release-drafter.yaml create mode 100644 .github/workflows/commitlint.yaml create mode 100644 .github/workflows/draft.yaml create mode 100644 .github/workflows/prtitle.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .mdformat.toml create mode 100644 .pre-commit-config.yaml create mode 100644 dev-requirements.txt create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/data/__init__.py create mode 100644 tests/data/first_app.vy create mode 100644 tests/data/hello_world.vy create mode 100644 tests/data/wrong_file_type.txt create mode 100644 tests/test_app.py diff --git a/.github/release-drafter.yaml b/.github/release-drafter.yaml new file mode 100644 index 0000000..a2beeef --- /dev/null +++ b/.github/release-drafter.yaml @@ -0,0 +1,37 @@ +name-template: '$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' + +categories: + - title: 'Features' + labels: + - 'feat' + - title: 'Bug Fixes' + labels: + - 'fix' + - title: 'Other updates' + labels: + - 'refactor' + - 'chore' + - 'docs' + +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. + +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch + +template: | + ## Changes + + $CHANGES + + Special thanks to: $CONTRIBUTORS diff --git a/.github/workflows/commitlint.yaml b/.github/workflows/commitlint.yaml new file mode 100644 index 0000000..17294ec --- /dev/null +++ b/.github/workflows/commitlint.yaml @@ -0,0 +1,27 @@ +on: push + +name: Commit Message + +# NOTE: Skip check on PR so as not to confuse contributors +# NOTE: Also install a PR title checker so we don't mess up merges +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install commitizen + + - name: Check commit history + run: cz check --rev-range $(git rev-list --all --reverse | head -1)..HEAD diff --git a/.github/workflows/draft.yaml b/.github/workflows/draft.yaml new file mode 100644 index 0000000..423582b --- /dev/null +++ b/.github/workflows/draft.yaml @@ -0,0 +1,17 @@ +name: Release Drafter + +on: + push: + branches: + - main + +jobs: + update-draft: + runs-on: ubuntu-latest + steps: + # Drafts your next Release notes as Pull Requests are merged into "main" + - uses: release-drafter/release-drafter@v5 + with: + disable-autolabeler: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prtitle.yaml b/.github/workflows/prtitle.yaml new file mode 100644 index 0000000..4de4256 --- /dev/null +++ b/.github/workflows/prtitle.yaml @@ -0,0 +1,30 @@ +name: PR Title + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install commitizen + + - name: Check PR Title + env: + TITLE: ${{ github.event.pull_request.title }} + run: cz check --message "$TITLE" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..d080d90 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,89 @@ +on: ["push", "pull_request"] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +name: Test + +concurrency: + # Cancel older, in-progress jobs from the same PR, same workflow. + # use run_id if the job is triggered by a push to ensure + # push-triggered jobs to not get canceled. + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + linting: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r dev-requirements.txt + + - name: Run Black + run: black --check . + + - name: Run isort + run: isort --check-only . + + - name: Run flake8 + run: flake8 . + + - name: Run mdformat + run: mdformat . --check + + type-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r dev-requirements.txt + + - name: Run MyPy + run: mypy . + + functional: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-latest] # eventually add `windows-latest` + python-version: [3.8, 3.9, "3.10", "3.11"] + + env: + GETH_VERSION: 1.12.0 + + steps: + - uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Tests + run: pytest + diff --git a/.mdformat.toml b/.mdformat.toml new file mode 100644 index 0000000..01b2fb0 --- /dev/null +++ b/.mdformat.toml @@ -0,0 +1 @@ +number = true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d725bb8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.2.0 + hooks: + - id: check-yaml + +- repo: https://github.com/pre-commit/mirrors-isort + rev: v5.10.1 + hooks: + - id: isort + +- repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + name: black + +- repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.991 + hooks: + - id: mypy + additional_dependencies: [types-setuptools, pydantic==1.10.4] + +- repo: https://github.com/executablebooks/mdformat + rev: 0.7.14 + hooks: + - id: mdformat + additional_dependencies: [mdformat-gfm, mdformat-frontmatter] + +default_language_version: + python: python3 diff --git a/README.md b/README.md index ad576d1..5efbb06 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,22 @@ -# hosted-compiler-new -Hosted compiler for working with Vyper files +# Overview + +**Vyper Compiler** is a tool used to help [Remix Online IDE](https://remix-project.org/) compile Vyper contracts. + +Vyper Compiler is built by [ApeWorX LTD](https://www.apeworx.io/). + +If you have any questions please ask in our discord [ApeWorX Discord server](https://discord.gg/apeworx). + +## Documentation + +The compiler is built with Ape and fastAPI. It has multiple routes to engage the compiler in different parts of your journey to compiler the contract. + + +## Quickstart with local contracts + +To show that it works. Clone this repo and deploy the local host server with `uvicorn main:app --reload` + +Using the post route: Upload the contract file and version number of vyper. + +Wait for it compile and while you wait, you can use the temporary directory name is the task ID to check the status of it. + +Once it says success, you can use the `get compiled artifact` route and return the manifest of the contract. diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..94b33a7 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,6 @@ +-r requirements.txt +black +flake8 +isort +mdformat +mypy diff --git a/main.py b/main.py new file mode 100644 index 0000000..eb9fcd4 --- /dev/null +++ b/main.py @@ -0,0 +1,121 @@ +import os +import tempfile +from enum import Enum +from pathlib import Path +from typing import Dict, List, Optional + +from ape import config +from ethpm_types import PackageManifest +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, UploadFile +from pydantic import BaseModel + +PackageManifest.update_forward_refs() +app = FastAPI() + + +class TaskStatus(Enum): + PENDING = "PENDING" + SUCCESS = "SUCCESS" + FAILED = "FAILED" + + +class Task(BaseModel): + id: str + status: TaskStatus = TaskStatus.PENDING + exceptions: List[str] = [] + manifest: Optional[PackageManifest] = None + + +# global db +tasks: Dict[str, Task] = {} + + +def is_supported_language(filename): + # Add your supported languages here (Vyper) + supported_languages = [".vy"] + _, file_extension = os.path.splitext(filename) + return file_extension.lower() in supported_languages + + +@app.post("/compile/") +async def create_compilation_task( + background_tasks: BackgroundTasks, + files: List[UploadFile], + vyper_version: str = Query( + default=None, + title="Vyper version", + description="Vyper version to use for compilation", + ), +) -> Task: + # Create a temporary directory for the project + project_root = Path(tempfile.mkdtemp("")) + + # Create a contracts directory + contracts_dir = project_root / "contracts" + contracts_dir.mkdir() + + # add request contracts in temp directory + for file in files: + if not is_supported_language(file.filename): + raise HTTPException( + status_code=400, + detail=f"Unsupported language for file {file.filename}", + ) + + content = (await file.read()).decode("utf-8") + (project_root / "contracts" / file.filename).write_text(content) # type: ignore [operator] + + task_id = project_root.name + + tasks[task_id] = Task(id=task_id) + + # Run the compilation task in the background using TaskIQ + background_tasks.add_task(compile_project, task_id, project_root, vyper_version) + + return tasks[task_id] + + +# NOTE: PackageManifest model fails during validation, temp workaround +@app.get("/compile/{task_id}", response_model=None) +async def get_compilation_task(task_id: str) -> Task: + if task_id not in tasks: + raise HTTPException(status_code=404, detail=f"Task id {task_id} not found.") + return tasks[task_id] + + +@app.get("/status/{task_id}") +async def get_task_status(task_id: str) -> TaskStatus: + if task_id not in tasks: + raise HTTPException(status_code=404, detail=f"Task id {task_id} not found.") + return tasks[task_id].status + + +@app.get("/exceptions/{task_id}") +async def get_task_exceptions(task_id: str) -> List[str]: + if task_id not in tasks: + raise HTTPException(status_code=404, detail=f"Task id {task_id} not found.") + return tasks[task_id].exceptions + + +# NOTE: PackageManifest model fails during validation, temp workaround +@app.get("/compiled_artifact/{task_id}", response_model=None) +async def get_compiled_artifact(task_id: str) -> PackageManifest: + if task_id not in tasks: + raise HTTPException(status_code=404, detail=f"Task id {task_id} not found.") + if tasks[task_id].status is not TaskStatus.SUCCESS: + raise HTTPException(status_code=404, detail="Task is not completed with Success status.") + + return tasks[task_id].manifest # type: ignore [return-value] + + +async def compile_project(task_id: str, project_root: Path, vyper_version: str): + try: + # compiles the project + with config.using_project(project_root) as project: + tasks[task_id].manifest = project.extract_manifest() + except Exception as e: + tasks[task_id].status = TaskStatus.FAILED + tasks[task_id].exceptions.append(str(e)) + + if not tasks[task_id].exceptions: + tasks[task_id].status = TaskStatus.SUCCESS diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..11e5f00 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[tool.mypy] +exclude = "build/" +plugins = ["pydantic.mypy"] +show_error_codes = true + +# NOTE: you have to use single-quoted strings in TOML for regular expressions. +# It's the equivalent of r-strings in Python. Multiline strings are treated as +# verbose regular expressions by Black. Use [ ] to denote a significant space +# character. + +[tool.black] +line-length = 100 +target-version = ['py38', 'py39', 'py310', 'py311'] +include = '\.pyi?$' + +[tool.pytest.ini_options] +python_files = "test_*.py" +testpaths = "tests" +markers = "fuzzing: Run Hypothesis fuzz test suite" + +[tool.isort] +line_length = 100 +force_grid_wrap = 0 +include_trailing_comma = true +multi_line_output = 3 +use_parentheses = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a13c911 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +ape-vyper +eth-ape +fastapi +httpx +pytest +python-multipart +uvicorn[standard] +vyper diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0acad3a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[flake8] +max-line-length = 100 +exclude = + venv* + docs + build \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e239411 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,33 @@ +import pytest + + +@pytest.fixture(scope="session") +def client(): + from fastapi.testclient import TestClient + + from main import app + + yield TestClient(app) + + +@pytest.fixture(scope="session") +def completed_task(client): + files = [ + ("files", open("tests/data/hello_world.vy", "rb")), + ("files", open("tests/data/first_app.vy", "rb")), + ] + + response = client.post("/compile/", files=files, data={"vyper_version": "0.2.10"}) + assert response.status_code == 200 + + return response.json() + + +@pytest.fixture(scope="session") +def completed_task_with_error(): + from main import Task, TaskStatus, tasks + + failed_task = Task(id="1234", status=TaskStatus.FAILED, exceptions=["some error"]) + tasks["1234"] = failed_task + + return tasks["1234"] diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/first_app.vy b/tests/data/first_app.vy new file mode 100644 index 0000000..d4feb4a --- /dev/null +++ b/tests/data/first_app.vy @@ -0,0 +1,28 @@ +# @version >=0.3.2 + +# @notice Simple contract for getting, incrementing and decrementing a counter +# @notice https://www.vyperexamples.com/first-app + +# @var The counter +# @dev Vyper automatically creates a getter method when you set a variable as public +# @dev We don't even really need the get() function we made +# @dev uint256 is set to the default value of 0 when the contract is deployed +counter: public(uint256) + +# @notice Returns the counter +# @return The current count +@external +@view +def get() -> uint256: + return self.counter + +# @notice Increment count by 1 +@external +def inc(): + self.counter += 1 + +# @notice Decrement count by 1 +# @dev Will fail if count is 0 +@external +def dec(): + self.counter -= 1 diff --git a/tests/data/hello_world.vy b/tests/data/hello_world.vy new file mode 100644 index 0000000..57d47b5 --- /dev/null +++ b/tests/data/hello_world.vy @@ -0,0 +1,12 @@ +# @version >=0.3.2 + +# @notice Simple greeting contract +# @notice https://www.vyperexamples.com/hello-world + +# @notice Returns the string "Hello World!" +# @notice The @external decorator means this function can only be called by external parties ie. by other contracts or by a wallet making a transaction +# @notice The @view decorator means that this function can read the contract state but not alter it. Cannot consume gas. +@external +@view +def helloWorld() -> String[24]: + return "Hello World!" diff --git a/tests/data/wrong_file_type.txt b/tests/data/wrong_file_type.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/tests/data/wrong_file_type.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..1d51868 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,117 @@ +def test_missing_files(client): + response = client.post("/compile/") + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + + +def test_invalid_file_type(client): + response = client.post("/compile/", files=[("files", "some_file")]) + assert response.status_code == 400 + assert response.json() == {"detail": "Unsupported language for file upload"} # noqa: E501 + + +def test_unsupported_file_type(client): + file = open("tests/data/wrong_file_type.txt", "rb") + response = client.post("/compile/", files=[("files", file)]) + assert response.status_code == 400 + assert response.json() == { + "detail": "Unsupported language for file wrong_file_type.txt" + } # noqa: E501 + + +def test_create_compilation_task(client): + # Upload a couple valid Vyper files + files = [ + ("files", open("tests/data/hello_world.vy", "rb")), + ("files", open("tests/data/first_app.vy", "rb")), + ] + + response = client.post("/compile/", files=files, data={"vyper_version": "0.2.10"}) # noqa: E501 + + assert response.status_code == 200 + data = response.json() + assert data["id"] is not None + assert data["status"] == "PENDING" + assert data["exceptions"] == [] + assert data["manifest"] is None + + +def test_get_compilation_task(client, completed_task): + task_id = completed_task["id"] + + response = client.get(f"/compile/{task_id}") + + assert response.status_code == 200 + data = response.json() + + assert data["id"] == task_id + assert data["status"] == "SUCCESS" + assert data["exceptions"] == [] + assert data["manifest"] is not None + + +def test_status_invalid_task_id(client): + response = client.get("/status/0") + assert response.status_code == 404 + assert response.json() == {"detail": "Task id 0 not found."} + + +def test_get_task_status(client, completed_task): + task_id = completed_task["id"] + + response = client.get(f"/status/{task_id}") + + assert response.status_code == 200 + data = response.json() + + assert data in ["PENDING", "SUCCESS"] + + +def test_get_task_status_invalid_task_id(client): + response = client.get("/status/0") + assert response.status_code == 404 + assert response.json() == {"detail": "Task id 0 not found."} + + +def test_exception_invalid_task_id(client): + response = client.get("/exceptions/0") + assert response.status_code == 404 + assert response.json() == {"detail": "Task id 0 not found."} + + +def test_get_task_exceptions(client, completed_task_with_error): + task_id = completed_task_with_error.id + + # Assuming the task_id has an Error status + response = client.get(f"/exceptions/{task_id}") + assert response.status_code == 200 + data = response.json() + + assert data == ["some error"] + + +def ttest_get_compiled_artifact_invalid_task_id(client): + response = client.get("/compiled_artifact/0") + assert response.status_code == 404 + assert response.json() == {"detail": "Task id 0 not found."} + + +def test_get_compiled_artifact(client, completed_task): + task_id = completed_task["id"] + + # Assuming the task_id has a Success status + response = client.get(f"/compiled_artifact/{task_id}") + assert response.status_code == 200 + + data = response.json() + assert "sources" in data + assert "compilers" in data + assert "contractTypes" in data