From 82ca432b6138e4ea390b89340dea329f17f03058 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Thu, 14 Mar 2024 08:14:58 +0200 Subject: [PATCH 01/23] add format and devcontainer ci --- .github/workflows/devcontaienr-ci.yml | 30 ++++++++++++++++++++++ .github/workflows/format.yml | 37 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 .github/workflows/devcontaienr-ci.yml create mode 100644 .github/workflows/format.yml diff --git a/.github/workflows/devcontaienr-ci.yml b/.github/workflows/devcontaienr-ci.yml new file mode 100644 index 0000000..9c83f3b --- /dev/null +++ b/.github/workflows/devcontaienr-ci.yml @@ -0,0 +1,30 @@ +name: Check Dev Container + +on: + push: + branches: [ "main" ] + paths: + - ".devcontainer/**" + - ".github/workflows/devcontainer-ci.yaml" + pull_request: + branches: [ "main" ] + paths: + - ".devcontainer/**" + - ".github/workflows/devcontainer-ci.yaml" + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20.x + - run: npm install -g @devcontainers/cli + - run: devcontainer build --config ./.devcontainer/devcontainer.json --workspace-folder "$(pwd)" \ No newline at end of file diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..32744b8 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,37 @@ +name: Run Python linter and formatter + +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' + - 'lab/**' + - 'assets/**' + + pull_request: + branches: [ main ] + paths-ignore: + - '**.md' + - 'lab/**' + - 'assets/**' + +jobs: + checks-format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.12 + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Lint with ruff + run: | + ruff check . + ruff format . + - name: Format with black + run: | + black . \ No newline at end of file From 365f813adee9bac8452292bf0aca20e12f97ceff Mon Sep 17 00:00:00 2001 From: John Aziz Date: Thu, 14 Mar 2024 08:15:15 +0200 Subject: [PATCH 02/23] add devcontainer --- .devcontainer/.devcontainer.json | 52 ++++++++++++++++++++++++++++ .devcontainer/Dockerfile_dev | 11 ++++++ .devcontainer/docker-compose_dev.yml | 41 ++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 .devcontainer/.devcontainer.json create mode 100644 .devcontainer/Dockerfile_dev create mode 100644 .devcontainer/docker-compose_dev.yml diff --git a/.devcontainer/.devcontainer.json b/.devcontainer/.devcontainer.json new file mode 100644 index 0000000..ea824cd --- /dev/null +++ b/.devcontainer/.devcontainer.json @@ -0,0 +1,52 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose +{ + "name": "flask_api_sqlite_db", + + // Update the 'dockerComposeFile' list if you have more compose files or use different names. + // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. + "dockerComposeFile": "docker-compose_dev.yml", + // The 'service' property is the name of the service for the container that VS Code should + // use. Update this value and .devcontainer/docker-compose.yml to the real service name. + "service": "app", + "shutdownAction": "stopCompose", + "workspaceFolder": "/workspace", + "forwardPorts": [5000, 3306], + "portsAttributes": { + "5000": {"label": "Web port", "onAutoForward": "notify"}, + "3306": {"label": "MySQL Port", "onAutoForward": "silent"} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.vscode-bicep", + "charliermarsh.ruff", + "ms-python.python", + "bierner.github-markdown-preview" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "files.exclude": { + ".coverage": true, + ".pytest_cache": true, + "__pycache__": true, + ".ruff_cache": true + }, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": true, + "source.fixAll": true + } + } + } + } + }, + "features": { + "ghcr.io/azure/azure-dev/azd:latest": {} + }, + "postCreateCommand": "pip install -e src && python3 -m flask --app src/flaskapp db upgrade --directory src/flaskapp/migrations" +} \ No newline at end of file diff --git a/.devcontainer/Dockerfile_dev b/.devcontainer/Dockerfile_dev new file mode 100644 index 0000000..daa7646 --- /dev/null +++ b/.devcontainer/Dockerfile_dev @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/devcontainers/python:3.12-bullseye + +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends default-mysql-server \ + && apt-get clean -y && rm -rf /var/lib/apt/lists/* + + +COPY requirements-dev.txt requirements-dev.txt +COPY src/requirements.txt src/requirements.txt +RUN python -m pip install --upgrade pip +RUN python -m pip install -r requirements-dev.txt \ No newline at end of file diff --git a/.devcontainer/docker-compose_dev.yml b/.devcontainer/docker-compose_dev.yml new file mode 100644 index 0000000..0011ddd --- /dev/null +++ b/.devcontainer/docker-compose_dev.yml @@ -0,0 +1,41 @@ +version: '3' +services: + db: + image: mysql:latest + + environment: + MYSQL_ROOT_PASSWORD: mysql + MYSQL_DATABASE: relecloud + + restart: unless-stopped + + volumes: + - mysql-data:/var/lib/mysql + + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: + context: .. + dockerfile: ./.devcontainer/Dockerfile_dev + depends_on: + db: + condition: service_healthy + network_mode: service:db + environment: + MYSQL_HOST: db + MYSQL_USER: root + MYSQL_PASS: mysql + MYSQL_DATABASE: relecloud + + command: sleep infinity + + volumes: + - ..:/workspace:cached + +volumes: + mysql-data: \ No newline at end of file From 385910ed2e6863e560ed3571729c844240c2a8a4 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Thu, 14 Mar 2024 08:17:36 +0200 Subject: [PATCH 03/23] add branch to .github --- .github/workflows/devcontaienr-ci.yml | 4 ++-- .github/workflows/format.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/devcontaienr-ci.yml b/.github/workflows/devcontaienr-ci.yml index 9c83f3b..a0db723 100644 --- a/.github/workflows/devcontaienr-ci.yml +++ b/.github/workflows/devcontaienr-ci.yml @@ -2,12 +2,12 @@ name: Check Dev Container on: push: - branches: [ "main" ] + branches: [ 'main', 'improved-api'] paths: - ".devcontainer/**" - ".github/workflows/devcontainer-ci.yaml" pull_request: - branches: [ "main" ] + branches: [ 'main', 'improved-api' ] paths: - ".devcontainer/**" - ".github/workflows/devcontainer-ci.yaml" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 32744b8..ae41a7d 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -2,14 +2,14 @@ name: Run Python linter and formatter on: push: - branches: [ main ] + branches: [ 'main', 'improved-api'] paths-ignore: - '**.md' - 'lab/**' - 'assets/**' pull_request: - branches: [ main ] + branches: [ 'main', 'improved-api' ] paths-ignore: - '**.md' - 'lab/**' From 5388d71e14e5c530cf78186984606579a0411662 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Thu, 14 Mar 2024 08:18:14 +0200 Subject: [PATCH 04/23] change name of devcontianer --- .devcontainer/.devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/.devcontainer.json b/.devcontainer/.devcontainer.json index ea824cd..b1124e9 100644 --- a/.devcontainer/.devcontainer.json +++ b/.devcontainer/.devcontainer.json @@ -1,7 +1,7 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose { - "name": "flask_api_sqlite_db", + "name": "flask_api_mysql_db", // Update the 'dockerComposeFile' list if you have more compose files or use different names. // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. From 573471481dbbcc5f16f6ffe24a818c5477c1964b Mon Sep 17 00:00:00 2001 From: John Aziz Date: Thu, 14 Mar 2024 08:20:52 +0200 Subject: [PATCH 05/23] Add requirements --- requirements-dev.txt | 11 +++++++++++ requirements.txt | 3 --- src/pyproject.toml | 15 +++++++++++++++ src/requirements.txt | 2 ++ 4 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 requirements-dev.txt delete mode 100644 requirements.txt create mode 100644 src/pyproject.toml create mode 100644 src/requirements.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ac548d8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,11 @@ +-r src/requirements.txt + +# Testing Tools +pytest +ephemeral-port-reserve +coverage +pytest-cov + +# Linters +ruff +black \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index efdc915..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -SQLAlchemy==2.0.27 -Flask==3.0.2 -Flask-SQLAlchemy==3.1.1 \ No newline at end of file diff --git a/src/pyproject.toml b/src/pyproject.toml new file mode 100644 index 0000000..cc80ca9 --- /dev/null +++ b/src/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "flaskapp" +version = "1.0.0" +description = "API for Managing test cases and their execution results across multiple test assets, with data stored in a SQLite database." +dependencies = [ + "Flask==2.3.2", + "SQLAlchemy==2.0.17", + "Flask-Migrate==4.0.4", + "Flask-SQLAlchemy==3.1.1", + "mysql-connector-python==8.3.0", + ] + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..bcf9079 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,2 @@ +# global requirements +gunicorn==20.1.0 From f11110443c620bf85d12db95054c6097f143adef Mon Sep 17 00:00:00 2001 From: John Aziz Date: Thu, 14 Mar 2024 08:23:57 +0200 Subject: [PATCH 06/23] convert app to package and add migrations --- .github/workflows/format.yml | 5 +- flask_test.py | 14 ++- src/database/__init__.py | 0 src/entrypoint.sh | 6 + src/flaskapp/__init__.py | 3 + src/{ => flaskapp}/app.py | 39 +++++-- src/{ => flaskapp/config}/__init__.py | 0 src/{ => flaskapp}/config/development.py | 2 +- src/{config => flaskapp/database}/__init__.py | 0 src/{ => flaskapp}/database/models.py | 15 ++- src/{ => flaskapp}/database/testdb.db | Bin 16384 -> 24576 bytes src/flaskapp/migrations/README | 1 + src/flaskapp/migrations/alembic.ini | 50 +++++++++ src/flaskapp/migrations/env.py | 106 ++++++++++++++++++ src/flaskapp/migrations/script.py.mako | 24 ++++ src/gunicorn.conf.py | 11 ++ 16 files changed, 255 insertions(+), 21 deletions(-) delete mode 100644 src/database/__init__.py create mode 100644 src/entrypoint.sh create mode 100644 src/flaskapp/__init__.py rename src/{ => flaskapp}/app.py (88%) rename src/{ => flaskapp/config}/__init__.py (100%) rename src/{ => flaskapp}/config/development.py (76%) rename src/{config => flaskapp/database}/__init__.py (100%) rename src/{ => flaskapp}/database/models.py (89%) rename src/{ => flaskapp}/database/testdb.db (62%) create mode 100644 src/flaskapp/migrations/README create mode 100644 src/flaskapp/migrations/alembic.ini create mode 100644 src/flaskapp/migrations/env.py create mode 100644 src/flaskapp/migrations/script.py.mako create mode 100644 src/gunicorn.conf.py diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index ae41a7d..b7d9319 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -28,10 +28,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt - - name: Lint with ruff + - name: Check linting with ruff run: | ruff check . ruff format . - - name: Format with black - run: | - black . \ No newline at end of file diff --git a/flask_test.py b/flask_test.py index 26203c7..3d0a82c 100644 --- a/flask_test.py +++ b/flask_test.py @@ -15,9 +15,17 @@ def setUp(self): self.app = create_app(config_override) self.client = self.app.test_client - self.new_test_case = {"name": "New Test Case", "description": "New Test Case Description"} - - self.new_execution = {"asset_id": "1", "test_case_id": "1", "status": True, "details": "Success"} + self.new_test_case = { + "name": "New Test Case", + "description": "New Test Case Description", + } + + self.new_execution = { + "asset_id": "1", + "test_case_id": "1", + "status": True, + "details": "Success", + } def tearDown(self): """Executed after each test""" diff --git a/src/database/__init__.py b/src/database/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/entrypoint.sh b/src/entrypoint.sh new file mode 100644 index 0000000..a15ade2 --- /dev/null +++ b/src/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +python -m pip install --upgrade pip +python -m pip install -e . +python -m flask --app flaskapp db upgrade --directory flaskapp/migrations +python -m gunicorn flaskapp \ No newline at end of file diff --git a/src/flaskapp/__init__.py b/src/flaskapp/__init__.py new file mode 100644 index 0000000..9a6f2ea --- /dev/null +++ b/src/flaskapp/__init__.py @@ -0,0 +1,3 @@ +from flaskapp.app import create_app + +app = create_app() diff --git a/src/app.py b/src/flaskapp/app.py similarity index 88% rename from src/app.py rename to src/flaskapp/app.py index 0e7f17c..28138e0 100644 --- a/src/app.py +++ b/src/flaskapp/app.py @@ -3,7 +3,7 @@ from flask import Flask, abort, jsonify, request -from src.database.models import ( +from flaskapp.database.models import ( Asset, Execution, TestCase, @@ -32,13 +32,15 @@ def create_app(test_config=None): is_prod_env = "RUNNING_IN_PRODUCTION" in os.environ if not is_prod_env: logging.info("Loading config.development.") - app.config.from_object("src.config.development") + app.config.from_object("flaskapp.config.development") setup_db(app) + # db_drop_and_create_all(app) else: logging.info("Loading config.production.") - app.config.from_object("src.config.production") + app.config.from_object("flaskapp.config.production") setup_db(app) + # db_drop_and_create_all(app) # ----------------------------------------------------------------------------# @@ -47,7 +49,9 @@ def create_app(test_config=None): @app.route("/") def index(): - return jsonify({"success": True, "message": "Welcome to the test case management API"}) + return jsonify( + {"success": True, "message": "Welcome to the test case management API"} + ) # ----------------------------------------------------------------------------# # Test cases. @@ -194,7 +198,12 @@ def get_executions(asset_id: int): @app.route("/executions", methods=["POST"]) def add_execution(): body = request.get_json() - if "status" not in body or "details" not in body or "asset_id" not in body or "test_case_id" not in body: + if ( + "status" not in body + or "details" not in body + or "asset_id" not in body + or "test_case_id" not in body + ): abort( 400, "The request body must contain 'status', 'details', 'asset_id', and 'test_case_id' fields.", @@ -242,35 +251,45 @@ def add_execution(): @app.errorhandler(400) def bad_request(error): return ( - jsonify({"success": False, "error": error.code, "message": error.description}), + jsonify( + {"success": False, "error": error.code, "message": error.description} + ), error.code, ) @app.errorhandler(404) def not_found(error): return ( - jsonify({"success": False, "error": error.code, "message": error.description}), + jsonify( + {"success": False, "error": error.code, "message": error.description} + ), error.code, ) @app.errorhandler(405) def method_not_allowed(error): return ( - jsonify({"success": False, "error": error.code, "message": error.description}), + jsonify( + {"success": False, "error": error.code, "message": error.description} + ), error.code, ) @app.errorhandler(422) def unprocessable(error): return ( - jsonify({"success": False, "error": error.code, "message": error.description}), + jsonify( + {"success": False, "error": error.code, "message": error.description} + ), error.code, ) @app.errorhandler(500) def internal_server_error(error): return ( - jsonify({"success": False, "error": error.code, "message": error.description}), + jsonify( + {"success": False, "error": error.code, "message": error.description} + ), error.code, ) diff --git a/src/__init__.py b/src/flaskapp/config/__init__.py similarity index 100% rename from src/__init__.py rename to src/flaskapp/config/__init__.py diff --git a/src/config/development.py b/src/flaskapp/config/development.py similarity index 76% rename from src/config/development.py rename to src/flaskapp/config/development.py index b13a393..4f2219c 100644 --- a/src/config/development.py +++ b/src/flaskapp/config/development.py @@ -3,7 +3,7 @@ DEBUG = True -database_filename = os.environ["DATABASE_FILENAME"] +database_filename = os.environ.get("DATABASE_FILENAME", "testdb.db") BASE_DIR = Path(__file__).resolve().parent.parent database_dir = os.path.join(BASE_DIR, "database") DATABASE_URI = f"sqlite:///{os.path.join(database_dir, database_filename)}" diff --git a/src/config/__init__.py b/src/flaskapp/database/__init__.py similarity index 100% rename from src/config/__init__.py rename to src/flaskapp/database/__init__.py diff --git a/src/database/models.py b/src/flaskapp/database/models.py similarity index 89% rename from src/database/models.py rename to src/flaskapp/database/models.py index 4a82912..4f2c670 100644 --- a/src/database/models.py +++ b/src/flaskapp/database/models.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import List +from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from sqlalchemy import Boolean, DateTime, ForeignKey, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -15,6 +16,7 @@ class Base(DeclarativeBase): db = SQLAlchemy(model_class=Base) +migrate = Migrate() """ setup_db(app) @@ -27,6 +29,7 @@ def setup_db(app): app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.app = app db.init_app(app) + migrate.init_app(app, db) with app.app_context(): db.create_all() @@ -55,7 +58,9 @@ class TestCase(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(String(500), nullable=True) - executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="test_case") + executions: Mapped[List["Execution"]] = relationship( + "Execution", back_populates="test_case" + ) def __init__(self, name, description): self.name = name @@ -81,7 +86,9 @@ class Asset(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="asset") + executions: Mapped[List["Execution"]] = relationship( + "Execution", back_populates="asset" + ) def __init__(self, name): self.name = name @@ -106,7 +113,9 @@ class Execution(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) test_case_id: Mapped[int] = mapped_column(ForeignKey("test_case.id")) - test_case: Mapped["TestCase"] = relationship("TestCase", back_populates="executions") + test_case: Mapped["TestCase"] = relationship( + "TestCase", back_populates="executions" + ) asset_id: Mapped[int] = mapped_column(ForeignKey("asset.id")) asset: Mapped["Asset"] = relationship("Asset", back_populates="executions") timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/src/database/testdb.db b/src/flaskapp/database/testdb.db similarity index 62% rename from src/database/testdb.db rename to src/flaskapp/database/testdb.db index 331b4a4ead8a5ee77b5fd4216db603254a37576d..49afe377e817e34cdcf98170824c98709646cb12 100644 GIT binary patch delta 895 zcmZo@U~D+RI6+!aoq>UY4TxcYWulI;xH^NLe=RR>Jp(Jx6bAl8zSq3%JX3fbHa5!f z)HiCfu#0PJGd6LSBqrsgCg!B(CS@kam!%dJXXfXjaao;%TpdGP6+#@Hd|VaKBo#Ec zIHB6&^Gb6S!W@H~Jsg8HjEyuE{QN@{{6c+vbQHKao&EiSLxLPV{X&2y)T3AzUyz-w z5D?_)>lhTN;O!czpn+tdCYPoyE1P(rHX}o3UP@|3abZqoNosszX-PhqACKmqctbP+ zP`Ge_0;VaEnO$67p0QQ1Wb-86d?soBTMS%$1`PZX{3rPr^B3~F^1b2P!`H(X!)LHr zQ9y*xK#PloLEYXjwOk=2wYWsVIk7kug`?n-TAW;zSx^EDGd)fghRLq-IxKn|Fa|q> zQLn{DyfNC0EDZXVp#>?4C8;S0Yuz%7fDQn=0?AG-R$>gEoGqWv0`$My=6mvL0&={( zcNqAu@~`5r=MUl6;QP;ajc*NK6JI2sJ|7$J9iYc|@z!hbvNLdUI&w-I85o)98W;nS zf}xR>sil>HrJlK&vALyXaA|UKYH_hT4@9LTs!9`MD`R6lb8{mD3qznvph699tVS9G z6nFDT&%Y!+15$3Jm`4I38< F7XSml?#ciF delta 138 zcmZoTz}V2hI6+!agn@y91&CpQd7_T7un2>me=RRih?%#Yfj^P&HE;W7MFCOX&F#EZ zOoBjh7XCs8{#*Pf`4{sS@;h!8R50RaG?<(qr!%=wK9fa-k%eLNJ$W?&A$GoN4E$I5 gSMk^Lhwy9g{pY&|RC|DrNu7N%qrJrD4|W2K09JD!5dZ)H diff --git a/src/flaskapp/migrations/README b/src/flaskapp/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/src/flaskapp/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/src/flaskapp/migrations/alembic.ini b/src/flaskapp/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/src/flaskapp/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/src/flaskapp/migrations/env.py b/src/flaskapp/migrations/env.py new file mode 100644 index 0000000..bd7d3e4 --- /dev/null +++ b/src/flaskapp/migrations/env.py @@ -0,0 +1,106 @@ +import logging +from logging.config import fileConfig + +from alembic import context +from flask import current_app + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger("alembic.env") + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions["migrate"].db.get_engine() + except TypeError: + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions["migrate"].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace("%", "%%") + except AttributeError: + return str(get_engine().url).replace("%", "%%") + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option("sqlalchemy.url", get_engine_url()) +target_db = current_app.extensions["migrate"].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, "metadatas"): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, "autogenerate", False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info("No changes in schema detected.") + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + process_revision_directives=process_revision_directives, + **current_app.extensions["migrate"].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/flaskapp/migrations/script.py.mako b/src/flaskapp/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/src/flaskapp/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/src/gunicorn.conf.py b/src/gunicorn.conf.py new file mode 100644 index 0000000..7bfb7a9 --- /dev/null +++ b/src/gunicorn.conf.py @@ -0,0 +1,11 @@ +import multiprocessing + +max_requests = 1000 +max_requests_jitter = 50 +log_file = "-" +bind = "0.0.0.0:5000" +workers = (multiprocessing.cpu_count() * 2) + 1 + +threads = workers + +timeout = 600 From c4e79f758f6eb2564d49998acfa3081208d0b0db Mon Sep 17 00:00:00 2001 From: John Aziz Date: Thu, 14 Mar 2024 08:25:37 +0200 Subject: [PATCH 07/23] add ruff configuration --- pyproject.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3c0020c..91c16eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,13 @@ line-length = 120 select = ["E", "F", "I", "UP"] ignore = ["D203"] +extend-exclude = ["src/flaskapp/migrations/"] [tool.ruff.isort] -known-first-party = ["src"] \ No newline at end of file +known-first-party = ["flaskapp"] + +[tool.pytest.ini_options] +addopts = "-ra -vv" + +[tool.coverage.report] +show_missing = true \ No newline at end of file From 5dec3601f3174701a0126556801d47f047edc911 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Thu, 14 Mar 2024 08:28:03 +0200 Subject: [PATCH 08/23] format with ruff --- src/flaskapp/app.py | 31 +++++++------------------------ src/flaskapp/database/models.py | 12 +++--------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/src/flaskapp/app.py b/src/flaskapp/app.py index 28138e0..2ef2fa2 100644 --- a/src/flaskapp/app.py +++ b/src/flaskapp/app.py @@ -49,9 +49,7 @@ def create_app(test_config=None): @app.route("/") def index(): - return jsonify( - {"success": True, "message": "Welcome to the test case management API"} - ) + return jsonify({"success": True, "message": "Welcome to the test case management API"}) # ----------------------------------------------------------------------------# # Test cases. @@ -198,12 +196,7 @@ def get_executions(asset_id: int): @app.route("/executions", methods=["POST"]) def add_execution(): body = request.get_json() - if ( - "status" not in body - or "details" not in body - or "asset_id" not in body - or "test_case_id" not in body - ): + if "status" not in body or "details" not in body or "asset_id" not in body or "test_case_id" not in body: abort( 400, "The request body must contain 'status', 'details', 'asset_id', and 'test_case_id' fields.", @@ -251,45 +244,35 @@ def add_execution(): @app.errorhandler(400) def bad_request(error): return ( - jsonify( - {"success": False, "error": error.code, "message": error.description} - ), + jsonify({"success": False, "error": error.code, "message": error.description}), error.code, ) @app.errorhandler(404) def not_found(error): return ( - jsonify( - {"success": False, "error": error.code, "message": error.description} - ), + jsonify({"success": False, "error": error.code, "message": error.description}), error.code, ) @app.errorhandler(405) def method_not_allowed(error): return ( - jsonify( - {"success": False, "error": error.code, "message": error.description} - ), + jsonify({"success": False, "error": error.code, "message": error.description}), error.code, ) @app.errorhandler(422) def unprocessable(error): return ( - jsonify( - {"success": False, "error": error.code, "message": error.description} - ), + jsonify({"success": False, "error": error.code, "message": error.description}), error.code, ) @app.errorhandler(500) def internal_server_error(error): return ( - jsonify( - {"success": False, "error": error.code, "message": error.description} - ), + jsonify({"success": False, "error": error.code, "message": error.description}), error.code, ) diff --git a/src/flaskapp/database/models.py b/src/flaskapp/database/models.py index 4f2c670..ad08981 100644 --- a/src/flaskapp/database/models.py +++ b/src/flaskapp/database/models.py @@ -58,9 +58,7 @@ class TestCase(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(String(500), nullable=True) - executions: Mapped[List["Execution"]] = relationship( - "Execution", back_populates="test_case" - ) + executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="test_case") def __init__(self, name, description): self.name = name @@ -86,9 +84,7 @@ class Asset(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - executions: Mapped[List["Execution"]] = relationship( - "Execution", back_populates="asset" - ) + executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="asset") def __init__(self, name): self.name = name @@ -113,9 +109,7 @@ class Execution(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) test_case_id: Mapped[int] = mapped_column(ForeignKey("test_case.id")) - test_case: Mapped["TestCase"] = relationship( - "TestCase", back_populates="executions" - ) + test_case: Mapped["TestCase"] = relationship("TestCase", back_populates="executions") asset_id: Mapped[int] = mapped_column(ForeignKey("asset.id")) asset: Mapped["Asset"] = relationship("Asset", back_populates="executions") timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) From a5a1f46b68e1bb62b49aca8c82bb5b60c3c06c76 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 15:27:01 +0000 Subject: [PATCH 09/23] fix file naem --- .devcontainer/{.devcontainer.json => devcontainer.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .devcontainer/{.devcontainer.json => devcontainer.json} (100%) diff --git a/.devcontainer/.devcontainer.json b/.devcontainer/devcontainer.json similarity index 100% rename from .devcontainer/.devcontainer.json rename to .devcontainer/devcontainer.json From 04a2d5fe812dc778832e0c5b0a00f5cfc4173520 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 15:27:32 +0000 Subject: [PATCH 10/23] auto generate requirements using pip-tools --- src/flaskapp/database/models.py | 1 + src/pyproject.toml | 11 ++++--- src/requirements.txt | 54 ++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/flaskapp/database/models.py b/src/flaskapp/database/models.py index ad08981..75b1092 100644 --- a/src/flaskapp/database/models.py +++ b/src/flaskapp/database/models.py @@ -2,6 +2,7 @@ Models for MySQL """ + from datetime import datetime from typing import List diff --git a/src/pyproject.toml b/src/pyproject.toml index cc80ca9..d4f8260 100644 --- a/src/pyproject.toml +++ b/src/pyproject.toml @@ -3,11 +3,12 @@ name = "flaskapp" version = "1.0.0" description = "API for Managing test cases and their execution results across multiple test assets, with data stored in a SQLite database." dependencies = [ - "Flask==2.3.2", - "SQLAlchemy==2.0.17", - "Flask-Migrate==4.0.4", - "Flask-SQLAlchemy==3.1.1", - "mysql-connector-python==8.3.0", + "flask", + "sqlalchemy", + "flask-migrate", + "flask-sqlalchemy", + "mysql-connector-python", + "gunicorn" ] [build-system] diff --git a/src/requirements.txt b/src/requirements.txt index bcf9079..194dd35 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,2 +1,54 @@ -# global requirements +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=requirements.txt pyproject.toml +# +alembic==1.13.1 + # via flask-migrate +blinker==1.7.0 + # via flask +click==8.1.7 + # via flask +flask==3.0.2 + # via + # flask-migrate + # flask-sqlalchemy + # flaskapp (pyproject.toml) +flask-migrate==4.0.7 + # via flaskapp (pyproject.toml) +flask-sqlalchemy==3.1.1 + # via + # flask-migrate + # flaskapp (pyproject.toml) +greenlet==3.0.3 + # via sqlalchemy gunicorn==20.1.0 + # via flaskapp (pyproject.toml) +itsdangerous==2.1.2 + # via flask +jinja2==3.1.3 + # via flask +mako==1.3.2 + # via alembic +markupsafe==2.1.5 + # via + # jinja2 + # mako + # werkzeug +mysql-connector-python==8.3.0 + # via flaskapp (pyproject.toml) +sqlalchemy==2.0.28 + # via + # alembic + # flask-sqlalchemy + # flaskapp (pyproject.toml) +typing-extensions==4.10.0 + # via + # alembic + # sqlalchemy +werkzeug==3.0.1 + # via flask + +# The following packages are considered to be unsafe in a requirements file: +# setuptools From ca73c93702c4c27b232978fa69ea96f4a9bed147 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 15:28:00 +0000 Subject: [PATCH 11/23] fix configuration for pyproject --- pyproject.toml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 91c16eb..1a39d94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,21 @@ [tool.ruff] line-length = 120 -select = ["E", "F", "I", "UP"] -ignore = ["D203"] +lint.select = ["E", "F", "I", "UP"] +target-version = "py312" extend-exclude = ["src/flaskapp/migrations/"] +src = ["src"] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["flaskapp"] +[tool.black] +line-length = 120 +target-version = ["py312"] +extend-exclude = "src/flaskapp/migrations/" + [tool.pytest.ini_options] -addopts = "-ra -vv" +addopts = "-ra --cov" +pythonpath = ["src"] [tool.coverage.report] show_missing = true \ No newline at end of file From 814684f8ce3f23b339922469cc94db350c5eb37b Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 15:30:04 +0000 Subject: [PATCH 12/23] add correct requirements --- requirements-dev.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index ac548d8..7baff0d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,8 +3,13 @@ # Testing Tools pytest ephemeral-port-reserve +pytest-playwright coverage pytest-cov +axe-playwright-python + +pre-commit +pip-tools # Linters ruff From 03ad5d1d787bd6c4b33ce2ca3d51b51b3c155db1 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 15:30:51 +0000 Subject: [PATCH 13/23] docs: update instructions --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 996a239..acccc34 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,11 @@ To run the Flask application, follow these steps: cd flask-api-sqlite-db ``` -1. **Install, initialize and activate a virtualenv using:** +1. **Initialize and activate a virtualenv using:** ```bash - pip install virtualenv - python -m virtualenv venv - source venv/bin/activate + python3 -m venv .venv + source .venv/bin/activate ``` >**Note** - In Windows, the `venv` does not have a `bin` directory. Therefore, you'd use the analogous command shown below: @@ -26,20 +25,25 @@ To run the Flask application, follow these steps: source venv\Scripts\activate ``` -1. **Install the dependencies:** +1. **Install the app as an editable package:** ```bash - pip install -r requirements.txt + python3 -m pip install -e src ``` -1. **Execute the following command in your terminal to start the flask app** +1. **Execute the following command to add the database name and apply the migrations:** ```bash export DATABASE_FILENAME=testdb.db - export FLASK_APP=src.app - export FLASK_ENV=development - flask run --reload + python3 -m flask --app src.flaskapp db upgrade --directory src/flaskapp/migrations ``` + +1. **Execute the following command to run the flask application:** + + ```bash + python3 -m flask --app src.flaskapp run --reload + ``` + ### Run the tests 1. **Inside your virtual environment, execute the following command to run the tests** From 1d0aefb948bffa9204ba7e044c5ad9d556684f70 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 15:45:33 +0000 Subject: [PATCH 14/23] Generate for python3.12 and format --- src/flaskapp/database/models.py | 5 ++--- src/requirements.txt | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/flaskapp/database/models.py b/src/flaskapp/database/models.py index 75b1092..670c2ee 100644 --- a/src/flaskapp/database/models.py +++ b/src/flaskapp/database/models.py @@ -4,7 +4,6 @@ """ from datetime import datetime -from typing import List from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy @@ -59,7 +58,7 @@ class TestCase(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(String(500), nullable=True) - executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="test_case") + executions: Mapped[list["Execution"]] = relationship("Execution", back_populates="test_case") def __init__(self, name, description): self.name = name @@ -85,7 +84,7 @@ class Asset(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="asset") + executions: Mapped[list["Execution"]] = relationship("Execution", back_populates="asset") def __init__(self, name): self.name = name diff --git a/src/requirements.txt b/src/requirements.txt index 194dd35..986f4b9 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --output-file=requirements.txt pyproject.toml From 382cd572e745b0b9a8bd8252be2802251b9e2189 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 15:52:31 +0000 Subject: [PATCH 15/23] add pre commit --- .pre-commit-config.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cca9034 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +exclude: '^tests/snapshots/' +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.14 + hooks: + - id: ruff +- repo: https://github.com/psf/black + rev: 24.1.0 + hooks: + - id: black +- repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + types_or: [css, javascript, ts, tsx, html] From fcc6a2f23470ee0b130aa362a69f067338a3def3 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 16:22:23 +0000 Subject: [PATCH 16/23] add black to format workflow --- .github/workflows/format.yml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b7d9319..d3aa28d 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -3,17 +3,13 @@ name: Run Python linter and formatter on: push: branches: [ 'main', 'improved-api'] - paths-ignore: - - '**.md' - - 'lab/**' - - 'assets/**' + paths: + - '**.py' pull_request: branches: [ 'main', 'improved-api' ] - paths-ignore: - - '**.md' - - 'lab/**' - - 'assets/**' + paths: + - '**.py' jobs: checks-format: @@ -31,4 +27,6 @@ jobs: - name: Check linting with ruff run: | ruff check . - ruff format . + - name: Check formatting with black + run: | + black . --verbose From f6fb16632897310fdf9766d63eb9937df472f55b Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 16:28:47 +0000 Subject: [PATCH 17/23] Pending changes exported from your codespace --- README.md | 8 +++++++- pyproject.toml | 1 + flask_test.py => tests/flask_test.py | 2 +- tests/test_gunicorn.py | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) rename flask_test.py => tests/flask_test.py (99%) create mode 100644 tests/test_gunicorn.py diff --git a/README.md b/README.md index acccc34..3f58d14 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,13 @@ To run the Flask application, follow these steps: ### Run the tests -1. **Inside your virtual environment, execute the following command to run the tests** +1. **Inside your virtual environment, execute the following command to install the development requirements:** + + ```bash + pip install -r requirements-dev.txt + ``` + +1. **Execute the following command to run the tests** ```bash python flask_test.py diff --git a/pyproject.toml b/pyproject.toml index 1a39d94..f4b519d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ extend-exclude = "src/flaskapp/migrations/" [tool.pytest.ini_options] addopts = "-ra --cov" +testpaths = ["tests"] pythonpath = ["src"] [tool.coverage.report] diff --git a/flask_test.py b/tests/flask_test.py similarity index 99% rename from flask_test.py rename to tests/flask_test.py index 3d0a82c..9b5f3a6 100644 --- a/flask_test.py +++ b/tests/flask_test.py @@ -2,7 +2,7 @@ import os import unittest -from src.app import create_app +from flaskapp import create_app class TestCaseManagementTestCase(unittest.TestCase): diff --git a/tests/test_gunicorn.py b/tests/test_gunicorn.py new file mode 100644 index 0000000..c036c16 --- /dev/null +++ b/tests/test_gunicorn.py @@ -0,0 +1,15 @@ +import sys +from unittest import mock + +import pytest +from gunicorn.app.wsgiapp import run + + +def test_config_imports(): + argv = ["gunicorn", "--check-config", "flaskapp:create_app()", "-c", "src/gunicorn.conf.py"] + + with mock.patch.object(sys, "argv", argv): + with pytest.raises(SystemExit) as excinfo: + run() + + assert excinfo.value.args[0] == 0 From 5c7ecbeedbe1cde8e533703e4be30ced38188f3f Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 22:12:36 +0200 Subject: [PATCH 18/23] add pytest --- README.md | 16 +++-- tests/conftest.py | 38 ++++++++++ tests/flask_test.py | 157 ---------------------------------------- tests/test_app.py | 158 +++++++++++++++++++++++++++++++++++++++++ tests/test_gunicorn.py | 6 +- 5 files changed, 211 insertions(+), 164 deletions(-) create mode 100644 tests/conftest.py delete mode 100644 tests/flask_test.py create mode 100644 tests/test_app.py diff --git a/README.md b/README.md index 3f58d14..1f10e9f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ To run the Flask application, follow these steps: >**Note** - In Windows, the `venv` does not have a `bin` directory. Therefore, you'd use the analogous command shown below: ```bash - source venv\Scripts\activate + source .venv\Scripts\activate ``` 1. **Install the app as an editable package:** @@ -44,7 +44,7 @@ To run the Flask application, follow these steps: python3 -m flask --app src.flaskapp run --reload ``` -### Run the tests +### Development 1. **Inside your virtual environment, execute the following command to install the development requirements:** @@ -52,10 +52,18 @@ To run the Flask application, follow these steps: pip install -r requirements-dev.txt ``` +1. **Execute the following command to install the pre commit hooks:** + + ```bash + pre-commit install + ``` + +### Testing + 1. **Execute the following command to run the tests** ```bash - python flask_test.py + pytest ``` ## API Documentation @@ -218,5 +226,3 @@ The API will return these error types when the request fails: "total_executions": 10 } ``` - - diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0a52c21 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +import os + +import pytest + +from flaskapp import create_app +from flaskapp.database.models import db + + +@pytest.fixture(scope="session") +def app_with_db(): + """Session-wide test `Flask` application.""" + config_override = { + "TESTING": True, + # Allows for override of database to separate test from dev environments + "SQLALCHEMY_DATABASE_URI": os.environ.get("TEST_DATABASE_URL", os.environ.get("DATABASE_FILENAME")), + } + app = create_app(config_override) + + with app.app_context(): + engines = db.engines + + engine_cleanup = [] + + for key, engine in engines.items(): + connection = engine.connect() + transaction = connection.begin() + engines[key] = connection + engine_cleanup.append((key, engine, connection, transaction)) + + yield app + + for key, engine, connection, transaction in engine_cleanup: + try: + transaction.rollback() + connection.close() + except Exception: + connection.close() + engines[key] = engine diff --git a/tests/flask_test.py b/tests/flask_test.py deleted file mode 100644 index 9b5f3a6..0000000 --- a/tests/flask_test.py +++ /dev/null @@ -1,157 +0,0 @@ -import json -import os -import unittest - -from flaskapp import create_app - - -class TestCaseManagementTestCase(unittest.TestCase): - """This class represents the trivia test case""" - - def setUp(self): - """Define test variables and initialize app.""" - config_override = {"TESTING": True} - os.environ["DATABASE_FILENAME"] = "testdb.db" - self.app = create_app(config_override) - self.client = self.app.test_client - - self.new_test_case = { - "name": "New Test Case", - "description": "New Test Case Description", - } - - self.new_execution = { - "asset_id": "1", - "test_case_id": "1", - "status": True, - "details": "Success", - } - - def tearDown(self): - """Executed after each test""" - pass - - def test_retrieve_tests(self): - """Test retrieve tests""" - res = self.client().get("/tests") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(data["test_cases"]) - - def test_405_using_wrong_method_to_retrieve_tests(self): - """Test 405 using wrong method to retrieve tests""" - res = self.client().patch("/tests") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 405) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_create_new_test(self): - """test create new test""" - res = self.client().post("/tests", json=self.new_test_case) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - - def test_400_create_new_test_without_name(self): - """test create new test without providing name""" - res = self.client().post("/tests", json={"testing": "xxx"}) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 400) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_405_creation_not_allowed(self): - """test 405 creation not allowed""" - res = self.client().post("/tests/45", json=self.new_test_case) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 405) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_get_specific_test(self): - """Test get specific test with id""" - res = self.client().get("/tests/1") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(len(data["test_case"])) - - def test_get_nonexistent_test(self): - """Test get non existent test""" - res = self.client().get("/tests/10000") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 404) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_update_test(self): - """Test update test""" - res = self.client().patch("/tests/1", json={"name": "Updated Test Case"}) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(data["test_case"]) - - def test_update_test_without_name(self): - """Test update test without providing name""" - res = self.client().patch("/tests/1", json={"testing": "Updated Test Case"}) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 400) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_delete_test_case(self): - """Test delete test case""" - res = self.client().delete("/tests/3") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertEqual(data["deleted_test_case_id"], 5) - self.assertTrue(data["total_test_cases"]) - - def test_404_delete_nonexistent_test(self): - """test 404 delete nonexistent test""" - res = self.client().delete("/tests/10000") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 404) - self.assertEqual(data["success"], False) - self.assertTrue(data["message"]) - - def test_get_execution_results(self): - """Test get execution results""" - res = self.client().get("/executions/1") - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(data["executions"]) - self.assertTrue(data["asset"]) - self.assertTrue(data["total_executions"]) - - def test_add_execution_results(self): - """Test add execution result""" - res = self.client().post("/executions", json=self.new_execution) - data = json.loads(res.data) - - self.assertEqual(res.status_code, 200) - self.assertEqual(data["success"], True) - self.assertTrue(data["execution"]) - self.assertTrue(data["total_executions"]) - - -# Make the tests conveniently executable -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..1b000e1 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,158 @@ +import pytest + + +@pytest.fixture +def client(app_with_db): + return app_with_db.test_client() + + +def test_index(client): + """Test index page""" + response = client.get("/") + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["message"] == "Welcome to the test case management API" + + +def test_retrieve_tests(client): + """Test retrieve tests""" + response = client.get("/tests") + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["test_cases"] + + +def test_405_using_wrong_method_to_retrieve_tests(client): + """Test 405 using wrong method to retrieve tests""" + response = client.patch("/tests") + body = response.get_json() + + assert response.status_code == 405 + assert body["success"] is False + assert body["message"] + + +def test_create_new_test(client): + """test create new test""" + response = client.post( + "/tests", + json={ + "name": "New Test Case", + "description": "New Test Case Description", + }, + ) + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + + +def test_400_create_new_test_without_name(client): + """test create new test without providing name""" + response = client.post("/tests", json={"testing": "xxx"}) + body = response.get_json() + + assert response.status_code == 400 + assert body["success"] is False + assert body["message"] + + +def test_405_creation_not_allowed(client): + """test 405 creation not allowed""" + response = client.post( + "/tests/45", + json={ + "name": "New Test Case", + "description": "New Test Case Description", + }, + ) + body = response.get_json() + + assert response.status_code == 405 + assert body["success"] is False + assert body["message"] + + +def test_get_specific_test(client): + """Test get specific test with id""" + response = client.get("/tests/1") + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert len(body["test_case"]) + + +def test_get_nonexistent_test(client): + """Test get non existent test""" + response = client.get("/tests/10000") + body = response.get_json() + + assert response.status_code == 404 + assert body["success"] is False + assert body["message"] + + +def test_update_test(client): + """Test update test""" + response = client.patch("/tests/1", json={"name": "Updated Test Case"}) + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["test_case"] + + +def test_update_test_without_name(client): + """Test update test without providing name""" + response = client.patch("/tests/1", json={"testing": "Updated Test Case"}) + body = response.get_json() + + assert response.status_code == 400 + assert body["success"] is False + assert body["message"] + + +def test_404_delete_nonexistent_test(client): + """test 404 delete nonexistent test""" + response = client.delete("/tests/10000") + body = response.get_json() + + assert response.status_code == 404 + assert body["success"] is False + assert body["message"] + + +def test_get_execution_results(client): + """Test get execution results""" + response = client.get("/executions/1") + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["executions"] + assert body["asset"] + assert body["total_executions"] + + +def test_add_execution_results(client): + """Test add execution result""" + response = client.post( + "/executions", + json={ + "asset_id": "1", + "test_case_id": "1", + "status": True, + "details": "Success", + }, + ) + body = response.get_json() + + assert response.status_code == 200 + assert body["success"] is True + assert body["execution"] + assert body["total_executions"] diff --git a/tests/test_gunicorn.py b/tests/test_gunicorn.py index c036c16..c2bc3a6 100644 --- a/tests/test_gunicorn.py +++ b/tests/test_gunicorn.py @@ -2,11 +2,13 @@ from unittest import mock import pytest -from gunicorn.app.wsgiapp import run +@pytest.mark.skipif(sys.platform == "win32", reason="Windows doesn't have what it takes.") def test_config_imports(): - argv = ["gunicorn", "--check-config", "flaskapp:create_app()", "-c", "src/gunicorn.conf.py"] + from gunicorn.app.wsgiapp import run + + argv = ["gunicorn", "--check-config", "flaskapp", "-c", "src/gunicorn.conf.py"] with mock.patch.object(sys, "argv", argv): with pytest.raises(SystemExit) as excinfo: From e079d882c82ca338637bb6539b7fd913ec8124cf Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 22:40:37 +0200 Subject: [PATCH 19/23] remove mysql db --- .devcontainer/Dockerfile_dev | 3 +-- .devcontainer/devcontainer.json | 7 +++--- .devcontainer/docker-compose_dev.yml | 33 ---------------------------- 3 files changed, 4 insertions(+), 39 deletions(-) diff --git a/.devcontainer/Dockerfile_dev b/.devcontainer/Dockerfile_dev index daa7646..cbbfdf5 100644 --- a/.devcontainer/Dockerfile_dev +++ b/.devcontainer/Dockerfile_dev @@ -1,11 +1,10 @@ FROM mcr.microsoft.com/devcontainers/python:3.12-bullseye RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends default-mysql-server \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* COPY requirements-dev.txt requirements-dev.txt COPY src/requirements.txt src/requirements.txt RUN python -m pip install --upgrade pip -RUN python -m pip install -r requirements-dev.txt \ No newline at end of file +RUN python -m pip install -r requirements-dev.txt diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b1124e9..60917fd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose { - "name": "flask_api_mysql_db", + "name": "flask_api_sqlite_db", // Update the 'dockerComposeFile' list if you have more compose files or use different names. // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. @@ -13,8 +13,7 @@ "workspaceFolder": "/workspace", "forwardPorts": [5000, 3306], "portsAttributes": { - "5000": {"label": "Web port", "onAutoForward": "notify"}, - "3306": {"label": "MySQL Port", "onAutoForward": "silent"} + "5000": {"label": "Web port", "onAutoForward": "notify"} }, "customizations": { "vscode": { @@ -49,4 +48,4 @@ "ghcr.io/azure/azure-dev/azd:latest": {} }, "postCreateCommand": "pip install -e src && python3 -m flask --app src/flaskapp db upgrade --directory src/flaskapp/migrations" -} \ No newline at end of file +} diff --git a/.devcontainer/docker-compose_dev.yml b/.devcontainer/docker-compose_dev.yml index 0011ddd..7d1473b 100644 --- a/.devcontainer/docker-compose_dev.yml +++ b/.devcontainer/docker-compose_dev.yml @@ -1,41 +1,8 @@ version: '3' services: - db: - image: mysql:latest - - environment: - MYSQL_ROOT_PASSWORD: mysql - MYSQL_DATABASE: relecloud - - restart: unless-stopped - - volumes: - - mysql-data:/var/lib/mysql - - healthcheck: - test: ["CMD-SHELL", "mysqladmin ping"] - interval: 10s - timeout: 5s - retries: 5 - app: build: context: .. dockerfile: ./.devcontainer/Dockerfile_dev - depends_on: - db: - condition: service_healthy - network_mode: service:db - environment: - MYSQL_HOST: db - MYSQL_USER: root - MYSQL_PASS: mysql - MYSQL_DATABASE: relecloud command: sleep infinity - - volumes: - - ..:/workspace:cached - -volumes: - mysql-data: \ No newline at end of file From ace6d880ac7abf35ee809c7dc71389466bd1248f Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 22:41:59 +0200 Subject: [PATCH 20/23] remove mysql connector and name --- src/flaskapp/database/models.py | 14 ++++++++++---- src/pyproject.toml | 3 +-- src/requirements.txt | 2 -- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/flaskapp/database/models.py b/src/flaskapp/database/models.py index 670c2ee..7621423 100644 --- a/src/flaskapp/database/models.py +++ b/src/flaskapp/database/models.py @@ -1,5 +1,5 @@ """ -Models for MySQL +Models for SQLite database """ @@ -58,7 +58,9 @@ class TestCase(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(String(500), nullable=True) - executions: Mapped[list["Execution"]] = relationship("Execution", back_populates="test_case") + executions: Mapped[list["Execution"]] = relationship( + "Execution", back_populates="test_case" + ) def __init__(self, name, description): self.name = name @@ -84,7 +86,9 @@ class Asset(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - executions: Mapped[list["Execution"]] = relationship("Execution", back_populates="asset") + executions: Mapped[list["Execution"]] = relationship( + "Execution", back_populates="asset" + ) def __init__(self, name): self.name = name @@ -109,7 +113,9 @@ class Execution(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) test_case_id: Mapped[int] = mapped_column(ForeignKey("test_case.id")) - test_case: Mapped["TestCase"] = relationship("TestCase", back_populates="executions") + test_case: Mapped["TestCase"] = relationship( + "TestCase", back_populates="executions" + ) asset_id: Mapped[int] = mapped_column(ForeignKey("asset.id")) asset: Mapped["Asset"] = relationship("Asset", back_populates="executions") timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/src/pyproject.toml b/src/pyproject.toml index d4f8260..84bb8e4 100644 --- a/src/pyproject.toml +++ b/src/pyproject.toml @@ -7,10 +7,9 @@ dependencies = [ "sqlalchemy", "flask-migrate", "flask-sqlalchemy", - "mysql-connector-python", "gunicorn" ] [build-system] requires = ["flit_core<4"] -build-backend = "flit_core.buildapi" \ No newline at end of file +build-backend = "flit_core.buildapi" diff --git a/src/requirements.txt b/src/requirements.txt index 986f4b9..17aa7e3 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -36,8 +36,6 @@ markupsafe==2.1.5 # jinja2 # mako # werkzeug -mysql-connector-python==8.3.0 - # via flaskapp (pyproject.toml) sqlalchemy==2.0.28 # via # alembic From 24582ac878243d354ce63a350b0028ccdafec7df Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 22:42:34 +0200 Subject: [PATCH 21/23] add tests --- .github/workflows/format.yml | 4 ++-- .github/workflows/tests.yml | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index d3aa28d..541de39 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -2,12 +2,12 @@ name: Run Python linter and formatter on: push: - branches: [ 'main', 'improved-api'] + branches: [ 'main' ] paths: - '**.py' pull_request: - branches: [ 'main', 'improved-api' ] + branches: [ 'main' ] paths: - '**.py' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5691076 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: Run Python tests + +on: + push: + branches: [ 'main' ] + paths: + - '**.py' + + pull_request: + branches: [ 'main' ] + paths: + - '**.py' + +jobs: + test_package: + + name: Test ${{ matrix.os }} Python ${{ matrix.python_version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["ubuntu-20.04"] + python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v3 + - name: Setup python + uses: actions/setup-python@v2 + with: + + python-version: ${{ matrix.python_version }} + architecture: x64 + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements-dev.txt + python3 -m pip install -e src + - name: Run the migrations + run: | + python3 -m flask --app src.flaskapp db upgrade --directory src/flaskapp/migrations + env: + DATABASE_FILENAME: testdb.db + - name: Run tests + run: python3 -m pytest + env: + DATABASE_FILENAME: testdb.db From a442f739c6a3e2dd9f7b88d789af28dcee61c3c4 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 22:48:14 +0200 Subject: [PATCH 22/23] fix gunicorn test --- tests/test_gunicorn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_gunicorn.py b/tests/test_gunicorn.py index c2bc3a6..f5ae570 100644 --- a/tests/test_gunicorn.py +++ b/tests/test_gunicorn.py @@ -8,7 +8,7 @@ def test_config_imports(): from gunicorn.app.wsgiapp import run - argv = ["gunicorn", "--check-config", "flaskapp", "-c", "src/gunicorn.conf.py"] + argv = ["gunicorn", "--check-config", "flaskapp:create_app()", "-c", "src/gunicorn.conf.py"] with mock.patch.object(sys, "argv", argv): with pytest.raises(SystemExit) as excinfo: From e34569830e07df52168ff685bf36361c4282f37c Mon Sep 17 00:00:00 2001 From: John Aziz Date: Wed, 20 Mar 2024 22:54:07 +0200 Subject: [PATCH 23/23] fix typing error --- src/flaskapp/database/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/flaskapp/database/models.py b/src/flaskapp/database/models.py index 7621423..f4b1b06 100644 --- a/src/flaskapp/database/models.py +++ b/src/flaskapp/database/models.py @@ -4,6 +4,7 @@ """ from datetime import datetime +from typing import List # noqa from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy @@ -58,7 +59,7 @@ class TestCase(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(String(500), nullable=True) - executions: Mapped[list["Execution"]] = relationship( + executions: Mapped[List["Execution"]] = relationship( # noqa "Execution", back_populates="test_case" ) @@ -86,7 +87,7 @@ class Asset(db.Model): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - executions: Mapped[list["Execution"]] = relationship( + executions: Mapped[List["Execution"]] = relationship( # noqa "Execution", back_populates="asset" )