diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 18cf2ce..0cca8ba 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -16,6 +16,11 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] python-version: ["3.8", "3.9", "3.10", "3.11"] + experimental: [false] + include: + - python-version: "3.12-dev" + os: ubuntu-latest + experimental: true steps: - name: Checkout sources @@ -33,6 +38,7 @@ jobs: - name: Run tox run: tox + continue-on-error: ${{ matrix.experimental }} - name: upload coverage to Codecov if: "matrix.python-version == '3.11'" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a8e0bb0..907692c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,9 @@ repos: - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.3.0 hooks: - id: black - args: [--safe, --line-length=120] + args: [--safe] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: @@ -13,19 +13,13 @@ repos: - id: check-added-large-files - id: debug-statements language_version: python3 - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.269 hooks: - - id: flake8 - args: [--max-line-length=120] - language_version: python3 - - repo: https://github.com/asottile/reorder_python_imports - rev: v3.9.0 - hooks: - - id: reorder-python-imports - args: [--application-directories=.src/, --py310-plus] - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + - id: ruff + args: [--fix] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.3.0 hooks: - - id: pyupgrade - args: [--py311-plus] + - id: mypy + args: [--no-strict-optional, --ignore-missing-imports] diff --git a/changelog.d/20230130_102418_ryuusuke.rst b/changelog.d/20230130_102418_ryuusuke.rst new file mode 100644 index 0000000..83bd3a4 --- /dev/null +++ b/changelog.d/20230130_102418_ryuusuke.rst @@ -0,0 +1,34 @@ +.. A new scriv changelog fragment. +.. +.. Uncomment the header that is right (remove the leading dots). +.. +.. Removed +.. ------- +.. +.. - A bullet item for the Removed category. +.. +Added +----- + +- Added mypy type checker + +Changed +------- + +- Changed linting to ruff + +.. Deprecated +.. ---------- +.. +.. - A bullet item for the Deprecated category. +.. +.. Fixed +.. ----- +.. +.. - A bullet item for the Fixed category. +.. +.. Security +.. -------- +.. +.. - A bullet item for the Security category. +.. diff --git a/changelog.d/20230130_111713_ryuusuke_infra_update.rst b/changelog.d/20230130_111713_ryuusuke_infra_update.rst new file mode 100644 index 0000000..0e8ac65 --- /dev/null +++ b/changelog.d/20230130_111713_ryuusuke_infra_update.rst @@ -0,0 +1,34 @@ +.. A new scriv changelog fragment. +.. +.. Uncomment the header that is right (remove the leading dots). +.. +.. Removed +.. ------- +.. +.. - A bullet item for the Removed category. +.. +Added +----- + +- Added support for python 3.12-dev +.. +.. Changed +.. ------- +.. +.. - A bullet item for the Changed category. +.. +.. Deprecated +.. ---------- +.. +.. - A bullet item for the Deprecated category. +.. +.. Fixed +.. ----- +.. +.. - A bullet item for the Fixed category. +.. +.. Security +.. -------- +.. +.. - A bullet item for the Security category. +.. diff --git a/changelog.d/20230130_120134_ryuusuke_infra_update.rst b/changelog.d/20230130_120134_ryuusuke_infra_update.rst new file mode 100644 index 0000000..83e1278 --- /dev/null +++ b/changelog.d/20230130_120134_ryuusuke_infra_update.rst @@ -0,0 +1,34 @@ +.. A new scriv changelog fragment. +.. +.. Uncomment the header that is right (remove the leading dots). +.. +.. Removed +.. ------- +.. +.. - A bullet item for the Removed category. +.. +.. Added +.. ----- +.. +.. - A bullet item for the Added category. +.. +Changed +------- + +- query() and execute() will raise ConnectionError in case they are run without active connection. +.. +.. Deprecated +.. ---------- +.. +.. - A bullet item for the Deprecated category. +.. +.. Fixed +.. ----- +.. +.. - A bullet item for the Fixed category. +.. +.. Security +.. -------- +.. +.. - A bullet item for the Security category. +.. diff --git a/poetry.lock b/poetry.lock index e4b6a77..3120234 100644 --- a/poetry.lock +++ b/poetry.lock @@ -539,6 +539,65 @@ files = [ {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, ] +[[package]] +name = "mypy" +version = "1.3.0" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mypy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eb485cea53f4f5284e5baf92902cd0088b24984f4209e25981cc359d64448d"}, + {file = "mypy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c99c3ecf223cf2952638da9cd82793d8f3c0c5fa8b6ae2b2d9ed1e1ff51ba85"}, + {file = "mypy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:550a8b3a19bb6589679a7c3c31f64312e7ff482a816c96e0cecec9ad3a7564dd"}, + {file = "mypy-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cbc07246253b9e3d7d74c9ff948cd0fd7a71afcc2b77c7f0a59c26e9395cb152"}, + {file = "mypy-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:a22435632710a4fcf8acf86cbd0d69f68ac389a3892cb23fbad176d1cddaf228"}, + {file = "mypy-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e33bb8b2613614a33dff70565f4c803f889ebd2f859466e42b46e1df76018dd"}, + {file = "mypy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d23370d2a6b7a71dc65d1266f9a34e4cde9e8e21511322415db4b26f46f6b8c"}, + {file = "mypy-1.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:658fe7b674769a0770d4b26cb4d6f005e88a442fe82446f020be8e5f5efb2fae"}, + {file = "mypy-1.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d29e324cdda61daaec2336c42512e59c7c375340bd202efa1fe0f7b8f8ca"}, + {file = "mypy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d0b6c62206e04061e27009481cb0ec966f7d6172b5b936f3ead3d74f29fe3dcf"}, + {file = "mypy-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:76ec771e2342f1b558c36d49900dfe81d140361dd0d2df6cd71b3db1be155409"}, + {file = "mypy-1.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc95f8386314272bbc817026f8ce8f4f0d2ef7ae44f947c4664efac9adec929"}, + {file = "mypy-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:faff86aa10c1aa4a10e1a301de160f3d8fc8703b88c7e98de46b531ff1276a9a"}, + {file = "mypy-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8c5979d0deb27e0f4479bee18ea0f83732a893e81b78e62e2dda3e7e518c92ee"}, + {file = "mypy-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c5d2cc54175bab47011b09688b418db71403aefad07cbcd62d44010543fc143f"}, + {file = "mypy-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:87df44954c31d86df96c8bd6e80dfcd773473e877ac6176a8e29898bfb3501cb"}, + {file = "mypy-1.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:473117e310febe632ddf10e745a355714e771ffe534f06db40702775056614c4"}, + {file = "mypy-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:74bc9b6e0e79808bf8678d7678b2ae3736ea72d56eede3820bd3849823e7f305"}, + {file = "mypy-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:44797d031a41516fcf5cbfa652265bb994e53e51994c1bd649ffcd0c3a7eccbf"}, + {file = "mypy-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ddae0f39ca146972ff6bb4399f3b2943884a774b8771ea0a8f50e971f5ea5ba8"}, + {file = "mypy-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1c4c42c60a8103ead4c1c060ac3cdd3ff01e18fddce6f1016e08939647a0e703"}, + {file = "mypy-1.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e86c2c6852f62f8f2b24cb7a613ebe8e0c7dc1402c61d36a609174f63e0ff017"}, + {file = "mypy-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f9dca1e257d4cc129517779226753dbefb4f2266c4eaad610fc15c6a7e14283e"}, + {file = "mypy-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:95d8d31a7713510685b05fbb18d6ac287a56c8f6554d88c19e73f724a445448a"}, + {file = "mypy-1.3.0-py3-none-any.whl", hash = "sha256:a8763e72d5d9574d45ce5881962bc8e9046bf7b375b0abf031f3e6811732a897"}, + {file = "mypy-1.3.0.tar.gz", hash = "sha256:e1f4d16e296f5135624b34e8fb741eb0eadedca90862405b1f1fde2040b9bd11"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "nodeenv" version = "1.8.0" @@ -973,6 +1032,18 @@ virtualenv = ">=20.21" docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-argparse-cli (>=1.11)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "devpi-process (>=0.3)", "diff-cover (>=7.5)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.14)", "psutil (>=5.9.4)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-xdist (>=3.2.1)", "re-assert (>=1.1)", "time-machine (>=2.9)", "wheel (>=0.40)"] +[[package]] +name = "typing-extensions" +version = "4.6.1" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.6.1-py3-none-any.whl", hash = "sha256:6bac751f4789b135c43228e72de18637e9a6c29d12777023a703fd1a6858469f"}, + {file = "typing_extensions-4.6.1.tar.gz", hash = "sha256:558bc0c4145f01e6405f4a5fdbd82050bd221b119f4bf72a961a1cfd471349d6"}, +] + [[package]] name = "urllib3" version = "2.0.2" @@ -1015,4 +1086,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "a4c867683febf4858f5ca84977ab869f1cd241f83c787b0742917dc74e4fd472" +content-hash = "df072ff2a56f642123dc0a5ec80c259ca8d8e2c6367a68bf23cae1bf1c64dcc7" diff --git a/pyproject.toml b/pyproject.toml index 076ec8e..a0ae2d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mysql-context-manager" -version = "0.1.6" +version = "0.1.5" description = "Work with MySQL databases asynchronously, and in context." license = "MIT" authors = ["IdoKendo "] @@ -25,11 +25,37 @@ tox = "^4.5" pytest-asyncio = "^0.21" pytest-mock = "^3.10" pytest-cov = "^4.0" +mypy = "^1.3" [tool.pytest.ini_options] addopts = "--cov=mysql_context_manager --cov-report xml" testpaths = ["tests"] +[tool.ruff] +exclude = [".tox"] +target-version = "py38" +select = [ + "A", # builtins + "ARG", # unsued arguments + "B", # bugbear + "C4", # comprehensions + "C90", # mccabe + "COM", # commas + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "PT", # pytest style + "RUF", # ruff + "SIM", # simplify + "TID", # tidy imports + "UP", # pyupgrade + "W", # warnings +] + +[tool.ruff.isort] +force-single-line = true + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/src/mysql_context_manager/__init__.py b/src/mysql_context_manager/__init__.py index e69dbf3..d995762 100644 --- a/src/mysql_context_manager/__init__.py +++ b/src/mysql_context_manager/__init__.py @@ -3,6 +3,7 @@ __version__ = "0.1.6" +import contextlib import json from json import JSONDecodeError from typing import Any @@ -32,7 +33,7 @@ def __init__( if schema is None: schema = "" self.connection_string = f"mysql://{credentials}@{hostname}:{port}/{schema}" - self.connection = None + self.connection: databases.Database | None = None self.engine = None async def __aenter__(self): @@ -53,8 +54,8 @@ async def disconnect(self) -> None: async def connect(self) -> None: """Establishes the connection to the database""" self.connection = databases.Database(self.connection_string) - - await self.connection.connect() + if self.connection is not None: + await self.connection.connect() async def query(self, sql_query: str, **kwargs) -> list[dict[str, Any]]: """Queries the database @@ -65,21 +66,24 @@ async def query(self, sql_query: str, **kwargs) -> list[dict[str, Any]]: Returns: list[dict[str, Any]]: List of rows represented in dictioary format + Raises: + ConnectionError: in case of method call before running connect() + Examples: - >>> query = "select username, element from users where team_name = :team_name limit 2;" + >>> query = "select name, elem from users where team = :team limit 2;" >>> async with MysqlConnector(hostname="localhost") as conn: - >>> print(await conn.query(query, team_name="Team Avatar")) - [{"username": "Katara", "element": "water"}, {"username": "Toph", "element": "earth"}] + >>> print(await conn.query(query, team="Team Avatar")) + [{"name": "Katara", "elem": "water"}, {"name": "Toph", "elem": "earth"}] """ - result = await self.connection.fetch_all(query=sql_query, values=kwargs) - result = [dict(i) for i in result] + if self.connection is None: + raise ConnectionError("No active connection") + records = await self.connection.fetch_all(query=sql_query, values=kwargs) + result = [dict(i) for i in records] for res in result: for key, val in res.items(): - try: + with contextlib.suppress(JSONDecodeError, TypeError): res[key] = json.loads(val) - except (JSONDecodeError, TypeError): - pass return result async def execute(self, sql_query: str, **kwargs) -> None: @@ -88,9 +92,14 @@ async def execute(self, sql_query: str, **kwargs) -> None: Args: sql_query (str): SQL query, with placeholders as :placeholder + Raises: + ConnectionError: in case of method call before running connect() + Examples: - >>> query = "update users set username = :username where user_id = :user_id;" + >>> query = "update users set name = :name where user_id = :user_id;" >>> async with MysqlConnector(hostname="localhost") as conn: - >>> await conn.query(query, username="Truth", user_id=42) + >>> await conn.query(query, name="Truth", user_id=42) """ + if self.connection is None: + raise ConnectionError("No active connection") await self.connection.execute(query=sql_query, values=kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index da933b2..3bf2a73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,13 @@ @pytest.fixture() -def mock_db(mocker): +def _mock_db(mocker): mocker.patch("databases.Database.connect", return_value=None) mocker.patch("databases.Database.disconnect", return_value=None) mocker.patch( "databases.Database.fetch_all", return_value=[ - [("username", "Aang")], + [("name", "Aang")], ], ) mocker.patch("databases.Database.execute", return_value=None) diff --git a/tests/test_mysql_context_manager.py b/tests/test_mysql_context_manager.py index 3151ec6..e1b0334 100644 --- a/tests/test_mysql_context_manager.py +++ b/tests/test_mysql_context_manager.py @@ -1,6 +1,6 @@ import pytest -from mysql_context_manager import __version__ from mysql_context_manager import MysqlConnector +from mysql_context_manager import __version__ def test_version(): @@ -17,7 +17,7 @@ def test_connection_string_with_schema_name(): assert connector.connection_string == "mysql://root@localhost:3306/team_avatar" -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_false_disconnect(): connector = MysqlConnector(hostname="localhost") assert connector.connection is None @@ -25,16 +25,40 @@ async def test_false_disconnect(): assert connector.connection is None -@pytest.mark.asyncio -async def test_query(mock_db): +@pytest.mark.usefixtures("_mock_db") +@pytest.mark.asyncio() +async def test_query(): connector = MysqlConnector(hostname="localhost", schema="team_avatar") async with connector as conn: - result = await conn.query("select username from users;") - assert result[0]["username"] == "Aang" + result = await conn.query("select name from users;") + assert result[0]["name"] == "Aang" -@pytest.mark.asyncio -async def test_execute(mock_db): +@pytest.mark.usefixtures("_mock_db") +@pytest.mark.asyncio() +async def test_execute(): connector = MysqlConnector(hostname="localhost", schema="team_avatar") async with connector as conn: - await conn.execute("update users set avatar_state = 1 where username = :username;", username="Aang") + await conn.execute( + "update users set avatar_state = 1 where name = :name;", + name="Aang", + ) + + +@pytest.mark.usefixtures("_mock_db") +@pytest.mark.asyncio() +async def test_query_without_connection(): + connector = MysqlConnector(hostname="localhost", schema="team_avatar") + with pytest.raises(ConnectionError): + await connector.query("select name from users;") + + +@pytest.mark.usefixtures("_mock_db") +@pytest.mark.asyncio() +async def test_execute_without_connection(): + connector = MysqlConnector(hostname="localhost", schema="team_avatar") + with pytest.raises(ConnectionError): + await connector.execute( + "update users set avatar_state = 1 where name = :name;", + name="Aang", + ) diff --git a/tox.ini b/tox.ini index 94e7c78..3a445a5 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = py39, py310, py311, + py312, lint, [testenv] @@ -20,12 +21,12 @@ commands = [testenv:lint] deps = black - flake8 - pylint + ruff + mypy commands = - pylint src/ - black --line-length=120 --check src/ - flake8 --max-line-length=120 src/ + black --check src/ + ruff --force-exclude src/ + mypy --no-strict-optional --ignore-missing-imports src/ [gh-actions] python = @@ -33,3 +34,4 @@ python = 3.9: py39 3.10: py310 3.11: py311, lint + 3.12-dev: py312