diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml index 4886b30..242abfa 100644 --- a/.github/workflows/python-check.yml +++ b/.github/workflows/python-check.yml @@ -32,4 +32,38 @@ jobs: version-file: 'pyproject.toml' - name: Run mypy - run: uv mypy movie-catalog + run: uv run mypy movie_catalog + + run-tests: + runs-on: ubuntu-latest + needs: + - run-checks + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Setup UV + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: uv sync --locked --all-extras --dev + + - name: Run python tests + run: uv run pytest + env: + TESTING: 1 + REDIS_PORT: 6379 diff --git a/movie_catalog/__init__.py b/movie_catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/movie_catalog/api/api_v1/auth/services/redis_tokens_helper.py b/movie_catalog/api/api_v1/auth/services/redis_tokens_helper.py index 9e6ed1e..890bad4 100644 --- a/movie_catalog/api/api_v1/auth/services/redis_tokens_helper.py +++ b/movie_catalog/api/api_v1/auth/services/redis_tokens_helper.py @@ -2,8 +2,13 @@ from redis import Redis -from api.api_v1.auth.services.tokens_helper import AbstractTokensHelper -from config import REDIS_DB_TOKENS, REDIS_HOST, REDIS_PORT, REDIS_TOKENS_SET_NAME +from movie_catalog.api.api_v1.auth.services.tokens_helper import AbstractTokensHelper +from movie_catalog.config import ( + REDIS_DB_TOKENS, + REDIS_HOST, + REDIS_PORT, + REDIS_TOKENS_SET_NAME, +) class RedisTokensHelper(AbstractTokensHelper): diff --git a/movie_catalog/api/api_v1/auth/services/redis_users_helper.py b/movie_catalog/api/api_v1/auth/services/redis_users_helper.py index a6beae7..79667c0 100644 --- a/movie_catalog/api/api_v1/auth/services/redis_users_helper.py +++ b/movie_catalog/api/api_v1/auth/services/redis_users_helper.py @@ -2,7 +2,7 @@ from redis import Redis -from config import REDIS_DB_USERS, REDIS_HOST, REDIS_PORT +from movie_catalog.config import REDIS_DB_USERS, REDIS_HOST, REDIS_PORT from .users_helper import AbstractUsersHelper diff --git a/movie_catalog/api/api_v1/movie_catalog/crud.py b/movie_catalog/api/api_v1/movie_catalog/crud.py index 97f2898..5e49f8f 100644 --- a/movie_catalog/api/api_v1/movie_catalog/crud.py +++ b/movie_catalog/api/api_v1/movie_catalog/crud.py @@ -4,13 +4,18 @@ from pydantic import BaseModel from redis import Redis -from config import ( +from movie_catalog.config import ( REDIS_DB_MOVIE_CATALOG, REDIS_HOST, REDIS_MOVIE_CATALOG_HASH_NAME, REDIS_PORT, ) -from schemas.movie_catalog import Movie, MovieCreate, MoviePartialUpdate, MovieUpdate +from movie_catalog.schemas.movie_catalog import ( + Movie, + MovieCreate, + MoviePartialUpdate, + MovieUpdate, +) logger = logging.getLogger(__name__) redis = Redis( diff --git a/movie_catalog/api/api_v1/movie_catalog/dependencies.py b/movie_catalog/api/api_v1/movie_catalog/dependencies.py index e512154..9005412 100644 --- a/movie_catalog/api/api_v1/movie_catalog/dependencies.py +++ b/movie_catalog/api/api_v1/movie_catalog/dependencies.py @@ -9,7 +9,7 @@ HTTPBearer, ) -from schemas.movie_catalog import Movie +from movie_catalog.schemas.movie_catalog import Movie from ..auth.services import redis_tokens, redis_users from .crud import storage @@ -87,7 +87,9 @@ def validate_basic_auth(credentials: HTTPBasicCredentials | None) -> None: def user_basic_auth_required( - credentials: Annotated[HTTPBasicCredentials | None, Depends(user_basic_auth)] = None + credentials: Annotated[ + HTTPBasicCredentials | None, Depends(user_basic_auth) + ] = None, ) -> None: validate_basic_auth(credentials=credentials) diff --git a/movie_catalog/api/api_v1/movie_catalog/views/details_view.py b/movie_catalog/api/api_v1/movie_catalog/views/details_view.py index 6e5e481..acfe879 100644 --- a/movie_catalog/api/api_v1/movie_catalog/views/details_view.py +++ b/movie_catalog/api/api_v1/movie_catalog/views/details_view.py @@ -2,12 +2,17 @@ from fastapi import APIRouter, Depends, status -from api.api_v1.movie_catalog.crud import storage -from api.api_v1.movie_catalog.dependencies import ( +from movie_catalog.api.api_v1.movie_catalog.crud import storage +from movie_catalog.api.api_v1.movie_catalog.dependencies import ( api_token_or_user_basic_auth_required, prefetch_film, ) -from schemas.movie_catalog import Movie, MoviePartialUpdate, MovieRead, MovieUpdate +from movie_catalog.schemas.movie_catalog import ( + Movie, + MoviePartialUpdate, + MovieRead, + MovieUpdate, +) router = APIRouter( prefix="/{slug}", diff --git a/movie_catalog/api/api_v1/movie_catalog/views/list_view.py b/movie_catalog/api/api_v1/movie_catalog/views/list_view.py index d6c969d..47b58cf 100644 --- a/movie_catalog/api/api_v1/movie_catalog/views/list_view.py +++ b/movie_catalog/api/api_v1/movie_catalog/views/list_view.py @@ -1,11 +1,15 @@ __all__ = ("router",) + from fastapi import APIRouter, Depends, HTTPException, status -from api.api_v1.movie_catalog.crud import MovieCatalogAlreadyExists, storage -from api.api_v1.movie_catalog.dependencies import ( +from movie_catalog.api.api_v1.movie_catalog.crud import ( + MovieCatalogAlreadyExists, + storage, +) +from movie_catalog.api.api_v1.movie_catalog.dependencies import ( api_token_or_user_basic_auth_required, ) -from schemas.movie_catalog import Movie, MovieCreate, MovieRead +from movie_catalog.schemas.movie_catalog import Movie, MovieCreate, MovieRead router: APIRouter = APIRouter( prefix="/movies", diff --git a/movie_catalog/commands/tokens.py b/movie_catalog/commands/tokens.py index 47f931a..d801556 100644 --- a/movie_catalog/commands/tokens.py +++ b/movie_catalog/commands/tokens.py @@ -2,11 +2,10 @@ from typing import Annotated import typer +from api.api_v1.auth.services import redis_tokens from rich import print from rich.markdown import Markdown -from api.api_v1.auth.services import redis_tokens - app = typer.Typer( name="token", help="Tokens management", @@ -52,7 +51,7 @@ def create_token() -> None: @app.command(name="delete") def delete_token( - token: Annotated[str, typer.Argument(help="The token to delete")] + token: Annotated[str, typer.Argument(help="The token to delete")], ) -> None: """ Delete a token. diff --git a/movie_catalog/config.py b/movie_catalog/config.py index 30fcb51..7ccad84 100644 --- a/movie_catalog/config.py +++ b/movie_catalog/config.py @@ -7,7 +7,7 @@ LOG_FORMAT = "[-] %(asctime)s [%(levelname)s] %(module)s-%(lineno)d - %(message)s" LOG_LEVEL = logging.DEBUG -REDIS_HOST = "localhost" +REDIS_HOST = getenv("REDIS_HOST", "localhost") REDIS_PORT = int(getenv("REDIS_PORT", 0)) or 6379 REDIS_DB = 0 REDIS_DB_TOKENS = 1 diff --git a/movie_catalog/main.py b/movie_catalog/main.py index 702cb14..3261cc5 100644 --- a/movie_catalog/main.py +++ b/movie_catalog/main.py @@ -2,10 +2,10 @@ from fastapi import FastAPI -import config -from api.main_view import router as main_router -from api import router as api_router -from app_lifespan import lifespan +from movie_catalog import config +from movie_catalog.api import router as api_router +from movie_catalog.api.main_view import router as main_router +from movie_catalog.app_lifespan import lifespan logging.basicConfig( format=config.LOG_FORMAT, diff --git a/movie_catalog/stuff.py b/movie_catalog/stuff.py index 6e68d32..76c249c 100644 --- a/movie_catalog/stuff.py +++ b/movie_catalog/stuff.py @@ -1,6 +1,5 @@ -from redis import Redis - from config import REDIS_DB, REDIS_HOST, REDIS_PORT +from redis import Redis redis = Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True) diff --git a/movie_catalog/tests/conftest.py b/movie_catalog/tests/conftest.py index d430ed3..7f56180 100644 --- a/movie_catalog/tests/conftest.py +++ b/movie_catalog/tests/conftest.py @@ -6,8 +6,8 @@ import pytest -from api.api_v1.movie_catalog.crud import storage -from schemas.movie_catalog import Movie, MovieCreate +from movie_catalog.api.api_v1.movie_catalog.crud import storage +from movie_catalog.schemas.movie_catalog import Movie, MovieCreate @pytest.fixture(scope="session", autouse=True) @@ -19,7 +19,7 @@ def check_testing_env() -> None: @pytest.fixture(autouse=True) -def disable_logging(): +def disable_logging() -> None: logging.getLogger().setLevel(logging.CRITICAL) @@ -43,7 +43,7 @@ def build_movie_create_random_slug( ) -> MovieCreate: return MovieCreate( slug="".join( - random.choices( + random.choices( # noqa: S311 Standard pseudo-random generators are not suitable for cryptographic purposes string.ascii_letters, k=10, ), diff --git a/movie_catalog/tests/test_api/conftest.py b/movie_catalog/tests/test_api/conftest.py index 2281e77..42cff1a 100644 --- a/movie_catalog/tests/test_api/conftest.py +++ b/movie_catalog/tests/test_api/conftest.py @@ -3,7 +3,7 @@ import pytest from fastapi.testclient import TestClient -from api.api_v1.auth.services import redis_tokens +from movie_catalog.api.api_v1.auth.services import redis_tokens from movie_catalog.main import app @@ -25,5 +25,3 @@ def auth_client(auth_token: str) -> Generator[TestClient]: headers = {"Authorization": f"Bearer {auth_token}"} with TestClient(app, headers=headers) as client: yield client - - diff --git a/movie_catalog/tests/test_api/test_api_v1/test_auth/test_services/test_redis_tokens_helper.py b/movie_catalog/tests/test_api/test_api_v1/test_auth/test_services/test_redis_tokens_helper.py index 5fad99a..01c5baf 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_auth/test_services/test_redis_tokens_helper.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_auth/test_services/test_redis_tokens_helper.py @@ -1,6 +1,6 @@ from unittest import TestCase -from api.api_v1.auth.services import redis_tokens +from movie_catalog.api.api_v1.auth.services import redis_tokens class RedisTokensHelperTestCase(TestCase): diff --git a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_crud.py b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_crud.py index 238d40e..16c5e71 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_crud.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_crud.py @@ -2,15 +2,24 @@ from typing import ClassVar from unittest import TestCase -from api.api_v1.movie_catalog.crud import storage -from schemas.movie_catalog import Movie, MovieCreate, MovieUpdate, MoviePartialUpdate +from movie_catalog.api.api_v1.movie_catalog.crud import storage +from movie_catalog.schemas.movie_catalog import ( + Movie, + MovieCreate, + MoviePartialUpdate, + MovieUpdate, +) def create_movie() -> Movie: import random movie_in = MovieCreate( - slug="".join(random.choices(string.ascii_letters + string.digits, k=8)), + slug="".join( + random.choices( # noqa: S311 Standard pseudo-random generators are not suitable for cryptographic purposes + string.ascii_letters + string.digits, k=8 + ) + ), title="Some title", description="Some description for unit-test", year_released=1901, @@ -77,6 +86,6 @@ def test_get_by_slug(self) -> None: self.assertEqual(movie, db_movie) @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: for movie in cls.movies: storage.delete(movie) diff --git a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_dependencies.py b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_dependencies.py index b4d0fb7..6bbfb72 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_dependencies.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_dependencies.py @@ -1,4 +1,4 @@ -from api.api_v1.movie_catalog.dependencies import UNSAFE_METHOD +from movie_catalog.api.api_v1.movie_catalog.dependencies import UNSAFE_METHOD class TestUnsafeMethods: diff --git a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_details_veiew.py b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_details_veiew.py index 3530fe0..6391f31 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_details_veiew.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_details_veiew.py @@ -1,17 +1,19 @@ from typing import Generator import pytest +from _pytest.fixtures import SubRequest from fastapi import status +from fastapi.testclient import TestClient -from api.api_v1.movie_catalog.crud import storage -from main import app -from schemas.movie_catalog import ( - Movie, - DESCRIPTION_MIN_LENGTH, +from movie_catalog.api.api_v1.movie_catalog.crud import storage +from movie_catalog.main import app +from movie_catalog.schemas.movie_catalog import ( DESCRIPTION_MAX_LENGTH, + DESCRIPTION_MIN_LENGTH, + Movie, MovieUpdate, ) -from tests.conftest import create_movie, create_movie_random_slug +from movie_catalog.tests.conftest import create_movie, create_movie_random_slug pytestmark = pytest.mark.apitest @@ -27,12 +29,12 @@ class TestDelete: ), ] ) - def movie(self, request) -> Generator[Movie, None, None]: + def movie(self, request: SubRequest) -> Generator[Movie, None, None]: movie = create_movie(slug=request.param) yield movie storage.delete(movie) - def test_delete_movie(self, movie, auth_client) -> None: + def test_delete_movie(self, movie: Movie, auth_client: TestClient) -> None: url = app.url_path_for("delete_movie", slug=movie.slug) response = auth_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT, response.text @@ -41,7 +43,7 @@ def test_delete_movie(self, movie, auth_client) -> None: class TestPartialUpdate: @pytest.fixture() - def movie(self, request) -> Generator[Movie, None, None]: + def movie(self, request: SubRequest) -> Generator[Movie, None, None]: movie = create_movie_random_slug(description=request.param) yield movie storage.delete(movie) @@ -64,7 +66,10 @@ def movie(self, request) -> Generator[Movie, None, None]: indirect=["movie"], ) def test_update_movie_details_partial( - self, movie, auth_client, new_description + self, + movie: Movie, + auth_client: TestClient, + new_description: str, ) -> None: url = app.url_path_for("partial_update_movie", slug=movie.slug) movie_before_update = storage.get_by_slug(movie.slug) @@ -72,12 +77,12 @@ def test_update_movie_details_partial( assert response.status_code == status.HTTP_200_OK, response.text movie_from_db = storage.get_by_slug(movie.slug) assert movie_from_db != movie_before_update - assert movie_from_db.description == new_description + assert movie_from_db.description == new_description # type: ignore class TestUpdate: @pytest.fixture() - def movie(self, request) -> Generator[Movie, None, None]: + def movie(self, request: SubRequest) -> Generator[Movie, None, None]: description, title = request.param movie = create_movie_random_slug(description=description, title=title) yield movie @@ -111,7 +116,11 @@ def movie(self, request) -> Generator[Movie, None, None]: indirect=["movie"], ) def test_update_movie_details( - self, movie, auth_client, new_description, new_title + self, + movie: Movie, + auth_client: TestClient, + new_description: str, + new_title: str, ) -> None: url = app.url_path_for("update_movie", slug=movie.slug) movie_before_update = storage.get_by_slug(movie.slug) @@ -126,5 +135,5 @@ def test_update_movie_details( assert response.status_code == status.HTTP_200_OK, response.text movie_from_db = storage.get_by_slug(movie.slug) assert movie_from_db != movie_before_update - assert movie_from_db.description == new_description - assert movie_from_db.title == new_title + assert movie_from_db.description == new_description # type: ignore + assert movie_from_db.title == new_title # type: ignore diff --git a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_list_view.py b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_list_view.py index 0fc4168..bb4decd 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_list_view.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_list_view.py @@ -4,25 +4,28 @@ from typing import Any import pytest +from _pytest.fixtures import SubRequest from fastapi import status from fastapi.testclient import TestClient from movie_catalog.main import app -from schemas.movie_catalog import MovieCreate, Movie -from tests.conftest import build_movie_create_random_slug +from movie_catalog.schemas.movie_catalog import Movie, MovieCreate +from movie_catalog.tests.conftest import build_movie_create_random_slug pytestmark = pytest.mark.apitest class TestCreate: - def test_create_movie(self, caplog, auth_client: TestClient): + def test_create_movie( + self, caplog: pytest.LogCaptureFixture, auth_client: TestClient + ) -> None: caplog.set_level(logging.DEBUG) url = app.url_path_for("add_movie") movie_create = MovieCreate( slug="".join( - random.choices( + random.choices( # noqa: S311 Standard pseudo-random generators are not suitable for cryptographic purposes string.ascii_letters, k=10, ), @@ -39,7 +42,9 @@ def test_create_movie(self, caplog, auth_client: TestClient): assert received_data == movie_create, received_data assert f"Add movie <{received_data.slug}> to catalog" in caplog.text - def test_create_movie_already_exists(self, auth_client: TestClient, movie: Movie): + def test_create_movie_already_exists( + self, auth_client: TestClient, movie: Movie + ) -> None: url = app.url_path_for("add_movie") movie_create = MovieCreate(**movie.model_dump()) data = movie_create.model_dump(mode="json") @@ -63,14 +68,16 @@ class TestCreateInvalid: ), ] ) - def movie_create_values(self, request) -> tuple[dict[str, Any], str]: + def movie_create_values(self, request: SubRequest) -> tuple[dict[str, Any], str]: build = build_movie_create_random_slug() data = build.model_dump(mode="json") slug, err_type = request.param data["slug"] = slug return data, err_type - def test_invalid_slug(self, auth_client, movie_create_values): + def test_invalid_slug( + self, auth_client: TestClient, movie_create_values: tuple[dict[str, Any], str] + ) -> None: url = app.url_path_for("add_movie") created_data, expected_error_type = movie_create_values response = auth_client.post(url, json=created_data) diff --git a/movie_catalog/tests/test_api/test_views.py b/movie_catalog/tests/test_api/test_views.py index 9c5a094..c794201 100644 --- a/movie_catalog/tests/test_api/test_views.py +++ b/movie_catalog/tests/test_api/test_views.py @@ -1,8 +1,9 @@ import pytest +from fastapi.testclient import TestClient @pytest.mark.apitest -def test_root_view(client): +def test_root_view(client: TestClient) -> None: response = client.get("/") assert response.status_code == 200 assert response.json() == { diff --git a/movie_catalog/tests/test_schemas/test_movie_catalog.py b/movie_catalog/tests/test_schemas/test_movie_catalog.py index c639365..b54ed76 100644 --- a/movie_catalog/tests/test_schemas/test_movie_catalog.py +++ b/movie_catalog/tests/test_schemas/test_movie_catalog.py @@ -2,7 +2,12 @@ from pydantic import ValidationError -from schemas.movie_catalog import MovieCreate, MovieUpdate, MoviePartialUpdate, Movie +from movie_catalog.schemas.movie_catalog import ( + Movie, + MovieCreate, + MoviePartialUpdate, + MovieUpdate, +) class MovieCreateTestCase(TestCase): diff --git a/pyproject.toml b/pyproject.toml index be4e5dc..63c3930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ select = [ "F401", # Pyflakes (F) "S", # flake8-bandit (S) "LOG", # flake8-logging (LOG) - "PT", # flake8-pytest-style (PT) + # "PT", # flake8-pytest-style (PT) # todo: remove after delete unittest style tests "RET", # flake8-return (RET) "ARG", # flake8-unused-arguments (ARG) "T20", # flake8-print (T20) @@ -106,3 +106,4 @@ unfixable = [] "stuff.py" = [ "T20", ] +"movie_catalog/tests/*" = ["S101"]