Skip to content

Template for any further python projects which supports API and ORM queries

License

Notifications You must be signed in to change notification settings

NawrasBukhari/pyt-framework

Repository files navigation

PY-Template (PYT)

A very simple Python general purpose template which is being in development stage right now

Installation

python setup.py install

Quick start

Environment variables for PYT are placed in the file .env in the root directory of the project

# Application status
APP_DEBUG="True"
APP_ENVIRONMENT="development"

# API credentials
APP_KEY=""

# Database credentials
DATABASE_PREFIX=""
DATABASE_DRIVER="mysql"
DATABASE_HOST="localhost"
DATABASE_NAME=""
DATABASE_USER="root"
DATABASE_PASSWORD=""
DATABASE_PORT="3306"

# Mail credentials
MAIL_MAILER="smtp"
MAIL_HOST="smtp.gmail.com"
MAIL_PORT="587"
MAIL_USERNAME="null"
MAIL_PASSWORD="null"
MAIL_ENCRYPTION="TLS"
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="PY Template"

Environment getters and setters

import os
from pathlib import Path


"""
    This env() function is used to get the value of the environment variable.
    when you call this function, you need to pass the key as a parameter.
    @:param key
    @:return value
"""


def env(key: str, ROOT_DIR=Path(__file__).parent.parent, ENV_FILE=".env"):
    try:
        ENV_FILE = os.path.join(ROOT_DIR, ENV_FILE)
        with open(ENV_FILE, "r") as file:
            lines = file.readlines()
            for line in lines:
                if key in line:
                    value = line.replace('"', "").replace(" ", "").replace("\n", "")
                    return value.split("=")[1]

            raise Exception(f"{key} environment variable is not set.")

    except Exception as e:
        return e


"""
    This set_env() function is used to set the value of the environment variable.
    when you call this function, you need to pass the key and value as a parameter.
    @:param key
    @:param value
    return True
"""


def set_env(key: str, value: str, ROOT_DIR=Path(__file__).parent.parent, ENV_FILE=".env"):
    try:
        ENV_FILE = os.path.join(ROOT_DIR, ENV_FILE)
        with open(ENV_FILE, "r") as file:
            lines = file.readlines()
            with open(ENV_FILE, "w") as file:
                for line in lines:
                    if key in line:
                        line = f'{key}="{value}"\n'
                    file.write(line)

    except Exception as e:
        return e


""" This is_debug() function is used to check if the application is in debug mode. """


def is_debug():
    return env("APP_DEBUG") == "True"

Login process

Login process has been implemented in this template you can refer to config/helpers.py to customize your own login process

from getpass import getpass
from app.User import User
from config.helpers import login

username = input("Enter your username: ")
password = getpass("Enter your password: ")
login(model=User, user_name=username, user_password=password)

Register process

Same thing was implemented for registration process

from getpass import getpass
from app.User import User
from config.helpers import register

username = input("Enter your username: ")
password = getpass("Enter your password: ")
register(model=User, user_name=username, user_password=password)

API Register

from fastapi import FastAPI, responses
from app.User import User
from config.helpers import signJWT, register

app = FastAPI(debug=True)


@app.post("/api/users/register", status_code=201)
async def register_users(name: str, password: str):
    if register(User, name, password) is True:
        return signJWT(name)
    else:
        return responses.Response(content="Strong password needed or name is taken!", status_code=400)

API Login

from fastapi import FastAPI, responses, Depends
from app.User import User
from config.helpers import Bearer, signJWT, login

app = FastAPI(debug=True)

@app.post("/api/users/login", dependencies=[Depends(Bearer())], status_code=200)
async def login_users(name: str, password: str):
    if login(model=User, user_name=name, user_password=password) is True:
        return signJWT(name)
    else:
        return responses.JSONResponse(content="These credentials do not match our records or no token was found!",
                                      status_code=401)

How does registration works?

The register function takes 3 arguments which are model, user_name and user_password so if we want to signin we have to make query to the database, the orm takes care of this step by not using the boring sql syntax's, and it deals with the database in a very simple way

from config.security import hash_password
from config.validator import validate_password, required


def register(model, user_name, user_password):
    try:
        model_class = model.where("name", user_name).first()
        if model_class is None:

            if required(user_name) is True and required(user_password) is True:

                if validate_password(user_password) is True:

                    password = hash_password(user_password)
                    model.create({"name": user_name, "password": password})
                    print("User " + user_name + " created successfully!")

                else:
                    print(
                        "Error Password must be at least 8 characters long and contain at least one uppercase letter, "
                        "one lowercase letter, one digit and one symbol.")
            else:
                print("Error Username and password are required.")
        else:
            print("User already exists")

    except AttributeError:
        print("Error! Could not create user")

