Compare commits

...

92 Commits
0.0.5 ... 2.0.2

Author SHA1 Message Date
807af12fa1 Merge pull request #13 from creyD/dev 2024-11-05 11:54:46 +01:00
creyD
dce897c247 Adjusted files for isort & autopep 2024-11-05 10:29:40 +00:00
vikynoah
89997372ef fix: Changes to accomodate pagination flag in Params (#14)
Co-authored-by: vikbhas <waraa.vignesh@gmail.com>
2024-11-05 11:29:06 +01:00
c8c5977978 fix: removed non-working backsync 2024-10-29 16:20:01 +01:00
974bc591d6 Merge pull request #11 from creyD/dev 2024-10-29 15:49:00 +01:00
eb895398ab fix: trying again to fix the pipeline 2024-10-29 15:46:52 +01:00
867abd7054 fix: fixed workflow again 2024-10-29 15:33:54 +01:00
26e18f6b31 Merge pull request #10 from creyD/dev 2024-10-29 15:32:28 +01:00
8a3a60dbb0 fix: fixed workflow again 2024-10-29 15:30:42 +01:00
e52a5f421b Merge pull request #9 from creyD/dev 2024-10-29 15:23:41 +01:00
a6ded91185 fix: syncing back tags to dev 2024-10-29 15:18:57 +01:00
eb64874c47 fix: minor adjustment to the pipeline 2024-10-29 15:13:36 +01:00
b7200852a4 Merge branch 'dev' of https://github.com/creyD/creyPY into dev 2024-10-29 15:13:30 +01:00
3d18205205 fix: fixed github pipeline 2024-10-29 15:12:41 +01:00
99c84b676c Merge pull request #8 from creyD/dev 2024-10-29 15:09:34 +01:00
6806de23b3 fix: fixed github pipeline 2024-10-29 15:06:31 +01:00
6a93ab05a3 Merge pull request #6 from creyD/dev 2024-10-29 14:56:14 +01:00
vikynoah
c5b2ab9932 fix: Add condition for total greater than zero (#7) 2024-10-28 15:37:14 +01:00
5a32a5908b Removed debug statement 2024-10-25 15:34:16 +02:00
b7df0bfdcd fix: added escape for variable names 2024-10-25 15:27:50 +02:00
378d1d60f1 fix: adjusting pipeline for prod as well 2024-10-25 15:22:35 +02:00
e381992f8e fix: fixing dev versioning 2024-10-25 15:09:15 +02:00
6d5411a8ae fix: debugging pipeline 2024-10-25 14:46:35 +02:00
89351d714b fix: debugging pipeline 2024-10-25 14:44:18 +02:00
c24f8933fb fix: attempt on fixing the versioning issue 2024-10-25 14:38:58 +02:00
0bed0e0da4 fix: attempt on fixing the versioning issue 2024-10-25 14:34:56 +02:00
8463eef907 fix: attempt on fixing the versioning issue 2024-10-25 14:25:48 +02:00
5903de2aad fix: fixed semantic versioning format selector 2024-10-25 14:19:11 +02:00
0bf89fe14d fix: switched to semantic versioning action 2024-10-25 14:12:04 +02:00
d54146e05b fix: fixed naming of pre-release commits 2024-10-24 12:41:04 +02:00
d6f79c3ed8 fix: fixed naming of pre-release commits 2024-10-24 12:35:13 +02:00
3f4a0ee00d fix: fixed naming of pre-release commits 2024-10-24 12:25:30 +02:00
714178d68f fix: fixed naming of pre-release commits 2024-10-24 12:22:45 +02:00
c7e205f14b fix: fixed naming of pre-release commits 2024-10-24 12:18:50 +02:00
39ae74becb fix: minor changelog adjustment 2024-10-24 12:15:23 +02:00
5f39966223 fix: Fixed tag pushing and changelog 2024-10-24 12:10:16 +02:00
c91e684f08 fix: fix attempt for the github pipeline 2024-10-24 12:10:16 +02:00
f11b8b8864 fix: alternative attempt on the fix 2024-10-24 12:10:16 +02:00
983553e97a fix: locked tag and publish to master and dev 2024-10-24 12:10:16 +02:00
8740eafce2 fix: fixed pipeline tagging 2024-10-24 12:10:16 +02:00
aa44b9ebe9 fix: fixed pipeline tagging 2024-10-24 12:10:16 +02:00
851573d964 fix: fixed pipeline tagging 2024-10-24 12:10:16 +02:00
cfa1da08d3 fix: pipeline now pushes pre-release versions 2024-10-24 12:10:16 +02:00
4a5a777ef5 breaking: Fixed #3 2024-10-24 12:10:16 +02:00
c9a9b1bc0a breaking: Fixed #1 2024-10-24 12:10:16 +02:00
d9f6e82736 Merge pull request #5 from creyD/dev
Co-authored-by: vikbhas <waraa.vignesh@gmail.com>
Co-authored-by: vikynoah <vigneshwaraa.sarangapani@noah.tech>
Co-authored-by: creyD <creyD@users.noreply.github.com>
2024-10-24 11:04:12 +02:00
65e93a023b fix: minor vscode adjustments 2024-10-24 09:32:11 +02:00
creyD
6ce0cfbd14 Adjusted files for isort & autopep 2024-10-24 07:27:26 +00:00
vikynoah
da7ec0b28e Feat: Addition of pagination proxy and Flag functionality (#4)
Co-authored-by: vikbhas <waraa.vignesh@gmail.com>
2024-10-24 09:26:57 +02:00
2727c452b6 fix: adjusted pipeline to dev branch and pull requests 2024-10-24 09:25:39 +02:00
be7d7ddb22 fix: bumped dependencies 2024-07-14 18:24:07 +02:00
3f0379290d fix: Dependencies can now be installed with newer versions 2024-05-14 14:40:12 +02:00
creyD
fa7a1c8a61 Adjusted files for isort & autopep 2024-05-13 09:22:06 +00:00
5a7e1776db fix: Added option to specify lookup_column for get_object_or_404 2024-05-13 11:21:25 +02:00
4c25d91e18 fix: fixed another minor bug with the order_by method 2024-04-25 19:43:12 +02:00
f24db62781 fix: fixed a bug with the jsonschema for order_by 2024-04-25 18:44:58 +02:00
4d997a375e feat: added order_by method 2024-04-25 18:19:26 +02:00
ee11d86235 feat: Added headers to testing 2024-04-02 13:18:41 +02:00
e47f5f2b07 breaking: Release of 1.0.0 2024-04-02 12:19:08 +02:00
754a951dc3 Fixed missing dependencies 2024-04-02 12:14:47 +02:00
8eb04f4b17 beaking: Version 1 release 2024-04-02 12:09:06 +02:00
140c6e4678 Minor bugfix 2024-04-02 11:40:26 +02:00
6fc0d01189 feat: Added generic testing client 2024-04-02 11:37:51 +02:00
10eaa2c0e0 Fixed workflow 2024-04-01 20:57:39 +02:00
b549fd941c Added content type for long description 2024-04-01 20:53:02 +02:00
2f4e3e7dba Added long_description 2024-04-01 20:46:27 +02:00
38d9a0c177 Added additional fetching for git checkout 2024-04-01 20:43:11 +02:00
f4c9b7b6b6 Update ci.yml 2024-04-01 20:38:16 +02:00
5b3389e939 Added manual triggering of the action 2024-04-01 20:33:34 +02:00
7eabeb3e04 Moved to tagging without prefix 2024-04-01 20:31:45 +02:00
51d4e7e6b8 Reworked CI 2024-04-01 20:28:16 +02:00
3fc6ae51c3 Added todos 2024-04-01 20:19:43 +02:00
525af5b34d Merged workflows 2024-04-01 20:19:02 +02:00
2daa8e5a22 feat: add todo 2024-04-01 20:11:51 +02:00
4fef25f898 feat: smaller bugfix for pipeline 2024-04-01 20:07:54 +02:00
creyD
351642b0f1 Adjusted files for isort & autopep 2024-04-01 18:05:56 +00:00
80dfe98a1d feat: added todos to trigger pipeline 2024-04-01 20:05:13 +02:00
b2b726ed9a Added testing to CI 2024-04-01 20:04:19 +02:00
246eccd606 feat: Added testing for CRUD 2024-04-01 20:01:15 +02:00
fc13dad076 feat: Added tests 2024-04-01 19:44:13 +02:00
172f47221c Fixed issue in pipeline 2024-04-01 19:29:58 +02:00
65c8203348 Fixed issue in pipeline 2024-04-01 19:26:14 +02:00
515c3372c6 Minor Changes 2024-04-01 19:22:08 +02:00
43055dde1b Triggering workflow 2024-04-01 19:19:35 +02:00
0aaa1dc6a1 Fixed workflow 2024-04-01 19:18:43 +02:00
2a04426356 Added automated versioning 2024-04-01 19:14:57 +02:00
2e056d2289 Added requirements 2024-04-01 18:48:14 +02:00
41aaef0d66 Added base schemas 2024-04-01 18:46:53 +02:00
9d6bdd60c8 Added base model 2024-04-01 18:37:33 +02:00
30ad5e28c5 Added fastapi CRUD 2024-04-01 18:28:18 +02:00
58dc5eb6f2 Added fastapi pagination 2024-04-01 18:16:11 +02:00
a004dd233a Added groupmode const 2024-04-01 18:06:30 +02:00
26 changed files with 878 additions and 58 deletions

105
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: Lint, Test, Tag & Publish
on:
push:
branches:
- master
- dev
paths-ignore:
- "**/.gitignore"
- "**/.vscode/**"
- "**/README.md"
- "**/CHANGELOG.md"
pull_request:
branches:
- dev
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: psf/black@stable
with:
options: "-l 100 --exclude '/.venv/|/__init__.py'"
- uses: creyD/autoflake_action@master
with:
no_commit: True
options: --in-place --remove-all-unused-imports -r --exclude **/__init__.py,**/db/models.py,
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Adjusted files for isort & autopep
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install --upgrade pip
- run: python -m pip install -r requirements.txt
- run: python test.py
tag_and_publish:
runs-on: ubuntu-latest
if: github.ref_name == 'master' || github.ref_name == 'dev'
needs: test
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
contents: write # for the tags
steps:
- uses: actions/checkout@v4
with:
fetch-tags: true
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: setup git
run: |
git config --local user.email "15138480+creyD@users.noreply.github.com"
git config --local user.name "creyD"
- name: set version format
id: version_format
run: |
if [[ ${{ github.ref_name }} == 'master' ]]; then
echo "version_format=\${major}.\${minor}.\${patch}" >> $GITHUB_OUTPUT
else
echo "version_format=\${major}.\${minor}.\${patch}rc\${increment}" >> $GITHUB_OUTPUT
fi
- name: Git Version
uses: PaulHatch/semantic-version@v5.4.0
id: git_version
with:
tag_prefix: ""
major_pattern: "breaking:"
minor_pattern: "feat:"
enable_prerelease_mode: false
version_format: ${{ steps.version_format.outputs.version_format }}
- name: Create & Push Tag
run: |
git tag ${{ steps.git_version.outputs.version }}
git push origin ${{ steps.git_version.outputs.version }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.build.txt
python setup.py sdist bdist_wheel
- name: Build and publish
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}

View File

@@ -1,29 +0,0 @@
name: Lint
on:
push:
branches:
- dev
- master
paths-ignore:
- "**/.github/**"
- "**/.gitignore"
- "**/.vscode/**"
- "**/README.md"
- "**/CHANGELOG.md"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: psf/black@stable
with:
options: "-l 100 --exclude '/.venv/|/__init__.py'"
- uses: creyD/autoflake_action@master
with:
no_commit: True
options: --in-place --remove-all-unused-imports -r --exclude **/__init__.py,**/db/models.py,
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Adjusted files for isort & autopep

View File

View File

@@ -26,10 +26,16 @@
"**/db.sqlite3": true,
"**/.DS_Store": true,
"**/*.pyc": true,
"**/__pycache__/": true
"**/__pycache__/": true,
"**/build": true,
"**/dist": true,
"**/*.egg-info": true,
},
"search.exclude": {
"**/.git": true,
"**/build": true,
"**/*.egg-info": true,
"**/dist": true,
"**/.venv": true,
"**/tmp": true,
"htmlcov/*": true,

49
CHANGELOG.md Normal file
View File

@@ -0,0 +1,49 @@
# Changelog
All notable changes to this project will be documented in this file.
## 2.0.0
- Fixed #1 Rename misspelled additonal_data to additional_data on create_obj_from_data
- Fixed #3 Inverse partial flag: bool = False because it was wrong on update_obj_from_data
Notes:
You will need to change calls to `create_obj_from_data` according to #1 (rename additonal_data to additional_data)
You will need to change calls to `update_obj_from_data` according to #3 (if you supplied `partial`, you will need to reverse it: `true` -> `false` and `false` -> `true`)
## 1.3.0
- Addition of pagination proxy and pagination=off functionality (Thanks to @vikbhas)
## 1.2.5
- Bumped dependencies
## 1.2.4
- Enabled newer versions for all dependencies
## 1.2.3
- Added option to specify lookup_column for get_object_or_404
## 1.2.2
- Added order_by method
## 1.1.0
- Added headers to testing
## 1.0.0
- Bumped dependencies
- Added const documentation
- Added installation instructions and examples to README
- Added sqlalchemy session for db connection
## 0.2.0
- Added testing client

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 Conrad
Copyright (c) 2024 Conrad Großer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,2 +1,62 @@
# creyPY
My collection of Python and FastAPI shortcuts etc.
## Installation
```bash
pip install creyPY -U
```
## Versioning
This library uses [Semantic Versioning](https://semver.org/).
## FastAPI
This library installes fastapi and pydantic, as well as sqlalchemy for you. It also provides a sqlalchemy base class and companion pydantic schemas. Also there are some helper functions for FastAPI in `creyPY.fastapi.app` like `generate_unique_id` to generate unique operation IDs for the OpenAPI schema to work with code generators.
### Database connection
The `creyPY.fastapi.db` module provides a `Session` class that can be used as a context manager to connect to a database. It exposes the `SQLALCHEMY_DATABASE_URL` variable for you to use. It uses the following environment variables:
- `POSTGRES_HOST`: The host of the database
- `POSTGRES_PORT`: The port of the database
- `POSTGRES_USER`: The user of the database
- `POSTGRES_PASSWORD`: The password of the database
- `POSTGRES_DB`: The database name
Currently only PostgreSQL is supported. It creates a sync session, it is planned to add async support in the future. You can use this like this:
```python
from creyPY.fastapi.db.session import get_db
async def test_endpoint(
db: Session = Depends(get_db),
) -> Any:
pass
```
## Constants
The constants module contains a few enums that I use in my projects. The best way to understand this library is to look at the code (it's not that much). However for simplicity, here is a brief overview:
- LanguageEnum: Contains all languages according to ISO 639
- CountryEnum: Contains all countries according to ISO 3166
- CurrencyEnum: Contains all accepted stripe currencies (Commented out are the Zero-decimal currencies, to avoid custom implementation)
- StripeStatus: Contains all stripe payment statuses
- GroupMode: Contains time group modes (e.g. day, week, month, year)
### Usage example
```python
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

View File

@@ -1,2 +1,3 @@
from .groups import * # noqa
from .i18n import * # noqa
from .stripe import * # noqa

10
creyPY/const/groups.py Normal file
View File

@@ -0,0 +1,10 @@
import enum
class GroupMode(str, enum.Enum):
Minute = "1m"
Hour = "1h"
Day = "1d"
Week = "7d"
Month = "1mo"
Year = "1y"

View File

@@ -1,6 +1,7 @@
import enum
# Source: https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes
class CountryEnum(str, enum.Enum):
AF = "Afghanistan"
AX = "land Islands"
@@ -248,6 +249,7 @@ class CountryEnum(str, enum.Enum):
ZW = "Zimbabwe"
# :: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
class LanguageEnum(str, enum.Enum):
AA = "Afar"
AB = "Abkhazian"

View File

@@ -0,0 +1,7 @@
from .app import * # noqa
from .crud import * # noqa
from .db import * # noqa
from .models import * # noqa
from .pagination import * # noqa
from .schemas import * # noqa
from .testing import * # noqa

23
creyPY/fastapi/app.py Normal file
View File

@@ -0,0 +1,23 @@
import re
from fastapi.routing import APIRoute
# Swagger operation ID config
def generate_unique_id(route: APIRoute) -> str:
op_id = re.sub(r"{.*?}", "", route.path_format) # remove path parameters
operation_id = re.sub(r"\W", "_", op_id.replace("//", "/"))[
1:
] # replace non-alphanumeric characters with underscores
assert route.methods
# if the route doesn't end with an underscore we should add one
if operation_id[-1] != "_":
operation_id += "_"
# If get method and no {} in the path, it should be called list
if "GET" in route.methods and "{" not in route.path_format:
operation_id = operation_id + "list"
else:
operation_id = (
operation_id + list(route.methods)[0].lower()
) # add first (and only) method to operation_id
return operation_id

63
creyPY/fastapi/crud.py Normal file
View File

@@ -0,0 +1,63 @@
from typing import Type, TypeVar
from uuid import UUID
from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from .models.base import Base
T = TypeVar("T", bound=Base)
def get_object_or_404(
db_class: Type[T], id: UUID | str, db: Session, expunge: bool = False, lookup_column: str = "id"
) -> T:
obj = db.query(db_class).filter(getattr(db_class, lookup_column) == id).one_or_none()
if obj is None:
raise HTTPException(status_code=404, detail="The object does not exist.")
if expunge:
db.expunge(obj)
return obj
# TODO: Add testing
def create_obj_from_data(
data: BaseModel, model: Type[T], db: Session, additional_data={}, exclude={}
) -> T:
obj = model(**data.model_dump(exclude=exclude) | additional_data)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
# TODO: Add testing
def update_obj_from_data(
data: BaseModel,
model: Type[T],
id: UUID | str,
db: Session,
partial: bool = True,
ignore_fields=[],
additional_data={},
exclude={},
) -> T:
obj = get_object_or_404(model, id, db)
data_dict = data.model_dump(exclude_unset=partial, exclude=exclude)
data_dict.update(additional_data) # merge additional_data into data_dict
for field in data_dict:
if field not in ignore_fields:
setattr(obj, field, data_dict[field])
db.commit()
db.refresh(obj)
return obj
# TODO: Add testing
def delete_object(db_class: Type[T], id: UUID | str, db: Session) -> None:
obj = db.query(db_class).filter(db_class.id == id).one_or_none()
if obj is None:
raise HTTPException(status_code=404, detail="The object does not exist.")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1 @@
from .session import * # noqa

View File

@@ -0,0 +1,26 @@
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()
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)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db() -> Generator[Session, None, None]:
with SessionLocal() as db:
yield db

View File

@@ -0,0 +1 @@
from .base import * # noqa

View File

@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, DateTime, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import as_declarative
from sqlalchemy.sql import func
@as_declarative()
class Base:
__abstract__ = True
# Primary key as uuid
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_by_id = Column(String)
__name__: str
# TODO: Add default representation string
# TODO: Add automated foreign key resolution
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

View File

@@ -0,0 +1,25 @@
from typing import Callable
from pydantic.json_schema import SkipJsonSchema
from sqlalchemy import String, asc, cast, desc
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql.selectable import Select
def order_by(order_by: str | SkipJsonSchema[None] = None) -> Callable[[Select], Select]:
def _order_by(query: Select) -> Select:
if order_by:
direction = desc if order_by.startswith("-") else asc
column_name = order_by.lstrip("-")
# Get the column from the query
for column in query.inner_columns:
if column.key == column_name:
# If the column is a UUID, cast it to a string
if isinstance(column.type, UUID):
column = cast(column, String)
query = query.order_by(direction(column))
break
return query
return _order_by

View File

@@ -0,0 +1,139 @@
from math import ceil
from typing import Any, Generic, Optional, Self, Sequence, TypeVar, Union
from pydantic import BaseModel
from fastapi_pagination import Params
from fastapi_pagination.bases import AbstractPage, AbstractParams
from fastapi_pagination.types import (
GreaterEqualOne,
GreaterEqualZero,
AdditionalData,
SyncItemsTransformer,
)
from fastapi_pagination.api import create_page, apply_items_transformer
from fastapi_pagination.utils import verify_params
from fastapi_pagination.ext.sqlalchemy import create_paginate_query
from fastapi_pagination.bases import AbstractParams, RawParams
from pydantic.json_schema import SkipJsonSchema
from sqlalchemy.sql.selectable import Select
from sqlalchemy.orm.session import Session
from sqlalchemy import select, func
from fastapi import Query
T = TypeVar("T")
class PaginationParams(BaseModel, AbstractParams):
page: int = Query(1, ge=1, description="Page number")
size: int = Query(50, ge=1, le=100, description="Page size")
pagination: bool = Query(True, description="Toggle pagination")
def to_raw_params(self) -> RawParams:
if not self.pagination:
return RawParams(limit=None, offset=None)
return RawParams(limit=self.size, offset=(self.page - 1) * self.size)
# TODO: Add complete fastapi-pagination proxy here
# TODO: Add pagination off functionality
# SkipJsonSchema is used to avoid generating invalid JSON schema in FastAPI
class Page(AbstractPage[T], Generic[T]):
results: Sequence[T]
page: GreaterEqualOne | SkipJsonSchema[None] = None
size: GreaterEqualOne | SkipJsonSchema[None] = None
pages: GreaterEqualZero | SkipJsonSchema[None] = None
total: GreaterEqualZero
has_next: bool | SkipJsonSchema[None] = None
has_prev: bool | SkipJsonSchema[None] = None
__params_type__ = PaginationParams
@classmethod
def create(
cls,
items: Sequence[T],
params: AbstractParams,
*,
total: Optional[int] = None,
**kwargs: Any,
) -> Self:
if not isinstance(params, Params):
raise TypeError("Page should be used with Params")
size = params.size or total or len(items)
page = params.page or 1
pages = None
if total is not None:
if total == 0:
pages = 1
else:
pages = ceil(total / size)
has_next = page < (pages or 1)
has_prev = page > 1
return cls(
total=total,
results=items,
page=page,
size=size,
pages=pages,
has_next=has_next,
has_prev=has_prev,
)
# Parse response from an SDK to a PAGE
def parse_page(response, page: int, size: int) -> Page:
return Page(
page=page,
size=size,
total=response.total,
results=response.results,
pages=response.pages or 1,
has_next=response.has_next,
has_prev=response.has_prev,
)
def create_count_query(query: Select) -> Select:
return select(func.count()).select_from(query.subquery())
def unwrap_scalars(
items: Sequence[Sequence[T]],
force_unwrap: bool = True,
) -> Union[Sequence[T], Sequence[Sequence[T]]]:
return [item[0] if force_unwrap else item for item in items]
def paginate(
connection: Session,
query: Select,
params: Optional[AbstractParams] = None,
transformer: Optional[SyncItemsTransformer] = None,
additional_data: Optional[AdditionalData] = None,
):
params, raw_params = verify_params(params, "limit-offset", "cursor")
count_query = create_count_query(query)
total = connection.scalar(count_query)
if params.pagination is False and total > 0:
params = Params(page=1, size=total)
else:
params = Params(page=params.page, size=params.size)
query = create_paginate_query(query, params)
items = connection.execute(query).all()
items = unwrap_scalars(items)
t_items = apply_items_transformer(items, transformer)
return create_page(
t_items,
params=params,
total=total,
**(additional_data or {}),
)

View File

@@ -0,0 +1 @@
from .base import * # noqa

View File

@@ -0,0 +1,16 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
# The created_by_id is a string because we use the sub from Auth0
class BaseSchemaModelIN(BaseModel):
created_by_id: str
model_config = ConfigDict(from_attributes=True)
class BaseSchemaModelOUT(BaseSchemaModelIN):
id: UUID
created_at: datetime
updated_at: datetime

138
creyPY/fastapi/testing.py Normal file
View File

@@ -0,0 +1,138 @@
import json
from fastapi.testclient import TestClient
class GenericClient(TestClient):
def __init__(self, client):
self.c = TestClient(client)
self.default_headers = {}
def get(self, url: str, r_code: int = 200, parse_json=True):
re = self.c.get(url, headers=self.default_headers)
if re.status_code != r_code:
print(re.content)
assert r_code == re.status_code
return re.json() if parse_json else re.content
def delete(self, url: str, r_code: int = 204):
re = self.c.delete(url, headers=self.default_headers)
if re.status_code != r_code:
print(re.content)
assert r_code == re.status_code
return re.json() if r_code != 204 else None
def post(
self, url: str, obj: dict | str = {}, r_code: int = 201, raw_response=False, *args, **kwargs
):
re = self.c.post(
url,
data=json.dumps(obj) if type(obj) == dict else obj,
headers=self.default_headers | {"Content-Type": "application/json"},
*args,
**kwargs,
)
if re.status_code != r_code:
print(re.content)
assert r_code == re.status_code
return re.json() if not raw_response else re
def post_file(self, url: str, file, r_code: int = 201, raw_response=False, *args, **kwargs):
re = self.c.post(
url,
files={"file": file},
headers=self.default_headers | {"Content-Type": "application/json"},
*args,
**kwargs,
)
if re.status_code != r_code:
print(re.content)
assert r_code == re.status_code
return re.json() if not raw_response else re
def patch(
self, url: str, obj: dict | str = {}, r_code: int = 200, raw_response=False, *args, **kwargs
):
re = self.c.patch(
url,
data=json.dumps(obj) if type(obj) == dict else obj,
headers=self.default_headers | {"Content-Type": "application/json"},
*args,
**kwargs,
)
if re.status_code != r_code:
print(re.content)
assert r_code == re.status_code
return re.json() if not raw_response else re
def put(
self, url: str, obj: dict | str = {}, r_code: int = 200, raw_response=False, *args, **kwargs
):
re = self.c.put(
url,
data=json.dumps(obj) if type(obj) == dict else obj,
headers=self.default_headers
| {
"Content-Type": "application/json",
"accept": "application/json",
},
*args,
**kwargs,
)
if re.status_code != r_code:
print(re.content)
assert r_code == re.status_code
return re.json() if not raw_response else re
def obj_lifecycle(
self,
input_obj: dict,
url: str,
pagination: bool = True,
id_field: str = "id",
created_at_check: bool = True,
):
# GET LIST
re = self.get(url)
if pagination:
assert re["total"] == 0
assert len(re["results"]) == 0
else:
assert len(re) == 0
# CREATE
re = self.post(url, obj=input_obj)
assert id_field in re
assert re[id_field] is not None
if created_at_check:
assert "created_at" in re
assert re["created_at"] is not None
obj_id = str(re[id_field])
# GET
re = self.get(f"{url}{obj_id}/")
assert re[id_field] == obj_id
# GET LIST
re = self.get(url)
if pagination:
assert re["total"] == 1
assert len(re["results"]) == 1
else:
assert len(re) == 1
# DELETE
self.delete(f"{url}{obj_id}")
# GET LIST
re = self.get(url)
if pagination:
assert re["total"] == 0
assert len(re["results"]) == 0
else:
assert len(re) == 0
# GET
self.get(f"{url}{obj_id}", parse_json=False, r_code=404)

View File

@@ -1,25 +1,27 @@
certifi==2024.2.2
charset-normalizer==3.3.2
docutils==0.20.1
idna==3.6
importlib_metadata==7.1.0
jaraco.classes==3.4.0
jaraco.context==4.3.0
jaraco.functools==4.0.0
keyring==25.0.0
markdown-it-py==3.0.0
mdurl==0.1.2
more-itertools==10.2.0
nh3==0.2.17
pkginfo==1.10.0
Pygments==2.17.2
readme_renderer==43.0
requests==2.31.0
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==13.7.1
setuptools==69.2.0
twine==5.0.0
urllib3==2.2.1
wheel==0.43.0
zipp==3.18.1
certifi>=2024.2.2
charset-normalizer>=3.3.2
docutils>=0.20.1
idna>=3.6
importlib_metadata>=7.1.0
jaraco.classes>=3.4.0
jaraco.context>=4.3.0
jaraco.functools>=4.0.0
keyring>=25.0.0
markdown-it-py>=3.0.0
mdurl>=0.1.2
more-itertools>=10.2.0
nh3>=0.2.17
pkginfo>=1.10.0
Pygments>=2.17.2
readme_renderer>=43.0
requests>=2.31.0
requests-toolbelt>=1.0.0
rfc3986>=2.0.0
rich>=13.7.1
setuptools>=69.2.0
twine>=5.0.0
urllib3>=2.2.1
wheel>=0.43.0
zipp>=3.18.1
-r requirements.txt

23
requirements.txt Normal file
View File

@@ -0,0 +1,23 @@
annotated-types>=0.7.0 # Pydantic
pydantic>=2.8.2 # Pydantic
pydantic-core>=2.20.1 # Pydantic
typing-extensions>=4.12.2 # Pydantic
anyio>=4.4.0 # FastAPI
fastapi>=0.111.0 # FastAPI
idna>=3.7 # FastAPI
sniffio>=1.3.1 # FastAPI
starlette>=0.37.2 # FastAPI
fastapi-pagination>=0.12.26 # Pagination
sqlalchemy>=2.0.31 # SQLAlchemy
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

View File

@@ -1,13 +1,46 @@
import subprocess
from setuptools import find_packages, setup
with open("requirements.txt") as f:
requirements = f.read().splitlines()
def get_latest_git_tag() -> str:
try:
tag = (
subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"])
.strip()
.decode("utf-8")
)
if tag.startswith("v"):
tag = tag[1:]
except subprocess.CalledProcessError:
raise RuntimeError("Unable to get latest git tag")
return str(tag)
setup(
name="creyPY",
version="0.0.5",
description="My collection of Python and FastAPI shortcuts etc.",
version=get_latest_git_tag(),
description="Collection of my Python and FastAPI shortcuts, snippets etc.",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
author="Conrad Großer",
author_email="conrad@noah.tech",
packages=find_packages(),
url="https://github.com/creyD/creyPY",
license="MIT",
python_requires=">=3.12",
install_requires=requirements,
keywords=[
"creyPY",
"Python",
"FastAPI",
"shortcuts",
"snippets",
"utils",
"personal library",
],
platforms="any",
)

90
test.py Normal file
View File

@@ -0,0 +1,90 @@
import unittest
from uuid import UUID
from fastapi import HTTPException
from fastapi.routing import APIRoute
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from creyPY.fastapi.app import generate_unique_id
from creyPY.fastapi.crud import (
get_object_or_404,
)
from creyPY.fastapi.models.base import Base
class MockDBClass(Base):
def __init__(self, id):
self.id = id
class TestMyFunction(unittest.TestCase):
def setUp(self):
# Create a SQLite in-memory database for testing
engine = create_engine("sqlite:///:memory:")
# Create a sessionmaker bound to this engine
Session = sessionmaker(bind=engine)
# Now you can use Session() to get a session bound to the engine
self.db = Session()
# create the table
Base.metadata.create_all(engine)
def test_generate_unique_id(self):
# Test case 1: Route with no path parameters and GET method
route1 = APIRoute(path="/users", methods={"GET"}, endpoint=lambda: None)
assert generate_unique_id(route1) == "users_list"
# Test case 2: Route with path parameters and POST method
route2 = APIRoute(path="/users/{user_id}", methods={"POST"}, endpoint=lambda: None)
assert generate_unique_id(route2) == "users_post"
# Test case 3: Route with path parameters and multiple methods
route3 = APIRoute(path="/users/{user_id}", methods={"GET", "PUT"}, endpoint=lambda: None)
result = generate_unique_id(route3)
assert result == "users_get" or result == "users_put"
# Test case 4: Route with special characters in path
route4 = APIRoute(
path="/users/{user_id}/posts/{post_id}", methods={"DELETE"}, endpoint=lambda: None
)
assert generate_unique_id(route4) == "users_posts_delete"
# Test case 5: Route with multiple path parameters and PATCH method
route5 = APIRoute(
path="/users/{user_id}/posts/{post_id}", methods={"PATCH"}, endpoint=lambda: None
)
assert generate_unique_id(route5) == "users_posts_patch"
# Test case 6: Route with no path parameters and PUT method
route6 = APIRoute(path="/users", methods={"PUT"}, endpoint=lambda: None)
assert generate_unique_id(route6) == "users_put"
def test_get_object_or_404_existing_object(self):
# Arrange
obj_id = UUID("123e4567-e89b-12d3-a456-426614174000")
obj = MockDBClass(obj_id)
self.db.add(obj)
self.db.commit()
# Act
result = get_object_or_404(MockDBClass, obj_id, self.db)
# Assert
assert result == obj
def test_get_object_or_404_non_existing_object(self):
# Arrange
obj_id = UUID("123e4567-e89b-12d3-a456-426614174000")
# Act & Assert
with self.assertRaises(HTTPException) as exc_info:
get_object_or_404(MockDBClass, obj_id, self.db)
assert exc_info.exception.status_code == 404
assert exc_info.exception.detail == "The object does not exist."
if __name__ == "__main__":
unittest.main()