Skip to content

Commit

Permalink
Merge branch 'dev' into asynctestclient_changes
Browse files Browse the repository at this point in the history
  • Loading branch information
vikynoah authored Jan 8, 2025
2 parents ec8b77c + 32bf089 commit dbc1cc8
Show file tree
Hide file tree
Showing 21 changed files with 280 additions and 49 deletions.
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
11 changes: 6 additions & 5 deletions creyPY/fastapi/testing_async.py
Original file line number Diff line number Diff line change
@@ -1,12 +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
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

0 comments on commit dbc1cc8

Please sign in to comment.