diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e5d58f..ed6e305 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,7 @@ name: Build on: + workflow_dispatch: release: types: [published] push: @@ -16,11 +17,11 @@ env: jobs: build: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, "3.10", "3.11"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v1 @@ -59,39 +60,50 @@ jobs: bash <(curl -s https://codecov.io/bash) - name: Install distribution dependencies - run: pip install --upgrade twine setuptools wheel - if: matrix.python-version == 3.8 || matrix.python-version == 3.9 + run: pip install build + if: matrix.python-version == 3.12 - name: Create distribution package - run: python setup.py sdist bdist_wheel - if: matrix.python-version == 3.8 || matrix.python-version == 3.9 + run: python -m build + if: matrix.python-version == 3.12 - name: Upload distribution package - uses: actions/upload-artifact@master + uses: actions/upload-artifact@v4 with: - name: dist-package-${{ matrix.python-version }} + name: dist path: dist - if: matrix.python-version == 3.8 || matrix.python-version == 3.9 + if: matrix.python-version == 3.12 publish: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest needs: build - if: github.event_name == 'release' + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' steps: - name: Download a distribution artifact - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 with: - name: dist-package-3.9 + name: dist path: dist - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@master + + - name: Use Python 3.12 + uses: actions/setup-python@v1 with: - skip_existing: true - user: __token__ - password: ${{ secrets.test_pypi_password }} - repository_url: https://test.pypi.org/legacy/ + python-version: '3.12' + + - name: Install dependencies + run: | + pip install twine + + - name: Publish distribution 📦 to Test PyPI + run: | + twine upload -r testpypi dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.test_pypi_password }} + - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.pypi_password }} + run: | + twine upload -r pypi dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.pypi_password }} diff --git a/.gitignore b/.gitignore index 771a5b9..816c204 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ dmypy.json # Pyre type checker .pyre/ /tests/example.db + +example.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b02df..21b1b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.3] - 2023-12-29 :christmas_tree: +- Upgrades the library to use `pyproject.toml`. +- Updates the GitHub Workflow. +- Updates the LICENSE contact. + ## [0.0.2] - 2021-11-28 :sheep: - Unpins the `SQLAlchemy` version from requirements diff --git a/LICENSE b/LICENSE index 7c78d18..0b0a942 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Neoteroi +Copyright (c) 2021, current Roberto Prevato Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 00b26f1..18ad65a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ simplifies the use of SQLAlchemy in the web framework. pip install blacksheep-sqlalchemy ``` +**Important:** this library only supports `rodi` dependency injection +container. However, the implementation can be used for reference to configure +other DI containers to work with SQLAlchemy. + ## How to use ```python @@ -64,7 +68,7 @@ For example, using SQLite: * requires driver: `pip install aiosqlite` * connection string: `sqlite+aiosqlite:///example.db` -See the `tests` folder for a [working example](https://github.com/Neoteroi/BlackSheep-SQLAlchemy/blob/main/tests/app.py) +See the `tests` folder for a [working example](https://github.com/Neoteroi/BlackSheep-SQLAlchemy/blob/main/tests/app.py) using database migrations applied with `Alembic`, and a documented API that offers methods to fetch, create, delete countries objects. diff --git a/blacksheepsqlalchemy/__init__.py b/blacksheepsqlalchemy/__init__.py index 057ac01..38e4760 100644 --- a/blacksheepsqlalchemy/__init__.py +++ b/blacksheepsqlalchemy/__init__.py @@ -1,6 +1,7 @@ from typing import Optional from blacksheep.server import Application +from rodi import Container from sqlalchemy.ext.asyncio import ( AsyncConnection, AsyncEngine, @@ -31,6 +32,9 @@ def connection_factory() -> AsyncConnection: def session_factory() -> AsyncSession: return AsyncSession(engine, expire_on_commit=False) + assert isinstance( + app.services, Container + ), "blacksheep-sqlalchemy only supports rodi for dependency injection" app.services.add_instance(engine) app.services.add_alias(db_engine_alias, AsyncEngine) diff --git a/dev-requirements.txt b/dev-requirements.txt index a7710a9..6687ccb 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,42 +1,43 @@ -aiosqlite==0.17.0 -alembic==1.8.1 -attrs==22.1.0 -black==22.10.0 -blacksheep==1.2.8 -certifi==2022.9.24 -click==8.1.3 -coverage==6.5.0 +aiosqlite==0.19.0 +alembic==1.13.1 +attrs==23.1.0 +black==23.12.1 +blacksheep==2.0.3 +certifi==2023.11.17 +charset-normalizer==3.1.0 +click==8.1.7 +coverage==7.4.0 essentials==1.1.5 -essentials-openapi==0.1.6 -exceptiongroup==1.0.4 -flake8==6.0.0 -greenlet==2.0.1 -guardpost==0.0.9 +essentials-openapi==1.0.9 +exceptiongroup==1.2.0 +flake8==6.1.0 +greenlet==3.0.3 +guardpost==1.0.2 h11==0.14.0 -httptools==0.5.0 -iniconfig==1.1.1 -isort==5.10.1 +httptools==0.6.1 +iniconfig==2.0.0 +isort==5.13.2 itsdangerous==2.1.2 Jinja2==3.1.2 -Mako==1.2.4 -MarkupSafe==2.1.1 +Mako==1.3.0 +MarkupSafe==2.1.3 mccabe==0.7.0 -mypy-extensions==0.4.3 -packaging==21.3 -pathspec==0.10.2 -platformdirs==2.5.4 -pluggy==1.0.0 -pycodestyle==2.10.0 -pyflakes==3.0.1 -pyparsing==3.0.9 -pytest==7.2.0 -pytest-asyncio==0.20.2 -pytest-cov==4.0.0 +mypy-extensions==1.0.0 +packaging==23.2 +pathspec==0.12.1 +platformdirs==4.1.0 +pluggy==1.3.0 +pycodestyle==2.11.1 +pyflakes==3.1.0 +pyparsing==3.1.1 +pytest==7.4.3 +pytest-asyncio==0.23.2 +pytest-cov==4.1.0 python-dateutil==2.8.2 -PyYAML==5.4.1 -rodi==1.1.3 +PyYAML==6.0.1 +rodi==2.0.6 six==1.16.0 -SQLAlchemy==1.4.44 +SQLAlchemy==2.0.24 tomli==2.0.1 -typing_extensions==4.4.0 -uvicorn==0.20.0 +typing_extensions==4.9.0 +uvicorn==0.25.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..af1c987 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "blacksheep-sqlalchemy" +version = "0.0.3" +authors = [{ name = "Roberto Prevato", email = "roberto.prevato@gmail.com" }] +description = "Extension for BlackSheep to use SQLAlchemy." +readme = "README.md" +requires-python = ">=3.7" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", +] +keywords = ["blacksheep", "sqlalchemy", "orm", "database"] + +dependencies = ["blacksheep", "SQLAlchemy"] + +[tool.hatch.build.targets.wheel] +packages = ["blacksheepsqlalchemy"] + +[tool.hatch.build.targets.sdist] +exclude = ["tests"] + +[tool.hatch.build] +only-packages = true + +[project.urls] +"Homepage" = "https://github.com/Neoteroi/BlackSheep-SQLAlchemy" +"Bug Tracker" = "https://github.com/Neoteroi/BlackSheep-SQLAlchemy/issues" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/setup.py b/setup.py deleted file mode 100644 index 24e9711..0000000 --- a/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -from setuptools import setup - - -def readme(): - with open("README.md") as f: - return f.read() - - -setup( - name="blacksheep-sqlalchemy", - version="0.0.2", - description="Extension for BlackSheep to use SQLAlchemy", - long_description=readme(), - long_description_content_type="text/markdown", - classifiers=[ - "Development Status :: 3 - Alpha", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Operating System :: OS Independent", - ], - url="https://github.com/Neoteroi/BlackSheep-SQLAlchemy", - author="RobertoPrevato", - author_email="roberto.prevato@gmail.com", - keywords="blacksheep sqlalchemy orm database", - license="MIT", - packages=["blacksheepsqlalchemy"], - install_requires=["blacksheep", "SQLAlchemy"], - include_package_data=True, - zip_safe=False, -) diff --git a/tests/app.py b/tests/app.py index 162c2d9..c6e85c4 100644 --- a/tests/app.py +++ b/tests/app.py @@ -1,13 +1,6 @@ -import sys from dataclasses import dataclass from typing import List -from sqlalchemy.sql.expression import select - -# hack to keep example code inside the tests folder without polluting the root folder -# of the repository -sys.path.append("../") - import uvicorn from blacksheep.messages import Response from blacksheep.server import Application @@ -15,6 +8,8 @@ from openapidocs.v3 import Info from sqlalchemy import delete as sql_delete from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession +from sqlalchemy.sql.expression import select from blacksheepsqlalchemy import use_sqlalchemy from tests.domain import Country @@ -45,7 +40,9 @@ class CountryData: @docs(tags=["db-connection"]) @post("/api/connection/countries") -async def create_country_1(db_connection, data: CreateCountryInput) -> Response: +async def create_country_1( + db_connection: AsyncConnection, data: CreateCountryInput +) -> Response: """ Inserts a country using a database connection. """ @@ -60,7 +57,7 @@ async def create_country_1(db_connection, data: CreateCountryInput) -> Response: @docs(tags=["db-connection"]) @delete("/api/connection/countries/{country_id}") -async def delete_country_1(db_connection, country_id: str) -> Response: +async def delete_country_1(db_connection: AsyncConnection, country_id: str) -> Response: """ Deletes a country by id using a database connection. """ @@ -75,21 +72,21 @@ async def delete_country_1(db_connection, country_id: str) -> Response: @docs(tags=["db-connection"]) @get("/api/connection/countries") -async def get_countries_1(db_connection) -> List[CountryData]: +async def get_countries_1(db_connection: AsyncConnection) -> List[CountryData]: """ Fetches the countries using a database connection. """ result = [] async with db_connection: - items = await db_connection.execute(text("SELECT * FROM country")) + items = await db_connection.execute(text("SELECT id, name FROM country")) for item in items.fetchall(): - result.append(CountryData(item["id"], item["name"])) + result.append(CountryData(item[0], item[1])) return result @docs(tags=["ORM"]) @get("/api/orm/countries") -async def get_countries_2(db_session) -> List[CountryData]: +async def get_countries_2(db_session: AsyncSession) -> List[CountryData]: """ Fetches the countries using the ORM pattern. """ @@ -104,7 +101,9 @@ async def get_countries_2(db_session) -> List[CountryData]: @docs(tags=["ORM"]) @post("/api/orm/countries") -async def create_country_2(db_session, data: CreateCountryInput) -> Response: +async def create_country_2( + db_session: AsyncSession, data: CreateCountryInput +) -> Response: """ Inserts a country using the ORM pattern. """ @@ -116,7 +115,7 @@ async def create_country_2(db_session, data: CreateCountryInput) -> Response: @docs(tags=["ORM"]) @delete("/api/orm/countries/{country_id}") -async def delete_country_2(db_session, country_id: str) -> Response: +async def delete_country_2(db_session: AsyncSession, country_id: str) -> Response: """ Deletes a country using the ORM pattern. """ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..40d6dfb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +import os + +os.environ["APP_DEFAULT_ROUTER"] = "0" diff --git a/tests/fixtures.py b/tests/fixtures.py index afb7093..e040b9e 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -5,32 +5,25 @@ import pytest import uvicorn from blacksheep.client import ClientSession -from blacksheep.client.pool import ClientConnectionPools +from blacksheep.client.pool import ConnectionPools from tests.utils import get_sleep_time from .app import app -@pytest.fixture(scope="session") -def event_loop(): - """Create an instance of the default event loop for all test cases.""" - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - -@pytest.fixture(scope="module") -def client_session(server_host, server_port, event_loop): - # It is important to pass the instance of ClientConnectionPools, +@pytest.fixture() +async def client_session(server_host, server_port): + # It is important to pass the instance of ConnectionPools, # to ensure that the connections are reused and closed + event_loop = asyncio.get_running_loop() session = ClientSession( loop=event_loop, base_url=f"http://{server_host}:{server_port}", - pools=ClientConnectionPools(event_loop), + pools=ConnectionPools(event_loop), ) yield session - asyncio.run(session.close()) + await session.close() @pytest.fixture(scope="module") diff --git a/tests/test_blacksheepsqlalchemy.py b/tests/test_blacksheepsqlalchemy.py index 3b17e73..1648156 100644 --- a/tests/test_blacksheepsqlalchemy.py +++ b/tests/test_blacksheepsqlalchemy.py @@ -1,3 +1,5 @@ +from typing import Literal + import pytest from blacksheep.client import ClientSession from blacksheep.contents import JSONContent @@ -9,8 +11,13 @@ async def insert_fetch_delete_scenario( - client_session: ClientSession, option: str, country_code: str, country_name: str + client_session: ClientSession, + option: Literal["connection", "orm"], + country_code: str, + country_name: str, ): + await client_session.delete(f"/api/{option}/countries/{country_code}") + response = await client_session.post( "/api/connection/countries", JSONContent({"id": country_code, "name": country_name}),