Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Major Version 3.0.0 #22

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,15 @@ jobs:
with:
python-version: '3.12'
- run: python -m pip install --upgrade pip
- run: python -m pip install -r requirements.txt
- run: |
python -m pip install -r requirements.txt
python -m pip install -r requirements.pg.txt
python -m pip install -r requirements.auth0.txt
- run: python test.py

tag_and_publish:
runs-on: ubuntu-latest
if: github.ref_name == 'master' || github.ref_name == 'dev'
if: (github.ref_name == 'master' || github.ref_name == 'dev') && github.event_name == 'push'
needs: test
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
Expand Down
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,3 @@ from creyPY.const import LanguageEnum
print(LanguageEnum.EN) # Output: LanguageEnum.EN
print(LanguageEnum.EN.value) # Output: English
```

## TODO

- Add async support for database connection
- Add version without postgresql dependency
3 changes: 2 additions & 1 deletion creyPY/fastapi/db/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .async_session import * # noqa
from .helpers import * # noqa
from .session import * # noqa
from .async_session import * # noqa
18 changes: 5 additions & 13 deletions creyPY/fastapi/db/async_session.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import os
from typing import AsyncGenerator
from dotenv import load_dotenv

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker

from .common import SQLALCHEMY_DATABASE_URL, name

load_dotenv()

host = os.getenv("POSTGRES_HOST", "localhost")
user = os.getenv("POSTGRES_USER", "postgres")
password = os.getenv("POSTGRES_PASSWORD", "root")
port = os.getenv("POSTGRES_PORT", "5432")
name = os.getenv("POSTGRES_DB", "fastapi")

SQLALCHEMY_DATABASE_URL = f"postgresql+psycopg://{user}:{password}@{host}:{port}/"

async_engine = create_async_engine(
SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True, connect_args={"sslmode": "require"}
)

async_engine = create_async_engine(SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True)
AsyncSessionLocal = sessionmaker(
bind=async_engine,
class_=AsyncSession,
Expand Down
13 changes: 13 additions & 0 deletions creyPY/fastapi/db/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import os

from dotenv import load_dotenv

load_dotenv()

host = os.getenv("POSTGRES_HOST", "localhost")
user = os.getenv("POSTGRES_USER", "postgres")
password = os.getenv("POSTGRES_PASSWORD", "root")
port = os.getenv("POSTGRES_PORT", "5432")
name = os.getenv("POSTGRES_DB", "fastapi")

SQLALCHEMY_DATABASE_URL = f"postgresql+psycopg://{user}:{password}@{host}:{port}/"
8 changes: 8 additions & 0 deletions creyPY/fastapi/db/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from sqlalchemy_utils import create_database, database_exists


def create_if_not_exists(db_name: str):
from .common import SQLALCHEMY_DATABASE_URL

if not database_exists(SQLALCHEMY_DATABASE_URL + db_name):
create_database(SQLALCHEMY_DATABASE_URL + db_name)
17 changes: 4 additions & 13 deletions creyPY/fastapi/db/session.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
import os
from typing import Generator

from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm.session import Session

load_dotenv()
from .common import SQLALCHEMY_DATABASE_URL, name

host = os.getenv("POSTGRES_HOST", "localhost")
user = os.getenv("POSTGRES_USER", "postgres")
password = os.getenv("POSTGRES_PASSWORD", "root")
port = os.getenv("POSTGRES_PORT", "5432")
name = os.getenv("POSTGRES_DB", "fastapi")

SQLALCHEMY_DATABASE_URL = f"postgresql+psycopg://{user}:{password}@{host}:{port}/"


engine = create_engine(SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True)
engine = create_engine(
SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True, connect_args={"sslmode": "require"}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


Expand Down
13 changes: 8 additions & 5 deletions creyPY/fastapi/testing_async.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import json
from httpx import AsyncClient
from httpx import ASGITransport, AsyncClient


class AsyncGenericClient:
def __init__(self, app):
self.c = AsyncClient(app=app, base_url="http://testserver", follow_redirects=True)
self.default_headers = {}
def __init__(self, app, headers={}):
self.c = AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver", follow_redirects=True
)
self.default_headers = headers

async def get(self, url: str, r_code: int = 200, parse_json=True):
re = await self.c.get(url, headers=self.default_headers)
Expand Down Expand Up @@ -33,7 +35,8 @@ async def post(
)
if re.status_code != r_code:
print(re.content)
assert r_code == re.status_code
if not raw_response:
assert r_code == re.status_code
return re.json() if not raw_response else re

async def post_file(
Expand Down
16 changes: 16 additions & 0 deletions creyPY/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import secrets
import string


def create_random_password(length: int = 12) -> str:
all_characters = string.ascii_letters + string.digits + string.punctuation

password = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
secrets.choice(string.punctuation),
]
password += [secrets.choice(all_characters) for _ in range(length - 4)]
secrets.SystemRandom().shuffle(password)
return "".join(password)
1 change: 1 addition & 0 deletions creyPY/services/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .auth0 import * # noqa
3 changes: 3 additions & 0 deletions creyPY/services/auth0/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .exceptions import * # noqa
from .manage import * # noqa
from .utils import * # noqa
13 changes: 13 additions & 0 deletions creyPY/services/auth0/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import os

from dotenv import load_dotenv

load_dotenv()

AUTH0_DOMAIN = os.getenv("AUTH0_DOMAIN")
AUTH0_CLIENT_ID = os.getenv("AUTH0_CLIENT_ID")
AUTH0_CLIENT_SECRET = os.getenv("AUTH0_CLIENT_SECRET")
AUTH0_ALGORIGHM = os.getenv("AUTH0_ALGORIGHM", "RS256")

AUTH0_AUDIENCE = os.getenv("AUTH0_AUDIENCE")
AUTH0_ISSUER = os.getenv("AUTH0_ISSUER")
12 changes: 12 additions & 0 deletions creyPY/services/auth0/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from fastapi import HTTPException, status


class UnauthorizedException(HTTPException):
def __init__(self, detail: str, **kwargs):
"""Returns HTTP 403"""
super().__init__(status.HTTP_403_FORBIDDEN, detail=detail)


class UnauthenticatedException(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Requires authentication")
21 changes: 21 additions & 0 deletions creyPY/services/auth0/manage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import requests
from cachetools import TTLCache, cached

from .common import AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_DOMAIN

cache = TTLCache(maxsize=100, ttl=600)


@cached(cache)
def get_management_token() -> str:
response = requests.post(
f"https://{AUTH0_DOMAIN}/oauth/token",
json={
"client_id": AUTH0_CLIENT_ID,
"client_secret": AUTH0_CLIENT_SECRET,
"audience": f"https://{AUTH0_DOMAIN}/api/v2/", # This should be the management audience
"grant_type": "client_credentials",
},
timeout=5, # Add a timeout parameter to avoid hanging requests
).json()
return response["access_token"]
136 changes: 136 additions & 0 deletions creyPY/services/auth0/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
from typing import Optional

import jwt
import requests
from fastapi import HTTPException, Request, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from creyPY.helpers import create_random_password

from .common import (
AUTH0_ALGORIGHM,
AUTH0_AUDIENCE,
AUTH0_CLIENT_ID,
AUTH0_DOMAIN,
AUTH0_ISSUER,
)
from .exceptions import UnauthenticatedException, UnauthorizedException
from .manage import get_management_token

JWKS_CLIENT = jwt.PyJWKClient(f"https://{AUTH0_DOMAIN}/.well-known/jwks.json")


async def verify(
request: Request,
token: Optional[HTTPAuthorizationCredentials] = Security(HTTPBearer(auto_error=False)),
) -> str:
if token is None:
raise UnauthenticatedException

# This gets the 'kid' from the passed token
try:
signing_key = JWKS_CLIENT.get_signing_key_from_jwt(token.credentials).key
except jwt.exceptions.PyJWKClientError as error:
raise UnauthorizedException(str(error))
except jwt.exceptions.DecodeError as error:
raise UnauthorizedException(str(error))

try:
payload = jwt.decode(
token.credentials,
signing_key,
algorithms=[AUTH0_ALGORIGHM],
audience=AUTH0_AUDIENCE,
issuer=AUTH0_ISSUER,
)
except Exception as error:
raise UnauthorizedException(str(error))

return payload["sub"]


### GENERIC AUTH0 CALLS ###
def get_user(sub) -> dict:
re = requests.get(
f"https://{AUTH0_DOMAIN}/api/v2/users/{sub}",
headers={"Authorization": f"Bearer {get_management_token()}"},
timeout=5,
)
if re.status_code != 200:
raise HTTPException(re.status_code, re.json())
return re.json()


def patch_user(input_obj: dict, sub) -> dict:
re = requests.patch(
f"https://{AUTH0_DOMAIN}/api/v2/users/{sub}",
headers={"Authorization": f"Bearer {get_management_token()}"},
json=input_obj,
timeout=5,
)
if re.status_code != 200:
raise HTTPException(re.status_code, re.json())
return re.json()


### USER METADATA CALLS ###
def get_user_metadata(sub) -> dict:
try:
return get_user(sub).get("user_metadata", {})
except:
return {}


def patch_user_metadata(input_obj: dict, sub) -> dict:
return patch_user({"user_metadata": input_obj}, sub)


def clear_user_metadata(sub) -> dict:
return patch_user({"user_metadata": {}}, sub)


def request_verification_mail(sub: str) -> None:
re = requests.post(
f"https://{AUTH0_DOMAIN}/api/v2/jobs/verification-email",
headers={"Authorization": f"Bearer {get_management_token()}"},
json={"user_id": sub},
timeout=5,
)
if re.status_code != 201:
raise HTTPException(re.status_code, re.json())
return re.json()


def create_user_invite(email: str) -> dict:
re = requests.post(
f"https://{AUTH0_DOMAIN}/api/v2/users",
headers={"Authorization": f"Bearer {get_management_token()}"},
json={
"email": email,
"connection": "Username-Password-Authentication",
"password": create_random_password(),
"verify_email": False,
"app_metadata": {"invitedToMyApp": True},
},
timeout=5,
)
if re.status_code != 201:
raise HTTPException(re.status_code, re.json())
return re.json()


def password_change_mail(email: str) -> bool:
re = requests.post(
f"https://{AUTH0_DOMAIN}/dbconnections/change_password",
headers={"Authorization": f"Bearer {get_management_token()}"},
json={
"client_id": AUTH0_CLIENT_ID,
"email": email,
"connection": "Username-Password-Authentication",
},
timeout=5,
)

if re.status_code != 200:
raise HTTPException(re.status_code, re.json())
return True
7 changes: 7 additions & 0 deletions renovate.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommitTypeAll(feat)"
]
}
7 changes: 7 additions & 0 deletions requirements.auth0.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
cachetools>=5.5.0 # for caching
charset-normalizer>=3.4.0 # Auth0 API interactions
requests>=2.32.3 # Auth0 API interactions
pyjwt>=2.10.1 # Auth0 API interactions
cffi>=1.17.1 # Auth0 API interactions
cryptography>=43.0.3 # Auth0 API interactions
pycparser>=2.22 # Auth0 API interactions
2 changes: 0 additions & 2 deletions requirements.build.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,3 @@ twine>=5.0.0
urllib3>=2.2.1
wheel>=0.43.0
zipp>=3.18.1

-r requirements.txt
5 changes: 5 additions & 0 deletions requirements.pg.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
psycopg>=3.2.1 # PostgreSQL
psycopg-binary>=3.2.1 # PostgreSQL
psycopg-pool>=3.2.2 # PostgreSQL
asyncpg>=0.30.0 # SQLAlchemy
greenlet>=3.1.1 # Async
8 changes: 1 addition & 7 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,10 @@ starlette>=0.37.2 # FastAPI

fastapi-pagination>=0.12.26 # Pagination
sqlalchemy>=2.0.31 # SQLAlchemy
sqlalchemy-utils>=0.41.2 # For managing databases

python-dotenv>=1.0.1 # Environment variables

psycopg>=3.2.1 # PostgreSQL
psycopg-binary>=3.2.1 # PostgreSQL
psycopg-pool>=3.2.2 # PostgreSQL

h11>=0.14.0 # Testing
httpcore>=1.0.5 # Testing
httpx>=0.27.0 # Testing

asyncpg>=0.30.0 #SQLAlchemy
greenlet>=3.1.1 #Async
Loading
Loading