diff --git a/coverage.xml b/coverage.xml index 0880fee9..f71424a8 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,9 +1,9 @@ - - + + - /workspaces/devsetgo_lib + /github/workspace @@ -25,7 +25,7 @@ - + @@ -39,7 +39,7 @@ - + @@ -50,15 +50,15 @@ - - + + - - - - - - + + + + + + @@ -103,7 +103,7 @@ - + @@ -118,243 +118,285 @@ - - - + + + - - - - + + + + - + + - + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - + + + + + + + + + - + @@ -365,46 +407,46 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -418,166 +460,166 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - + @@ -589,50 +631,50 @@ - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - + @@ -646,68 +688,68 @@ - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + @@ -719,7 +761,7 @@ - + @@ -727,8 +769,8 @@ - - + + @@ -744,7 +786,7 @@ - + @@ -766,33 +808,33 @@ - - + + - - - - - - + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/dsg_lib/__init__.py b/dsg_lib/__init__.py index 3209b01e..179fd161 100644 --- a/dsg_lib/__init__.py +++ b/dsg_lib/__init__.py @@ -8,7 +8,7 @@ """ from datetime import date -__version__ = "2024.10.20.1" +__version__ = "2024-11-28-001" __author__ = "Mike Ryan" __license__ = "MIT" __copyright__ = f"Copyright© 2021-{date.today().year}" diff --git a/dsg_lib/async_database_functions/database_operations.py b/dsg_lib/async_database_functions/database_operations.py index cffa2e78..6ba73287 100644 --- a/dsg_lib/async_database_functions/database_operations.py +++ b/dsg_lib/async_database_functions/database_operations.py @@ -28,15 +28,15 @@ Date: 2024/05/16 License: MIT """ - +import functools import time -from typing import Dict, List, Type +import warnings +from typing import Any, Dict, List, Optional, Tuple, Type, Union from sqlalchemy import delete from sqlalchemy.ext.declarative import DeclarativeMeta +from sqlalchemy.sql.elements import ClauseElement -# from loguru import logger -# import logging as logger from .. import LOGGER as logger from .__import_sqlalchemy import import_sqlalchemy # Importing AsyncDatabase class from local module async_database @@ -107,6 +107,22 @@ def handle_exceptions(ex: Exception) -> Dict[str, str]: return {"error": "General Exception", "details": str(ex)} +def deprecated(reason): + def decorator(func): + message = f"{func.__name__}() is deprecated: {reason}" + + @functools.wraps(func) + async def wrapped(*args, **kwargs): + warnings.warn( + message, + DeprecationWarning, + stacklevel=2, + ) + return await func(*args, **kwargs) + return wrapped + return decorator + + class DatabaseOperations: """ This class provides methods for performing CRUD operations on a database using SQLAlchemy's asynchronous session. @@ -435,6 +451,7 @@ async def get_table_names(self): logger.error(f"Exception occurred: {ex}") # pragma: no cover return handle_exceptions(ex) # pragma: no cover + @deprecated("Use `execute_one` with an INSERT query instead.") async def create_one(self, record): """ Adds a single record to the database. @@ -506,6 +523,7 @@ async def create_one(self, record): logger.error(f"Exception occurred: {ex}") return handle_exceptions(ex) + @deprecated("Use `execute_one` with an INSERT query instead.") async def create_many(self, records): """ Adds multiple records to the database. @@ -904,6 +922,7 @@ async def read_multi_query(self, queries: Dict[str, str]): logger.error(f"Exception occurred: {ex}") return handle_exceptions(ex) + @deprecated("Use `execute_one` with a UPDATE query instead.") async def update_one(self, table, record_id: str, new_values: dict): """ Updates a single record in the database identified by its ID. @@ -997,6 +1016,7 @@ async def update_one(self, table, record_id: str, new_values: dict): logger.error(f"Exception occurred: {ex}") return handle_exceptions(ex) + @deprecated("Use `execute_many` with a DELETE query instead.") async def delete_one(self, table, record_id: str): """ Deletes a single record from the database based on the provided table @@ -1100,6 +1120,7 @@ async def delete_one(self, table, record_id: str): logger.error(f"Exception occurred: {ex}") return handle_exceptions(ex) + @deprecated("User 'execute_many' with a DELETE query instead.") async def delete_many( self, table: Type[DeclarativeMeta], @@ -1185,3 +1206,93 @@ async def delete_many( # Handle any exceptions that occur during the record deletion logger.error(f"Exception occurred: {ex}") return handle_exceptions(ex) + + + async def execute_one( + self, + query: ClauseElement, + values: Optional[Dict[str, Any]] = None + ) -> Union[str, Dict[str, str]]: + """ + Executes a single non-read SQL query asynchronously. + + This method executes a single SQL statement that modifies the database, + such as INSERT, UPDATE, or DELETE. It handles the execution within an + asynchronous session and commits the transaction upon success. + + Args: + query (ClauseElement): An SQLAlchemy query object representing the SQL statement to execute. + values (Optional[Dict[str, Any]]): A dictionary of parameter values to bind to the query. + Defaults to None. + + Returns: + Union[str, Dict[str, str]]: "complete" if the query executed and committed successfully, + or an error dictionary if an exception occurred. + + Example: + ```python + from sqlalchemy import insert + + query = insert(User).values(name='John Doe') + result = await db_ops.execute_one(query) + ``` + """ + logger.debug("Starting execute_one operation") + try: + async with self.async_db.get_db_session() as session: + logger.debug(f"Executing query: {query}") + await session.execute(query, params=values) + await session.commit() + logger.debug("Query executed successfully") + return "complete" + except Exception as ex: + logger.error(f"Exception occurred: {ex}") + return handle_exceptions(ex) + + async def execute_many( + self, + queries: List[Tuple[ClauseElement, Optional[Dict[str, Any]]]] + ) -> Union[str, Dict[str, str]]: + """ + Executes multiple non-read SQL queries asynchronously within a single transaction. + + This method executes a list of SQL statements that modify the database, + such as multiple INSERTs, UPDATEs, or DELETEs. All queries are executed + within the same transaction, which is committed if all succeed, or rolled + back if any fail. + + Args: + queries (List[Tuple[ClauseElement, Optional[Dict[str, Any]]]]): A list of tuples, each containing + a query and an optional dictionary of parameter values. Each tuple should be of the form + `(query, values)` where: + - `query` is an SQLAlchemy query object. + - `values` is a dictionary of parameters to bind to the query (or None). + + Returns: + Union[str, Dict[str, str]]: "complete" if all queries executed and committed successfully, + or an error dictionary if an exception occurred. + + Example: + ```python + from sqlalchemy import insert + + queries = [ + (insert(User), {'name': 'User1'}), + (insert(User), {'name': 'User2'}), + (insert(User), {'name': 'User3'}), + ] + result = await db_ops.execute_many(queries) + ``` + """ + logger.debug("Starting execute_many operation") + try: + async with self.async_db.get_db_session() as session: + for query, values in queries: + logger.debug(f"Executing query: {query}") + await session.execute(query, params=values) + await session.commit() + logger.debug("All queries executed successfully") + return "complete" + except Exception as ex: + logger.error(f"Exception occurred: {ex}") + return handle_exceptions(ex) diff --git a/examples/fastapi_example.py b/examples/fastapi_example.py index ef2b3b10..6188cf3b 100644 --- a/examples/fastapi_example.py +++ b/examples/fastapi_example.py @@ -13,7 +13,7 @@ from fastapi.responses import RedirectResponse from loguru import logger from pydantic import BaseModel, EmailStr -from sqlalchemy import Column, ForeignKey, Select, String +from sqlalchemy import Column, ForeignKey, Select, String, insert from sqlalchemy.orm import relationship from tqdm import tqdm @@ -99,7 +99,7 @@ async def lifespan(app: FastAPI): create_users = True if create_users: - await create_a_bunch_of_users(single_entry=2000, many_entries=20000) + await create_a_bunch_of_users(single_entry=2, many_entries=100) yield logger.info("shutting down") await async_db.disconnect() @@ -341,6 +341,32 @@ async def read_list_of_records( return records_list +@app.post("/database/execute-one", tags=["Database Examples"]) +async def execute_query(query: str = Body(...)): + # add a user with execute_one + logger.info(f"Executing query: {query}") + + query = insert(User).values(first_name='John', last_name='Doe',email='x@abc.com') + result = await db_ops.execute_one(query) + logger.info(f"Executed query: {result}") + query_return = await db_ops.read_query(Select(User).where(User.first_name == 'John')) + return query_return + +@app.post("/database/execute-many", tags=["Database Examples"]) +async def execute_many(query: str = Body(...)): + # multiple users with execute_many + logger.info(f"Executing query: {query}") + queries = [] + + for i in range(10): + query = insert(User).values(first_name=f'User{i}', last_name='Doe',email='x@abc.com') + queries.append(query) + + results = await db_ops.execute_many(queries) + logger.info(f"Executed query: {results}") + query_return = await db_ops.read_query(Select(User)) + return query_return + if __name__ == "__main__": import uvicorn diff --git a/pyproject.toml b/pyproject.toml index 37926d19..36c29974 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,49 +1,16 @@ [build-system] -requires = ["hatchling"] +requires = [ "hatchling",] build-backend = "hatchling.build" [project] name = "devsetgo_lib" -version = "2024.10.20.1" +version = "2024.11.28.1" requires-python = ">=3.9" -description = """ -DevSetGo Library is a Python library offering reusable functions for efficient coding. It includes file operations, calendar utilities, pattern matching, advanced logging with loguru, FastAPI endpoints, async database handling, and email validation. Designed for ease of use and versatility, it's a valuable tool for Python developers. -""" -keywords = [ - "python", - "library", - "reusable functions", - "file operations", - "calendar utilities", - "pattern matching", - "logging", - "loguru", - "FastAPI", - "async database", - "CRUD operations", - "email validation", - "development tools", -] +description = "DevSetGo Library is a Python library offering reusable functions for efficient coding. It includes file operations, calendar utilities, pattern matching, advanced logging with loguru, FastAPI endpoints, async database handling, and email validation. Designed for ease of use and versatility, it's a valuable tool for Python developers.\n" +keywords = [ "python", "library", "reusable functions", "file operations", "calendar utilities", "pattern matching", "logging", "loguru", "FastAPI", "async database", "CRUD operations", "email validation", "development tools",] readme = "README.md" -classifiers = [ - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - # "Programming Language :: Python :: 3.13", - "Operating System :: POSIX :: Linux", - "Operating System :: POSIX", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", -] -dependencies = [ - "loguru>=0.7.0", - "packaging>=20.0", - "email-validator>=2.1.1", -] +classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Operating System :: POSIX :: Linux", "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows",] +dependencies = [ "loguru>=0.7.0", "packaging>=20.0", "email-validator>=2.1.1",] [[project.authors]] name = "Mike Ryan" email = "mikeryan56@gmail.com" @@ -129,7 +96,7 @@ exclude_lines = [ "pragma: no cover", "if __name__", "def main", "import_sqlalch norecursedirs = [ "/tests",] testpaths = [ "tests",] python_files = [ "test_*.py", "*_test.py",] -addopts = [ "--cov=./", "--cov-report=html", "--cov-report=xml", "--junitxml=report.xml", "-ra", "--strict-markers", "--tb=short", "-p pytester",] +addopts = [ "--cov=./", "--cov-report=html", "--cov-report=xml", "--junitxml=report.xml", "--html=htmlcov/_test_report.html", "--self-contained-html", "-ra", "--strict-markers", "--tb=short", "-p", "pytester",] [tool.hatch.build.targets.sdist] include = [ "/dsg_lib",] diff --git a/report.xml b/report.xml index b62497ac..c1433aca 100644 --- a/report.xml +++ b/report.xml @@ -1 +1 @@ - + diff --git a/requirements.txt b/requirements.txt index 9fa5ff83..48da0c48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,42 +1,44 @@ aiomysql==0.2.0 # Vulnerabilities: None aiosqlite==0.20.0 # Vulnerabilities: None -asyncpg==0.29.0 # Vulnerabilities: None +asyncpg==0.30.0 # From 0.29.0 | Vulnerabilities: None autoflake==2.3.1 # Vulnerabilities: None autopep8==2.3.1 # Vulnerabilities: None black==24.10.0 # Vulnerabilities: None -bumpcalver==2024.10.20.4 # Vulnerabilities: None +bumpcalver==2024.11.8 # From 2024.10.20.4 | Vulnerabilities: None click==8.1.7 # Vulnerabilities: None -fastapi[all]==0.115.4 # Vulnerabilities: None +fastapi[all]==0.115.5 # From 0.115.2 | Vulnerabilities: None flake8==7.1.1 # Vulnerabilities: None genbadge[all]==1.1.1 # Vulnerabilities: None -hatchling==1.25.0 # Vulnerabilities: None +hatchling==1.26.3 # From 1.25.0 | Vulnerabilities: None loguru==0.7.2 # Vulnerabilities: None mike==2.1.3 # Vulnerabilities: None -mkdocs-material==9.5.41 # Vulnerabilities: None +mkdocs-material==9.5.46 # From 9.5.41 | Vulnerabilities: None mkdocs-print-site-plugin==2.6.0 # Vulnerabilities: None -mkdocstrings[python,shell]==0.26.2 # Vulnerabilities: None -oracledb==2.4.1 # Vulnerabilities: None -packaging==24.1 # Vulnerabilities: None +mkdocstrings[python,shell]==0.27.0 # From 0.26.2 | Vulnerabilities: None +oracledb==2.5.0 # From 2.4.1 | Vulnerabilities: None +packaging==24.2 # From 24.1 | Vulnerabilities: None pre-commit==4.0.1 # Vulnerabilities: None psycopg2==2.9.10 # Vulnerabilities: None Pygments==2.18.0 # Vulnerabilities: None pylint==3.3.1 # Vulnerabilities: None -pymdown-extensions==10.12 # Vulnerabilities: None +pymdown-extensions==10.12 # From 10.11.2 | Vulnerabilities: None pytest==8.3.3 # Vulnerabilities: None pytest-asyncio==0.24.0 # Vulnerabilities: None -pytest-cov==5.0.0 # Vulnerabilities: None +pytest-cov==6.0.0 # From 5.0.0 | Vulnerabilities: None +pytest-html==4.1.1 # From none | Vulnerabilities: None pytest-mock==3.14.0 # Vulnerabilities: None pytest-runner==6.0.1 # Vulnerabilities: None pytest-xdist==3.6.1 # Vulnerabilities: None python-json-logger==2.0.7 # Vulnerabilities: None PyYAML==6.0.2 # Vulnerabilities: None -ruff==0.7.1 # Vulnerabilities: None +ruff==0.8.0 # From 0.7.0 | Vulnerabilities: None SQLAlchemy==2.0.36 # Vulnerabilities: None structlog==24.4.0 # Vulnerabilities: None toml==0.10.2 # Vulnerabilities: None -tox==4.23.2 # Vulnerabilities: None -tqdm==4.66.6 # Vulnerabilities: None +tox==4.23.2 # From 4.23.0 | Vulnerabilities: None +tqdm==4.67.1 # From 4.66.5 | Vulnerabilities: None twine==5.1.1 # Vulnerabilities: None -watchdog==5.0.3 # Vulnerabilities: None -wheel==0.44.0 # Vulnerabilities: None +watchdog==6.0.0 # From 5.0.3 | Vulnerabilities: None +wheel==0.45.1 # From 0.44.0 | Vulnerabilities: None xmltodict==0.14.2 # Vulnerabilities: None + diff --git a/tests-badge.svg b/tests-badge.svg index 80231369..306ea6a6 100644 --- a/tests-badge.svg +++ b/tests-badge.svg @@ -1 +1 @@ -tests: 117tests117 +tests: 121tests121 diff --git a/tests/test_database_functions/test_async_database.py b/tests/test_database_functions/test_async_database.py index 0bc2aa21..368b16cc 100644 --- a/tests/test_database_functions/test_async_database.py +++ b/tests/test_database_functions/test_async_database.py @@ -3,7 +3,7 @@ import secrets import pytest -from sqlalchemy import Column, Integer, String, select +from sqlalchemy import Column, Integer, String, delete, insert, select from sqlalchemy.exc import IntegrityError, SQLAlchemyError from dsg_lib.async_database_functions.async_database import AsyncDatabase @@ -439,3 +439,69 @@ async def test_delete_many_exception(self, db_ops): ) # assert result contains "error" assert "error" in result + + @pytest.fixture(scope="class", autouse=True) + async def setup_database(self): + await async_db.create_tables() + yield + await async_db.drop_tables() + + @pytest.fixture(scope="function", autouse=True) + async def setup_teardown(self): + # Clean the database before each test + await db_ops.execute_one(delete(User)) + yield + # Clean the database after each test + await db_ops.execute_one(delete(User)) + + @pytest.mark.asyncio + async def test_execute_one_insert(self): + query = insert(User).values(name='Test User') + result = await db_ops.execute_one(query) + assert result == "complete" + r_query = select(User).where(User.name == 'Test User') + user = await db_ops.read_one_record(query=r_query) + assert user.name == 'Test User' + + @pytest.mark.asyncio + async def test_execute_many_insert(self): + queries = [ + (insert(User), {'name': f'User {i}'}) for i in range(1, 6) + ] + result = await db_ops.execute_many(queries) + assert result == "complete" + r_query = select(User) + users = await db_ops.read_query(query=r_query) + assert len(users) >= 5 + + @pytest.mark.asyncio + async def test_execute_one_delete(self): + query = insert(User).values(name='Test User') + await db_ops.execute_one(query) + query = delete(User).where(User.name == 'Test User') + result = await db_ops.execute_one(query) + assert result == "complete" + r_query = select(User).where(User.name == 'Test User') + user = await db_ops.read_one_record(query=r_query) + assert user is None + + @pytest.mark.asyncio + async def test_execute_many_delete(self): + # Insert users to delete + queries = [ + (insert(User), {'name': f'User {i}'}) for i in range(1, 6) + ] + await db_ops.execute_many(queries) + # Fetch all users + r_query = select(User) + users = await db_ops.read_query(query=r_query) + # Create delete queries based on pkid + user_pkids = [user.pkid for user in users] + queries = [ + (delete(User).where(User.pkid == pkid), None) for pkid in user_pkids + ] + result = await db_ops.execute_many(queries) + assert result == "complete" + # Verify all users are deleted + users = await db_ops.read_query(query=r_query) + assert len(users) == 0