How does login works?

The login function takes 3 arguments which are model, user_name and user_password so if we want to signin we have to make query to the database, the orm takes care of this step by not using the boring sql syntax's, and it deals with the database in a very simple way same as register function

from config.security import verify_password


def login(model, user_name, user_password):
    try:
        model_class = model.where("name", user_name).first()
        name = model_class.name.replace(" ", "")
        password = model_class.password.replace(" ", "")

        if name == user_name and verify_password(password=user_password, hashed_password=password) is True:
            print("Welcome " + model_class.name)
        else:
            print("Wrong password")

    except AttributeError:
        print("These credentials do not match our records.")

Security

Password hashing

I have used argon2 since it is considered now one of the strongest password hashing algorithms libraries, it is very simple, and it generates random salt for each password which we can avoid human predictable passwords.

from argon2 import PasswordHasher

def hash_password(password):
    try:
        password_hasher = PasswordHasher()
        return password_hasher.hash(password)
    except Exception as e:
        print(e)

Password verification

from argon2 import PasswordHasher

def verify_password(password, hashed_password):
    try:
        password_hasher = PasswordHasher()
        return password_hasher.verify(hashed_password, password)
    except Exception as e:
        print(e)

Password validation

I have made sure to use the most secure password validation rules which can be found in OWASP, you can add your custom rules in config/validator.py file

from password_validator import PasswordValidator

def required(data):
    return data != ""


def validate_password(password):
    try:
        validator = PasswordValidator()
        validator.min(8).max(100).has().uppercase().has().lowercase().has().digits().has().symbols() and required(
            password)
        return validator.validate(password)
    except AttributeError:
        return False

Database

Database configuration can be found in config/database.py and it can not be changed to another directory

from orm.connections import ConnectionResolver as Connection
from config.environment import get_env

DATABASES = {
    "default": "mysql",
    "mysql": {
        "host":     get_env("DATABASE_HOST"),
        "driver":   get_env("DATABASE_DRIVER"),
        "database": get_env("DATABASE_NAME"),
        "user":     get_env("DATABASE_USER"),
        "password": get_env("DATABASE_PASSWORD"),
        "port":     get_env("DATABASE_PORT"),
        "log_queries": False,
        "strict": True,
        "engine": "InnoDB",
        "options": {

        }
    }
}
DB = Connection().set_connection_details(DATABASES)

Models

Models can be found in app directory, and they can not be changed to another directory, you can create your own models by extending the Model class, and you can use the Model class to make queries to the database for example:

""" User Model """
from orm.models import Model


class User(Model):
    """User Model"""
    __table__ = 'users'
    __primary_key__ = 'id'
    __fillable__ = ['name', 'email', 'password', 'role', 'token']
    __timestamps__ = True
    __hidden__ = ['password', 'role', 'created_at', 'updated_at', 'remember_token', 'token']

where:
__table__ is the name of the table in the database
__primary_key__ is the primary key of the table the default is id
__fillable__ is the columns that can be filled by the user
__timestamps__ is the columns that will be filled automatically by the orm
__hidden__ is the columns that will not be shown to the user

Generate classes

You can setup the project by running the following commands

USAGE
  ORM Version: [-h] [-q] [-v [<...>]] [-V] [--ansi] [--no-ansi] [-n] <command> [<arg1>] ... [<argN>]

ARGUMENTS
  <command>              The command to execute
  <arg>                  The arguments of the command

GLOBAL OPTIONS
  -h (--help)            Display this help message
  -q (--quiet)           Do not output any message
  -v (--verbose)         Increase the verbosity of messages: "-v" for normal output, "-vv" for more verbose output and "-vvv" for debug
  -V (--version)         Display this application version
  --ansi                 Force ANSI output
  --no-ansi              Disable ANSI output
  -n (--no-interaction)  Do not ask any interactive question

AVAILABLE COMMANDS
  help                   Display the manual of a command.
  key:generate           Generates a new application key.
  migrate                Run migrations.
  migrate:refresh        Rolls back migrations and migrates them again.
  migrate:reset          Reset migrations.
  migrate:rollback       Rolls back the last batch of migrations.
  migrate:status         Display migrations status.
  migration              Creates a new migration file.
  model                  Creates a new model file.
  model:docstring        Generate model docstring and type hints (for auto-completion).
  observer               Creates a new observer file.
  seed                   Creates a new seed file.
  seed:run               Run seeds.
  shell                  Connect to your database interactive terminal.

Helpers file

You can find the helpers file in app/helpers.py and you can add your own helpers in this file

import os.path

