diff --git a/.github/workflows/sample-tests.yaml b/.github/workflows/sample-tests.yaml index 58a11e05..1f7cb187 100644 --- a/.github/workflows/sample-tests.yaml +++ b/.github/workflows/sample-tests.yaml @@ -35,6 +35,7 @@ jobs: # Set job outputs to values from filter step outputs: java: ${{ steps.filter.outputs.java }} + python: ${{ steps.filter.outputs.python }} steps: - name: Remove PR label if: "${{ github.event.action == 'labeled' && github.event.label.name == 'tests: run' }}" @@ -59,6 +60,8 @@ jobs: filters: | java: - 'examples/java/**' + python: + - 'examples/python/**' java: # This ensures that the java job execute after the changes job, since it's dependent on # that job's output. @@ -111,3 +114,52 @@ jobs: run: | ./examples/java/run_tests.sh + python: + # This ensures that the python job executes after the changes job, since it's dependent on + # that job's output. + needs: changes + if: ${{ needs.changes.outputs.python == 'true' || github.event_name == 'schedule' }} + runs-on: [self-hosted, linux, x64] + permissions: + contents: 'read' + id-token: 'write' + steps: + - name: Checkout code + uses: 'actions/checkout@v3' + with: + ref: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - name: 'Authenticate to Google Cloud' + id: 'auth' + uses: 'google-github-actions/auth@v1.0.0' + with: + workload_identity_provider: ${{ secrets.PROVIDER_NAME }} + service_account: ${{ secrets.SERVICE_ACCOUNT }} + + - name: 'Set up Cloud SDK' + uses: 'google-github-actions/setup-gcloud@v1.0.1' + - name: Get Secrets + id: 'secrets' + uses: 'google-github-actions/get-secretmanager-secrets@v0' + with: + secrets: |- + ALLOYDB_CONN_NAME:alloydb-connector-testing/ALLOYDB_CONN_NAME + ALLOYDB_CLUSTER_PASS:alloydb-connector-testing/ALLOYDB_CLUSTER_PASS + + - name: Run lint + run: | + pip install --upgrade pip + pip install flake8 + cd examples/python + python -m flake8 . + + - name: Run tests + env: + DB_NAME: 'postgres' + DB_USER: 'postgres' + DB_PASS: '${{ steps.secrets.outputs.ALLOYDB_CLUSTER_PASS }}' + ALLOYDB_CONNECTION_NAME: '${{ steps.secrets.outputs.ALLOYDB_CONN_NAME }}' + run: | + ./examples/python/run_tests.sh + diff --git a/.gitignore b/.gitignore index 4f3b5b94..c90dbfe6 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ out/ .gradle/ .vscode/ .DS_Store +__pycache__ +.pytest_cache diff --git a/examples/java/README.md b/examples/java/README.md index 18794a1a..72dfd70f 100644 --- a/examples/java/README.md +++ b/examples/java/README.md @@ -9,15 +9,13 @@ 1. Create an AlloyDB cluster and its primary instance by following these [instructions](https://cloud.google.com/alloydb/docs/cluster-create). Make note of the Cluster ID, Instance ID, IP Address and Password 1. Create a database for your application by following these -[instructions](https://cloud.google.com/sql/docs/postgres/create-manage-databases). Note the database +[instructions](https://cloud.google.com/alloydb/docs/database-create). Note the database name. 1. Create a user in your database by following these [instructions](https://cloud.google.com/alloydb/docs/database-users/about). Note the username. -1. Create a service account with the 'AlloyDB Client' permissions by following these -[instructions](https://cloud.google.com/sql/docs/postgres/connect-external-app#4_if_required_by_your_authentication_method_create_a_service_account). -Download a JSON key to use to authenticate your connection. +1. Create a [service account](https://cloud.google.com/iam/docs/understanding-service-accounts) with the 'AlloyDB Client' permissions. 1. Use the information noted in the previous steps: ```bash @@ -26,7 +24,7 @@ export DB_USER='my-db-user' export DB_PASS='my-db-pass' export DB_NAME='my_db' export DB_HOST='' -export DB_POST=5432 +export DB_PORT=5432 export ALLOYDB_CONNECTION_NAME='projects//locations//clusters//instances/' ``` Note: Saving credentials in environment variables is convenient, but not secure - consider a more @@ -67,7 +65,7 @@ mvn clean package com.google.cloud.tools:jib-maven-plugin:2.8.0:build \ ```sh gcloud run deploy run-postgres \ - --image gcr.io/[YOUR_PROJECT_ID]/run-postgres \ + --image gcr.io/[YOUR_PROJECT_ID]/run-alloydb \ --platform managed \ --allow-unauthenticated \ --region [REGION] \ @@ -82,7 +80,7 @@ mvn clean package com.google.cloud.tools:jib-maven-plugin:2.8.0:build \ Take note of the URL output at the end of the deployment process. It is recommended to use the [Secret Manager integration](https://cloud.google.com/run/docs/configuring/secrets) for Cloud Run instead - of using environment variables for the SQL configuration. The service injects the Alloy credentials from + of using environment variables for the AlloyDB configuration. The service injects the Alloy credentials from Secret Manager at runtime via an environment variable. Create secrets via the command line: diff --git a/examples/python/.dockerignore b/examples/python/.dockerignore new file mode 100644 index 00000000..f902a83a --- /dev/null +++ b/examples/python/.dockerignore @@ -0,0 +1,4 @@ +Dockerfile +.dockerignore +__pycache__ +.pytest_cache diff --git a/examples/python/Dockerfile b/examples/python/Dockerfile new file mode 100644 index 00000000..38f4f784 --- /dev/null +++ b/examples/python/Dockerfile @@ -0,0 +1,38 @@ +# Copyright 2022 Google, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Use the official Python image. +# https://hub.docker.com/_/python +FROM python:3 + +# Copy application dependency manifests to the container image. +# Copying this separately prevents re-running pip install on every code change. +COPY requirements.txt ./ + +# Install production dependencies. +RUN set -ex; \ + pip install -r requirements.txt; \ + pip install gunicorn + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY . ./ + + +# Run the web service on container startup. Here we use the gunicorn +# webserver, with one worker process and 8 threads. +# For environments with multiple CPU cores, increase the number of workers +# to be equal to the cores available. +CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 app:app diff --git a/examples/python/README.md b/examples/python/README.md new file mode 100644 index 00000000..3c9607e7 --- /dev/null +++ b/examples/python/README.md @@ -0,0 +1,168 @@ +# Connecting to AlloyDB + +## Before you begin + +1. Enable access to AlloyDB in your project by following these [instructions](https://cloud.google.com/alloydb/docs/project-enable-access) + +1. Create a VPC network and [configure Private Services Access for AlloyDB](https://cloud.google.com/alloydb/docs/configure-connectivity) + +1. Create an AlloyDB cluster and its primary instance by following these [instructions](https://cloud.google.com/alloydb/docs/cluster-create). Make note of the Cluster ID, Instance ID, IP Address and Password + +1. Create a database for your application by following these +[instructions](https://cloud.google.com/alloydb/docs/database-create). Note the database +name. + +1. Create a user in your database by following these +[instructions](https://cloud.google.com/alloydb/docs/database-users/about). Note the username. + +1. Create a [service account](https://cloud.google.com/iam/docs/understanding-service-accounts) with the 'AlloyDB Client' permissions. + + +1. Use the information noted in the previous steps: +```bash +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service/account/key.json +export DB_USER='' +export DB_PASS='' +export DB_NAME='' +export DB_HOST='' +export DB_POST=5432 +export ALLOYDB_CONNECTION_NAME='projects//locations//clusters//instances/' +``` +Note: Saving credentials in environment variables is convenient, but not secure - consider a more +secure solution such as [Secret Manager](https://cloud.google.com/secret-manager/) to help keep secrets safe. + + +## Deploy to App Engine Standard + +To run on GAE-Standard, create an App Engine project by following the setup for these +[instructions](https://cloud.google.com/appengine/docs/standard/python3/quickstart#before-you-begin). + +First, update `app.standard.yaml` with the correct values to pass the environment +variables into the runtime. Your `app.standard.yaml` file should look like this: + +```yaml +runtime: python37 +entrypoint: gunicorn -b :$PORT app:app + +env_variables: + DB_HOST: '' + DB_PORT: 5432 + DB_USER: + DB_PASS: + DB_NAME: +``` + +Note: Saving credentials in environment variables is convenient, but not secure - consider a more +secure solution such as [Secret Manager](https://cloud.google.com/secret-manager/docs/overview) to +help keep secrets safe. + +Next, the following command will deploy the application to your Google Cloud project: + +```bash +gcloud app deploy app.standard.yaml +``` + +## Deploy to App Engine Flexible + +To run on GAE-Flexible, create an App Engine project by following the setup for these +[instructions](https://cloud.google.com/appengine/docs/flexible/python/quickstart#before-you-begin). + +First, update `app.flexible.yaml` with the correct values to pass the environment +variables into the runtime. Your `app.flexible.yaml` file should look like this: + +```yaml +runtime: custom +env: flex +entrypoint: gunicorn -b :$PORT app:app + +env_variables: + DB_HOST: '' + DB_PORT: 5432 + DB_USER: + DB_PASS: + DB_NAME: + +``` + +Note: Saving credentials in environment variables is convenient, but not secure - consider a more +secure solution such as [Secret Manager](https://cloud.google.com/secret-manager/docs/overview) to +help keep secrets safe. + +Next, the following command will deploy the application to your Google Cloud project: + +```bash +gcloud app deploy app.flexible.yaml +``` + +## Deploy to Cloud Run + +Before deploying the application, you will need to [configure a Serverless VPC Connector](https://cloud.google.com/vpc/docs/configure-serverless-vpc-access) to be able to connect to the VPC in which your AlloyDB cluster is running. +1. Build the container image: + +```sh +gcloud builds submit --tag gcr.io/[YOUR_PROJECT_ID]/run-alloydb +``` + +2. Deploy the service to Cloud Run: + + ```sh + gcloud run deploy run-alloydb \ + --image gcr.io/[YOUR_PROJECT_ID]/run-alloydb \ + --platform managed \ + --allow-unauthenticated \ + --region [REGION] \ + --update-env-vars DB_HOST=[DB_HOST] \ + --update-env-vars DB_PORT=[DB_PORT] \ + --update-env-vars DB_USER=[MY_DB_USER] \ + --update-env-vars DB_PASS=[MY_DB_PASS] \ + --update-env-vars DB_NAME=[MY_DB] + ``` + +Take note of the URL output at the end of the deployment process. + +Replace environment variables with the correct values for your AlloyDB +instance configuration. + +It is recommended to use the [Secret Manager integration](https://cloud.google.com/run/docs/configuring/secrets) for Cloud Run instead +of using environment variables for the AlloyDB configuration. The service injects the AlloyDB credentials from +Secret Manager at runtime via an environment variable. + +Create secrets via the command line: +```sh +echo -n "projects//locations//clusters//instances/" | \ + gcloud secrets versions add ALLOYDB_CONNECTION_NAME_SECRET --data-file=- +``` + +Deploy the service to Cloud Run specifying the env var name and secret name: +```sh +gcloud beta run deploy SERVICE --image gcr.io/[YOUR_PROJECT_ID]/run-alloydb \ + --update-secrets --update-secrets DB_HOST=[DB_HOST_SECRET]:latest,\ + DB_PORT=[DB_PORT_SECRET]:latest, \ + DB_USER=[DB_USER_SECRET]:latest, \ + DB_PASS=[DB_PASS_SECRET]:latest, \ + DB_NAME=[DB_NAME_SECRET]:latest +``` + +3. Navigate your browser to the URL noted in step 2. + +For more details about using Cloud Run see http://cloud.run. +Review other [Python on Cloud Run samples](../../../run/). + +## Deploy to Cloud Functions + +To deploy the service to [Cloud Functions](https://cloud.google.com/functions/docs) run the following command: + +```sh +gcloud functions deploy votes --runtime python39 --trigger-http --allow-unauthenticated \ +--set-env-vars DB_HOST=$DB_HOST \ +--set-env-vars DB_PORT=$DB_PORT \ +--set-env-vars DB_USER=$DB_USER \ +--set-env-vars DB_PASS=$DB_PASS \ + --set-env-vars DB_NAME=$DB_NAME +``` + +Take note of the URL output at the end of the deployment process or run the following to view your function: + +```sh +gcloud app browse +``` diff --git a/examples/python/app.flexible.yaml b/examples/python/app.flexible.yaml new file mode 100644 index 00000000..87ed63f7 --- /dev/null +++ b/examples/python/app.flexible.yaml @@ -0,0 +1,28 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +runtime: custom +env: flex +entrypoint: gunicorn -b :$PORT app:app + +# Note: Saving credentials in environment variables is convenient, but not +# secure - consider a more secure solution such as +# Cloud Secret Manager (https://cloud.google.com/secret-manager) to help +# keep secrets safe. +env_variables: + DB_HOST: + DB_PORT: + DB_USER: + DB_PASS: + DB_NAME: diff --git a/examples/python/app.py b/examples/python/app.py new file mode 100644 index 00000000..35d2e580 --- /dev/null +++ b/examples/python/app.py @@ -0,0 +1,155 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import logging +import os +from typing import Dict + +from flask import Flask, render_template, request, Response + +import sqlalchemy + +from connect_tcp import connect_tcp_socket + +app = Flask(__name__) + +logger = logging.getLogger() + + +def init_connection_pool() -> sqlalchemy.engine.base.Engine: + # use a TCP socket when DB_HOST (e.g. 127.0.0.1) is defined + if os.environ.get("DB_HOST"): + return connect_tcp_socket() + + raise ValueError( + "Missing database connection parameter. Please define DB_HOST" + ) + + +# create 'votes' table in database if it does not already exist +def migrate_db(db: sqlalchemy.engine.base.Engine) -> None: + with db.connect() as conn: + conn.execute( + "CREATE TABLE IF NOT EXISTS votes " + "( vote_id SERIAL NOT NULL, time_cast timestamp NOT NULL, " + "candidate VARCHAR(6) NOT NULL, PRIMARY KEY (vote_id) );" + ) + + +# This global variable is declared with a value of `None`, instead of calling +# `init_db()` immediately, to simplify testing. In general, it +# is safe to initialize your database connection pool when your script starts +# -- there is no need to wait for the first request. +db = None + + +# init_db lazily instantiates a database connection pool. Users of Cloud Run or +# App Engine may wish to skip this lazy instantiation and connect as soon +# as the function is loaded. This is primarily to help testing. +@app.before_first_request +def init_db() -> sqlalchemy.engine.base.Engine: + global db + db = init_connection_pool() + migrate_db(db) + + +@app.route("/", methods=["GET"]) +def render_index() -> str: + context = get_index_context(db) + return render_template("index.html", **context) + + +@app.route("/votes", methods=["POST"]) +def cast_vote() -> Response: + team = request.form["team"] + return save_vote(db, team) + + +# get_index_context gets data required for rendering HTML application +def get_index_context(db: sqlalchemy.engine.base.Engine) -> Dict: + votes = [] + + with db.connect() as conn: + # Execute the query and fetch all results + recent_votes = conn.execute( + "SELECT candidate, time_cast FROM votes" + " ORDER BY time_cast DESC LIMIT 5" + ).fetchall() + # Convert the results into a list of dicts representing votes + for row in recent_votes: + votes.append({"candidate": row[0], "time_cast": row[1]}) + + stmt = sqlalchemy.text( + "SELECT COUNT(vote_id) FROM votes WHERE candidate=:candidate" + ) + # Count number of votes for tabs + tab_result = conn.execute(stmt, candidate="TABS").fetchone() + tab_count = tab_result[0] + # Count number of votes for spaces + space_result = conn.execute(stmt, candidate="SPACES").fetchone() + space_count = space_result[0] + + return { + "space_count": space_count, + "recent_votes": votes, + "tab_count": tab_count, + } + + +# save_vote saves a vote to the database that was retrieved from form data +def save_vote(db: sqlalchemy.engine.base.Engine, team: str) -> Response: + time_cast = datetime.datetime.now(tz=datetime.timezone.utc) + # Verify that the team is one of the allowed options + if team != "TABS" and team != "SPACES": + logger.warning(f"Received invalid 'team' property: '{team}'") + return Response( + response=( + "Invalid team specified." + " Should be one of 'TABS' or 'SPACES'"), + status=400, + ) + + # [START cloud_sql_postgres_sqlalchemy_connection] + # Preparing a statement before hand can help protect against injections. + stmt = sqlalchemy.text( + "INSERT INTO votes (time_cast, candidate)" + " VALUES (:time_cast, :candidate)" + ) + try: + # Using a with statement ensures that the connection is always released + # back into the pool at the end of statement (even if an error occurs) + with db.connect() as conn: + conn.execute(stmt, time_cast=time_cast, candidate=team) + except Exception as e: + # If something goes wrong, handle the error in this section. This might + # involve retrying or adjusting parameters depending on the situation. + # [START_EXCLUDE] + logger.exception(e) + return Response( + status=500, + response="Unable to successfully cast vote! Please check the " + "application logs for more details.", + ) + # [END_EXCLUDE] + # [END cloud_sql_postgres_sqlalchemy_connection] + + return Response( + status=200, + response=f"Vote successfully cast for '{team}' at time {time_cast}!", + ) + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=8080, debug=True) diff --git a/examples/python/app.standard.yaml b/examples/python/app.standard.yaml new file mode 100644 index 00000000..ba9e2c90 --- /dev/null +++ b/examples/python/app.standard.yaml @@ -0,0 +1,27 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +runtime: python37 +entrypoint: gunicorn -b :$PORT app:app + +# Note: Saving credentials in environment variables is convenient, but not +# secure - consider a more secure solution such as +# Cloud Secret Manager (https://cloud.google.com/secret-manager) to help +# keep secrets safe. +env_variables: + DB_HOST: + DB_PORT: + DB_USER: + DB_PASS: + DB_NAME: diff --git a/examples/python/connect_tcp.py b/examples/python/connect_tcp.py new file mode 100644 index 00000000..7772aaf5 --- /dev/null +++ b/examples/python/connect_tcp.py @@ -0,0 +1,79 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# [START alloydb_sqlalchemy_connect_tcp] +import os + +import sqlalchemy + + +# connect_tcp_socket initializes a TCP connection pool +# for an AlloyDB instance. +def connect_tcp_socket() -> sqlalchemy.engine.base.Engine: + # Note: Saving credentials in environment variables is convenient, but not + # secure - consider a more secure solution such as + # Cloud Secret Manager (https://cloud.google.com/secret-manager) to help + # keep secrets safe. + db_host = os.environ[ + "DB_HOST" + ] # e.g. '127.0.0.1' ('172.17.0.1' if deployed to GAE Flex) + db_user = os.environ["DB_USER"] # e.g. 'my-db-user' + db_pass = os.environ["DB_PASS"] # e.g. 'my-db-password' + db_name = os.environ["DB_NAME"] # e.g. 'my-database' + db_port = os.environ["DB_PORT"] # e.g. 5432 + + pool = sqlalchemy.create_engine( + # Equivalent URL: + # postgresql+pg8000://:@:/ + sqlalchemy.engine.url.URL.create( + drivername="postgresql+pg8000", + username=db_user, + password=db_pass, + host=db_host, + port=db_port, + database=db_name, + ), + # [START_EXCLUDE] + # [START alloydb_sqlalchemy_limit] + # Pool size is the maximum number of permanent connections to keep. + pool_size=5, + # Temporarily exceeds the set pool_size if no connections are + # available. + max_overflow=2, + # The total number of concurrent connections for your application will + # be a total of pool_size and max_overflow. + # [END alloydb_sqlalchemy_limit] + # [START alloydb_sqlalchemy_backoff] + # SQLAlchemy automatically uses delays between failed connection + # attempts, but provides no arguments for configuration. + # [END alloydb_sqlalchemy_backoff] + # [START alloydb_sqlalchemy_timeout] + # 'pool_timeout' is the maximum number of seconds to wait when + # retrieving a new connection from the pool. After the specified + # amount of time, an exception will be thrown. + pool_timeout=30, # 30 seconds + # [END alloydb_sqlalchemy_timeout] + # [START alloydb_sqlalchemy_lifetime] + # 'pool_recycle' is the maximum number of seconds a connection can + # persist. Connections that live longer than the specified amount + # of time will be re-established + pool_recycle=1800, # 30 minutes + # [END alloydb_sqlalchemy_lifetime] + # [END_EXCLUDE] + ) + return pool + + +# [END alloydb_sqlalchemy_connect_tcp] diff --git a/examples/python/connection_test.py b/examples/python/connection_test.py new file mode 100644 index 00000000..0440d756 --- /dev/null +++ b/examples/python/connection_test.py @@ -0,0 +1,46 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from flask.testing import FlaskClient + +import pytest + +import app + +logger = logging.getLogger() + + +@pytest.fixture(scope="module") +def client() -> FlaskClient: + app.app.testing = True + client = app.app.test_client() + return client + + +def test_get_votes(client: FlaskClient) -> None: + response = client.get("/") + text = "Tabs VS Spaces" + body = response.text + assert response.status_code == 200 + assert text in body + + +def test_cast_vote(client: FlaskClient) -> None: + response = client.post("/votes", data={"team": "SPACES"}) + text = "Vote successfully cast for 'SPACES'" + body = response.text + assert response.status_code == 200 + assert text in body diff --git a/examples/python/main.py b/examples/python/main.py new file mode 100644 index 00000000..7e44bb8f --- /dev/null +++ b/examples/python/main.py @@ -0,0 +1,41 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from flask import render_template, Response + +from app import get_index_context, init_connection_pool, migrate_db, save_vote + +# TABS vs. SPACES App for Cloud Functions + +# initiate a connection pool to a Cloud SQL database +db = init_connection_pool() +# creates required 'votes' table in database (if it does not exist) +migrate_db(db) + + +def votes(request): + if request.method == "GET": + context = get_index_context(db) + return render_template("index.html", **context) + + if request.method == "POST": + team = request.form["team"] + return save_vote(db, team) + + return Response( + response=( + "Invalid http request." + " Method not allowed, must be 'GET' or 'POST'"), + status=400, + ) diff --git a/examples/python/requirements-test.txt b/examples/python/requirements-test.txt new file mode 100644 index 00000000..c2845bff --- /dev/null +++ b/examples/python/requirements-test.txt @@ -0,0 +1 @@ +pytest==7.0.1 diff --git a/examples/python/requirements.txt b/examples/python/requirements.txt new file mode 100644 index 00000000..5055271f --- /dev/null +++ b/examples/python/requirements.txt @@ -0,0 +1,4 @@ +Flask==2.1.0 +pg8000==1.24.2 +SQLAlchemy==1.4.38 +gunicorn==20.1.0 diff --git a/examples/python/run_tests.sh b/examples/python/run_tests.sh new file mode 100755 index 00000000..292e5988 --- /dev/null +++ b/examples/python/run_tests.sh @@ -0,0 +1,40 @@ +#! /bin/bash +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# `-e` enables the script to automatically fail when a command fails +set -ex + +# download the proxy and run it in the background listening on 127.0.0.1:5432 +URL="https://storage.googleapis.com/alloydb-auth-proxy/v0.6.2" +wget "$URL/alloydb-auth-proxy.linux.amd64" -O alloydb-auth-proxy +chmod +x alloydb-auth-proxy +./alloydb-auth-proxy "${ALLOYDB_CONNECTION_NAME}" & +export DB_HOST="127.0.0.1" +export DB_PORT=5432 +ps +PROXY_PID="$(pgrep alloydb)" +trap 'kill ${PROXY_PID}' 1 2 3 6 15 + +cd examples/python + +pip install -r requirements.txt +pip install -r requirements-test.txt + +# log python version info +echo "Running tests using Python:" +python --version +python -m pytest --version + +python -m pytest --tb=long . diff --git a/examples/python/templates/index.html b/examples/python/templates/index.html new file mode 100644 index 00000000..0761145e --- /dev/null +++ b/examples/python/templates/index.html @@ -0,0 +1,98 @@ + + + + Tabs VS Spaces + + + + + + +
+
+

+ {% if tab_count == space_count %} + TABS and SPACES are evenly matched! + {% elif tab_count > space_count %} + TABS are winning by {{tab_count - space_count}} + {{'votes' if tab_count - space_count > 1 else 'vote'}}! + {% elif space_count > tab_count %} + SPACES are winning by {{space_count - tab_count}} + {{'votes' if space_count - tab_count > 1 else 'vote'}}! + {% endif %} +

+
+
+
+
+ keyboard_tab +

{{tab_count}} votes

+ +
+
+
+
+ space_bar +

{{space_count}} votes

+ +
+
+
+

Recent Votes

+
    + {% for vote in recent_votes %} +
  • + {% if vote.candidate == "TABS" %} + keyboard_tab + {% elif vote.candidate == "SPACES" %} + space_bar + {% endif %} + + A vote for {{vote.candidate}} + +

    was cast at {{vote.time_cast}}

    +
  • + {% endfor %} +
+
+ + +