diff --git a/.gitignore b/.gitignore index 54cd5e6..dabdbca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ __pycache__/ *.py[o|c] **_build/ geckodriver.log - +sql_app.db diff --git a/Makefile b/Makefile index d2b2955..9fbd449 100644 --- a/Makefile +++ b/Makefile @@ -12,12 +12,15 @@ run_dev: git ls-files | entr -r pipenv run python dev.py tdd: - git ls-files | entr pipenv run pytest --lf --nf + git ls-files | entr make test opt='--lf --ff' git ls-files | entr make functionnal_tests +watch_db: + watch "sqlite3 sql_app.db 'select * from movies'" + test: - pipenv run pytest $(opt) + pipenv run pytest $(opt) utests functionnal_tests: pipenv run python -m pytest functionnal_test.py @@ -31,3 +34,16 @@ docs: cd docs && firefox ./_build/html/README.html ./_build/html/index.html & .PHONY: docs + +venv_install: + make -f MakefileVenv install_dev + + +venv_test: + make -f MakefileVenv test + +db_clean: + rm sql_app.db + +db_fill: + pipenv run python database.py diff --git a/Pipfile b/Pipfile index a630cd6..c0f4fc3 100644 --- a/Pipfile +++ b/Pipfile @@ -6,10 +6,14 @@ name = "pypi" [packages] fastapi = "*" uvicorn = "*" +sqlalchemy = "<2.0.0" +wheel = "*" [dev-packages] pytest = "*" selenium = "*" +httpx = "*" +pdbpp = "*" [requires] -python_version = "3.12" +python_version = "3.11" diff --git a/README.rst b/README.rst index f28350a..76f0bca 100644 --- a/README.rst +++ b/README.rst @@ -58,6 +58,14 @@ Functional testing requires *geckodriver* and *selenium* which is a driver to pr +Database +-------- + +For now we only use sqlite + +One may use `make db_clean` and `make db_fill` to feed database. + + Toolings -------- diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..abb3f46 --- /dev/null +++ b/crud.py @@ -0,0 +1,32 @@ +from sqlalchemy.orm import Session + +import models + +# import schemas + + +def create_movie(db: Session, name: str = ""): + db_movie = models.Movie(name=name) + db.add(db_movie) + db.commit() + db.refresh(db_movie) + return db_movie + + +def get_movie_by_name(db: Session, name: str = ""): + db_movie = db.query(models.Movie).filter(models.Movie.name == name) + return db_movie.all() + + +def get_all_movies(db: Session): + db_movie = db.query(models.Movie) + return db_movie.all() + + +def get_movie_by_id(db: Session, id_: str = ""): + try: + id_ = int(id_) + except ValueError: + pass + db_movie = db.query(models.Movie).filter(models.Movie.id == id_) + return db_movie.one() diff --git a/database.py b/database.py new file mode 100644 index 0000000..655377f --- /dev/null +++ b/database.py @@ -0,0 +1,34 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db" +# SQLALCHEMY_DATABASE_URL = "postgresql://user:password@postgresserver/db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def create_db(): + import models + + models.Base.metadata.create_all(bind=engine) + + +def fill_db(): + create_db() + import crud + import random + + for _ in range(10): + name = f"fill_db_{random.randint(1, 1000):03}" + out = crud.create_movie(SessionLocal(), name=name) + print(out.name) + + +if __name__ == "__main__": + fill_db() diff --git a/dev.py b/dev.py index 0ce2447..d7f47e0 100644 --- a/dev.py +++ b/dev.py @@ -1,13 +1,68 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Depends, Request + +from sqlalchemy.orm import Session + import uvicorn +import database +import models +import crud + app = FastAPI() +# Dependency +def get_db(): + db = database.SessionLocal() + try: + yield db + finally: + db.close() + + @app.get("/") async def root(): return {"message": "Hello World"} +@app.post("/movies/") +async def create_movie( + name: str = "", db: Session = Depends(get_db), request: Request = None +): + out = {} + try: # Bypass for dev + data = await request.json() + except: + data = {} + name = name or data["name"] + movie = models.Movie() + movie.name = name + db.add(movie) + db.flush() + db.commit() + db.refresh(movie) + out = {"message": f"Created {movie.name} XX", "id": movie.id} + return out + + +@app.get("/movies/{id_}") +async def get_movie(id_: str, db: Session = Depends(get_db)): + movie = crud.get_movie_by_id(db, id_) + out = {k: v for (k, v) in movie.__dict__.items() if not k.startswith("_")} + return out + + +@app.get("/movies/") +async def create_movie(db: Session = Depends(get_db)): + movies = crud.get_all_movies(db) + + out = [ + {k: v for (k, v) in movie.__dict__.items() if not k.startswith("_")} + for movie in movies + ] + return out + + if __name__ == "__main__": + database.create_db() uvicorn.run(app, host="127.0.0.1", port=5000) diff --git a/functionnal_test.py b/ftests/test_functionnal.py similarity index 100% rename from functionnal_test.py rename to ftests/test_functionnal.py diff --git a/test_guidelines.py b/ftests/test_guidelines.py similarity index 92% rename from test_guidelines.py rename to ftests/test_guidelines.py index 7f18445..e908435 100644 --- a/test_guidelines.py +++ b/ftests/test_guidelines.py @@ -33,7 +33,9 @@ class TestGuidelines(unittest.TestCase): assert target.is_file() - @unittest.skipIf(os.environ.get("already_in_venv"), "Avoid self call infinite loop") + @unittest.skipIf( + True or os.environ.get("already_in_venv"), "Avoid self call infinite loop" + ) def test_environment(self): """We want to make sure that the project is virtualenv compatible. we may provie and extra makefile for that (and automate build phase) diff --git a/models.py b/models.py new file mode 100644 index 0000000..f660cc3 --- /dev/null +++ b/models.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, ForeignKey, Integer, String + +from database import Base + + +class Movie(Base): + __tablename__ = "movies" + + id = Column(Integer, primary_key=True, index=True) + + name = Column(String, index=True) diff --git a/requirements.txt b/requirements.txt index e4f81fa..2c80ff2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,16 @@ -i https://pypi.org/simple +annotated-types==0.5.0 ; python_version >= '3.7' +anyio==3.7.1 ; python_version >= '3.7' +click==8.1.7 ; python_version >= '3.7' +fastapi==0.101.1 +greenlet==2.0.2 ; python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32'))))) +h11==0.14.0 ; python_version >= '3.7' +idna==3.4 ; python_version >= '3.5' +pydantic==2.3.0 ; python_version >= '3.7' +pydantic-core==2.6.3 ; python_version >= '3.7' +sniffio==1.3.0 ; python_version >= '3.7' +sqlalchemy==1.4.49 +starlette==0.27.0 ; python_version >= '3.7' +typing-extensions==4.7.1 ; python_version >= '3.7' +uvicorn==0.23.2 +wheel==0.41.2 diff --git a/requirements_dev.txt b/requirements_dev.txt index f9e31e3..8edcb73 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,5 +1,27 @@ -i https://pypi.org/simple +anyio==3.7.1 ; python_version >= '3.7' +attrs==23.1.0 ; python_version >= '3.7' +certifi==2023.7.22 ; python_version >= '3.6' +exceptiongroup==1.1.3 ; python_version >= '3.7' +fancycompleter==0.9.1 +h11==0.14.0 ; python_version >= '3.7' +httpcore==0.17.3 ; python_version >= '3.7' +httpx==0.24.1 +idna==3.4 ; python_version >= '3.5' iniconfig==2.0.0 ; python_version >= '3.7' +outcome==1.2.0 ; python_version >= '3.7' packaging==23.1 ; python_version >= '3.7' +pdbpp==0.10.3 pluggy==1.2.0 ; python_version >= '3.7' +pygments==2.16.1 ; python_version >= '3.7' +pyrepl==0.9.0 +pysocks==1.7.1 pytest==7.4.0 +selenium==4.11.2 +sniffio==1.3.0 ; python_version >= '3.7' +sortedcontainers==2.4.0 +trio==0.22.2 ; python_version >= '3.7' +trio-websocket==0.10.3 ; python_version >= '3.7' +urllib3[socks]==2.0.4 ; python_version >= '3.7' +wmctrl==0.4 +wsproto==1.2.0 ; python_full_version >= '3.7.0' diff --git a/utests/__init__.py b/utests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_overview.py b/utests/test_overview.py similarity index 100% rename from test_overview.py rename to utests/test_overview.py diff --git a/utests/test_sql_database.py b/utests/test_sql_database.py new file mode 100644 index 0000000..1fca0e1 --- /dev/null +++ b/utests/test_sql_database.py @@ -0,0 +1,108 @@ +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from sqlalchemy import MetaData + +from database import Base +from dev import app, get_db +from models import Movie + +import pytest +import crud +import contextlib + +import random +import inspect + +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +Base.metadata.create_all(bind=engine) + + +def clear_db(): + # Make this a more generic functional tool for test + meta = MetaData() + with contextlib.closing(engine.connect()) as con: + trans = con.begin() + for table in reversed(meta.sorted_tables): + con.execute(table.delete()) + trans.commit() + + +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + clear_db() + finally: + db.close() + + +@contextlib.contextmanager +def db_context(): + yield from override_get_db() + + +app.dependency_overrides[get_db] = override_get_db + +client = TestClient(app) + + +def rand_name(): + import sys + + caller = sys._getframe(1).f_code.co_name + name = f"{caller}_{random.randint(1, 1000)}" + return name + + +def test_create_moviem_models(): + name = rand_name() + movie = Movie(name=name) + assert movie.name == name + + +def test_sample_crud(): + name = rand_name() + + with db_context() as db: + movie = crud.create_movie(db, name=name) + assert movie.name == name + + +def test_list_movies(): + clear_db() + response = client.get("/movies/") + # assert response.json() == [] + + N = 10 + names = [] + with db_context() as db: + for _ in range(N): + name = rand_name() + + names.append(name) + crud.create_movie(db, name=name) + + movies = client.get("movies") + movies_by_name = {m["name"]: m for m in movies.json()} + assert all(movies_by_name[name] for name in names) + + +def test_create_movie_api(): + name = f"rand_{random.randint(1, 1000)}" + response = client.post("/movies/", json={"name": name}) + assert response.status_code == 200 + movie_id = response.json()["id"] + assert f"Created {name}" in response.json()["message"] + response = client.get(f"/movies/{movie_id}") + assert response.json()["name"] == name