from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi import Request, HTTPException
from typing import Dict
import time
import jwt
from .security import verify_password, hash_password, validate_password, required
from .environment import env

"""
    @Author:        Nawras Bukhari
    @Description:   This script is used to provide a quick access to another scripts
    @Github:        https://github.com/NawrasBukhari
    @Date:          24/Oct/2024
    @LastEditors:   Nawras Bukhari
    @LastEditTime:  07/Nov/2022
"""

"""
    ** Password must be at least 8 characters long and contain at least one number, one uppercase and one lowercase letter **
    This function is used to add a new user to the database
    by calling the register function from the helpers.py file
    validation is done in the validator.py file
    @:param model
    @:param user_name
    @:param user_password
    for example:
    register(User, "John Doe", "Password123!")
"""


def find_file(file_name: str) -> str:
    file_path = os.path.join(os.path.dirname(__file__), file_name)
    return file_path


def register(model, user_name, user_password):
    try:
        model_class = model.where("name", user_name).first()
        if model_class is None:

            if required(user_name) is True and required(user_password) is True:

                if validate_password(user_password) is True:
                    password = hash_password(user_password)
                    model.create({"name": user_name, "password": password})
                    return True

                else:
                    return False
            else:
                return False
        else:
            return False

    except AttributeError:
        return False


"""
    This function is used to login user to the application
    by calling the login function from the helpers.py file
    validation is done in the validator.py file
    @:param model
    @:param user_name
    @:param user_password
    for example:
    login(User, "John Doe", "Password123!")
"""


def login(model, user_name, user_password):
    try:
        model_class = model.where("name", user_name).first()
        name = model_class.name.replace(" ", "")
        password = model_class.password.replace(" ", "")

        if name == user_name and verify_password(password=user_password, hashed_password=password) is True:
            return True
        else:
            return False

    except AttributeError:
        return False


def update_credentials(model, user_name, user_password):
    # TODO: Update user credentials
    pass


def reset_password():
    # TODO: Reset user password
    pass


def logout():
    # TODO: Logout user
    pass


"""
    This token_response() is used to return the token to the user
    @:param token
"""


def token_response(token: str):
    return {
        "access_token": token
    }


"""
    This signJWT() is used to sign the JWT token
    which means creating it
    @:param user_id
    @:return token_response
    Dictionary of the user_id and the expiration time
    token_response() is called to return the token to the user
"""


def signJWT(user_id: str) -> Dict[str, str]:
    payload = {
        "user_id": user_id,
        "expires": time.time() + 600
    }
    token = jwt.encode(payload=payload, key=env('APP_KEY'), algorithm=env('APP_ALGORITHM'))

    return token_response(token)


"""
    This decodeJWT() is used to decode the JWT token
    which means reading it
    @:param token
    @:return decoded_token
    Dictionary of the user_id and the expiration time
"""


def decodeJWT(token: str) -> dict:
    try:
        decoded_token = jwt.decode(jwt=token, key=env('APP_KEY'), algorithms=[env('APP_ALGORITHM')])
        return decoded_token if decoded_token["expires"] >= time.time() else None
    except:
        return {}


"""
    This verify_jwt() is used to verify the JWT token
    which means checking if it is valid
    @:param token
    @:return isTokenValid
    Boolean value of the token validity
"""


def verify_jwt(token: str) -> bool:
    isTokenValid: bool = False

    try:
        payload = decodeJWT(token)
    except:
        payload = None
    if payload:
        isTokenValid = True
    return isTokenValid


"""
    This Bearer() is extending the HTTPBearer class
    which means it is inheriting from it
    @:param credentials
    @:return credentials
    @:raise HTTPException
    HTTPException is raised if the token is not valid
"""


class Bearer(HTTPBearer):
    def __init__(self, auto_error: bool = True):
        super(Bearer, self).__init__(auto_error=auto_error)

    async def __call__(self, request: Request):
        credentials: HTTPAuthorizationCredentials = await super(Bearer, self).__call__(request)
        if credentials:
            if not credentials.scheme == "Bearer":
                raise HTTPException(status_code=403, detail="Invalid authentication scheme.")
            if not verify_jwt(credentials.credentials):
                raise HTTPException(status_code=403, detail="Invalid token or expired token.")
            return credentials.credentials
        else:
            raise HTTPException(status_code=403, detail="Invalid authorization code.")

TODO

  • Add more database drivers
  • Add more database features
  • Add more database relations
  • Add more database migrations
  • Add more features
  • Add more tests
  • Add more documentation
  • Add more examples
  • Add more security
  • Add more validation

Known Issues

  • Relative imports are not working
  • Difference between the activated and the deactivated environments

License

MIT License
Copyright (c) 2022 Nawras Bukhari
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.