diff --git a/pytest-clickhouse/LICENSE.txt b/pytest-clickhouse/LICENSE.txt new file mode 100644 index 00000000..d32613ca --- /dev/null +++ b/pytest-clickhouse/LICENSE.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024-present DecFox + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/pytest-clickhouse/README.md b/pytest-clickhouse/README.md new file mode 100644 index 00000000..8d6d7d21 --- /dev/null +++ b/pytest-clickhouse/README.md @@ -0,0 +1,21 @@ +# pytest-clickhouse + +[![PyPI - Version](https://img.shields.io/pypi/v/pytest-clickhouse.svg)](https://pypi.org/project/pytest-clickhouse) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-clickhouse.svg)](https://pypi.org/project/pytest-clickhouse) + +----- + +**Table of Contents** + +- [Installation](#installation) +- [License](#license) + +## Installation + +```console +pip install pytest-clickhouse +``` + +## License + +`pytest-clickhouse` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/pytest-clickhouse/dist/pytest_clickhouse-0.0.1-py3-none-any.whl b/pytest-clickhouse/dist/pytest_clickhouse-0.0.1-py3-none-any.whl new file mode 100644 index 00000000..1901830c Binary files /dev/null and b/pytest-clickhouse/dist/pytest_clickhouse-0.0.1-py3-none-any.whl differ diff --git a/pytest-clickhouse/dist/pytest_clickhouse-0.0.1.tar.gz b/pytest-clickhouse/dist/pytest_clickhouse-0.0.1.tar.gz new file mode 100644 index 00000000..3bd60aff Binary files /dev/null and b/pytest-clickhouse/dist/pytest_clickhouse-0.0.1.tar.gz differ diff --git a/pytest-clickhouse/pyproject.toml b/pytest-clickhouse/pyproject.toml new file mode 100644 index 00000000..55531cee --- /dev/null +++ b/pytest-clickhouse/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pytest-clickhouse" +dynamic = ["version"] +description = 'pytest plugin for clickhouse' + +dependencies = [ + "clickhouse-driver ~= 0.2.6", + "docker ~= 6.1.3", +] + +readme = "README.md" +requires-python = ">=3.8" +license = "BSD-3-Clause" +keywords = [] +authors = [ + { name = "OONI", email = "contact@ooni.org" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "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", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] + +[project.urls] +Documentation = "https://docs.ooni.org" +Issues = "https://github.com/ooni/backend/issues" +Source = "https://github.com/ooni/backend" + +[project.entry-points.pytest11] +pytest_clickhouse = "pytest_clickhouse.plugin" + +[tool.hatch.version] +path = "pytest_clickhouse/__about__.py" + +[tool.hatch.envs.default] +dependencies = [ + "pytest", + "pytest-cov", + "black", + "click", +] +path=".venv/" + +[tool.hatch.envs.default.scripts] +test = "pytesli-lt {args:tests}" +test-cov = "pytest -s --full-trace --log-level=INFO --log-cevel=INFO -v --setup-show --cov=./ --cov-report=xml --cov-report=html --cov-report=term {args:tests}" +cov-report = ["coverage report"] +cov = ["test-cov", "cov-report"] + +[[tool.hatch.envs.all.matrix]] +python = ["3.8", "3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.types] +dependencies = [ + "mypy>=1.0.0", +] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:pytest_clickhouse tests}" + +[tool.coverage.run] +source_pkgs = ["pytest_clickhouse", "tests"] +branch = true +parallel = true +omit = [ + "pytest_clickhouse/__about__.py", +] + +[tool.coverage.paths] +pytest_clickhouse = ["pytest_clickhouse", "*/pytest-clickhouse/pytest_clickhouse"] +tests = ["tests", "*/pytest-clickhouse/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/pytest-clickhouse/pytest_clickhouse/__about__.py b/pytest-clickhouse/pytest_clickhouse/__about__.py new file mode 100644 index 00000000..f102a9ca --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/__about__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/pytest-clickhouse/pytest_clickhouse/__init__.py b/pytest-clickhouse/pytest_clickhouse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest-clickhouse/pytest_clickhouse/config.py b/pytest-clickhouse/pytest_clickhouse/config.py new file mode 100644 index 00000000..d555f467 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/config.py @@ -0,0 +1,32 @@ +from pathlib import Path +from typing import TypedDict, Optional, List, Union + +from pytest import FixtureRequest + + +class ClickhouseConfigDict(TypedDict): + """ + Typed Config Dictionary + """ + + image_name: str + host: str + port: Optional[str] + dbname: str + + +def get_config(request: FixtureRequest) -> ClickhouseConfigDict: + """ + Return a dictionary with config options + """ + + def get_clickhouse_option(option: str) -> any: + name = f"clickhouse_{option}" + return request.config.getoption(name) or request.config.getini(name) + + return ClickhouseConfigDict( + image_name=get_clickhouse_option("image"), + host=get_clickhouse_option("host"), + port=get_clickhouse_option("port"), + dbname=get_clickhouse_option("dbname"), + ) diff --git a/pytest-clickhouse/pytest_clickhouse/executor.py b/pytest-clickhouse/pytest_clickhouse/executor.py new file mode 100644 index 00000000..360fe5f5 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/executor.py @@ -0,0 +1,70 @@ +from typing import Optional +import time + +import docker +from docker.models.containers import Container as DockerContainer + +class ClickhouseExecutor: + """ + Clickhouse executor running on docker + """ + + + def __init__(self, image_name: str, dbname: str): + self.image_name = image_name + self.container = None + self.clickhouse_url = "" + self.dbname = dbname + + + def start(self): + client = docker.from_env() + image_name = self.image_name + client.images.pull(image_name) + + # run a container with a random port mapping for ClickHouse default port 9000 + container = client.containers.run( + image_name, + ports={ + "9000/tcp": None + }, + detach=True, + ) + assert isinstance(container, DockerContainer) + self.container = container + + # obtain the port mapping + container.reload() + assert isinstance(container.attrs, dict) + host_port = container.attrs["NetworkSettings"]["Ports"]["9000/tcp"][0]["HostPort"] + + # construct the connection string + clickhouse_url = f"clickhouse://localhost:{host_port}" + self.clickhouse_url = clickhouse_url + + + def wait_for_clickhouse(self): + while 1: + if self.running(): + break + time.sleep(1) + + + def running(self) -> bool: + if self.container and self.container.status == 'running': + return True + return False + + + def terminate(self): + if self.container: + self.container.stop() + self.container.remove() + + + def __enter__(self): + self.start() + + + def __exit__(self): + self.terminate() diff --git a/pytest-clickhouse/pytest_clickhouse/executor_noop.py b/pytest-clickhouse/pytest_clickhouse/executor_noop.py new file mode 100644 index 00000000..224726b4 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/executor_noop.py @@ -0,0 +1,20 @@ +from typing import Optional, Union + +class ClickhouseNoopExecutor: + """ + Clickhouse nooperator executor + """ + + + def __init__( + self, + host: str, + port: Union[str, int], + dbname: str + ): + self.host = host + self.port = int(port) + self.clickhouse_url = f"clickhouse://{self.host}:{self.port}" + self.dbname = dbname + + diff --git a/pytest-clickhouse/pytest_clickhouse/factories/__init__.py b/pytest-clickhouse/pytest_clickhouse/factories/__init__.py new file mode 100644 index 00000000..da18216d --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/factories/__init__.py @@ -0,0 +1,5 @@ +from pytest_clickhouse.factories.client import clickhouse +from pytest_clickhouse.factories.process import clickhouse_proc +from pytest_clickhouse.factories.noprocess import clickhouse_noproc + +__all__ = {"clickhouse_proc", "clickhouse_noproc", "clickhouse"} \ No newline at end of file diff --git a/pytest-clickhouse/pytest_clickhouse/factories/client.py b/pytest-clickhouse/pytest_clickhouse/factories/client.py new file mode 100644 index 00000000..771e5dd6 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/factories/client.py @@ -0,0 +1,28 @@ +from typing import Callable, Union, Optional, List + +import pytest +from pytest import FixtureRequest +from clickhouse_driver import Client as Clickhouse + +from pytest_clickhouse.executor import ClickhouseExecutor +from pytest_clickhouse.executor_noop import ClickhouseNoopExecutor +from pytest_clickhouse.janitor import ClickhouseJanitor + +def clickhouse( + process_fixture_name: str, + dbname: Optional[str] = None, + url: Optional[str] = None, +) -> Callable[[FixtureRequest], Clickhouse]: + + @pytest.fixture + def clickhouse_factory(request: FixtureRequest) -> Clickhouse: + proc_fixture: Union[ClickhouseExecutor, ClickhouseNoopExecutor] = request.getfixturevalue( + process_fixture_name + ) + + clickhouse_url = url or proc_fixture.clickhouse_url + + client = Clickhouse.from_url(clickhouse_url) + yield client + + return clickhouse_factory \ No newline at end of file diff --git a/pytest-clickhouse/pytest_clickhouse/factories/noprocess.py b/pytest-clickhouse/pytest_clickhouse/factories/noprocess.py new file mode 100644 index 00000000..0148d875 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/factories/noprocess.py @@ -0,0 +1,34 @@ +from typing import Callable, Optional + +import pytest +from pytest import FixtureRequest + +from pytest_clickhouse.config import get_config +from pytest_clickhouse.executor_noop import ClickhouseNoopExecutor +from pytest_clickhouse.janitor import ClickhouseJanitor + +def clickhouse_noproc( + host: Optional[str] = None, + port: Optional[str] = None, + dbname: Optional[str] = None, +) -> Callable[[FixtureRequest], ClickhouseNoopExecutor]: + + @pytest.fixture(scope="session") + def clickhouse_noproc_fixture( + request: FixtureRequest + ) -> ClickhouseNoopExecutor: + config = get_config(request) + clickhouse_noop_exec = ClickhouseNoopExecutor( + host=host or config["host"], + port=port or config["port"], + dbname=dbname or config["dbname"], + ) + + with clickhouse_noop_exec: + with ClickhouseJanitor( + dbname=clickhouse_noop_exec.dbname, + clickhouse_url=clickhouse_noop_exec.clickhouse_url + ): + yield clickhouse_noop_exec + + return clickhouse_noproc_fixture \ No newline at end of file diff --git a/pytest-clickhouse/pytest_clickhouse/factories/process.py b/pytest-clickhouse/pytest_clickhouse/factories/process.py new file mode 100644 index 00000000..b30c0c80 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/factories/process.py @@ -0,0 +1,33 @@ +from typing import Callable, Iterator, Optional + +import pytest +from pytest import FixtureRequest + +from pytest_clickhouse.executor import ClickhouseExecutor +from pytest_clickhouse.config import get_config +from pytest_clickhouse.janitor import ClickhouseJanitor + +def clickhouse_proc( + image_name: Optional[str] = None, + dbname: Optional[str] = None, +) -> Callable[[FixtureRequest], ClickhouseExecutor]: + + @pytest.fixture(scope="session") + def clickhouse_proc_fixture( + request: FixtureRequest + ) -> ClickhouseExecutor: + config = get_config(request) + clickhouse_executor = ClickhouseExecutor( + image_name=image_name or config["image_name"], + dbname=dbname or config["dbname"] + ) + + with clickhouse_executor: + clickhouse_executor.wait_for_clickhouse() + with ClickhouseJanitor( + dbname=clickhouse_executor.dbname, + clickhouse_url=clickhouse_executor.clickhouse_url + ): + yield clickhouse_executor + + return clickhouse_proc_fixture \ No newline at end of file diff --git a/pytest-clickhouse/pytest_clickhouse/janitor.py b/pytest-clickhouse/pytest_clickhouse/janitor.py new file mode 100644 index 00000000..78180d97 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/janitor.py @@ -0,0 +1,44 @@ +from contextlib import contextmanager + +from clickhouse_driver import Client as Clickhouse + +class ClickhouseJanitor: + """ + Manage clickhouse database state + """ + + + def __init__(self, dbname: str, clickhouse_url: str): + self.dbname = dbname + self.clickhouse_url = clickhouse_url + self.click = None + + + def init(self): + """ + Initialize client and test database in clickhouse + """ + self.click: Clickhouse = Clickhouse.from_url(self.clickhouse_url) + query = f""" + CREATE DATABASE IF NOT EXISTS {self.dbname} + COMMENT 'test database' + """ + self.click.execute(query, {}) + + + def drop(self): + """ + Drop test database + """ + query = f""" + DROP DATABASE IF EXISTS {self.dbname} + """ + self.click.execute(query, {}) + + + def __enter__(self): + self.init() + + + def __exit__(self): + self.drop() diff --git a/pytest-clickhouse/pytest_clickhouse/plugin.py b/pytest-clickhouse/pytest_clickhouse/plugin.py new file mode 100644 index 00000000..d2448919 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/plugin.py @@ -0,0 +1,58 @@ +from _pytest.config.argparsing import Parser + +from pytest_clickhouse import factories + +_help_image = "Docker image to use to run Clickhouse server" +_help_host = "Host at which Clickhouse will accept connections" +_help_port = "Port at which Clickhouse will accept connections" +_help_dbname = "Default database name" +_help_load = "Dotted-style or entrypoint-style path to callable or path to SQL File" + + +def pytest_addoption(parser: Parser) -> None: + """ + Configure options for pytest-clickhouse + """ + + parser.addini( + name="clickhouse_image", help=_help_image, default="clickhouse/clickhouse-server:24.3-alpine" + ) + + parser.addini(name="clickhouse_host", help=_help_host, default="localhost") + + parser.addini(name="clickhouse_port", help=_help_port, default=None) + + parser.addini(name="clickhouse_dbname", help=_help_dbname, default="tests") + + parser.addoption( + "--clickhouse-image", + action="store", + dest="clickhouse_image", + help=_help_image + ) + + parser.addoption( + "--clickhouse-host", + action="store", + dest="clickhouse_host", + help=_help_host + ) + + parser.addoption( + "--clickhouse-port", + action="store", + dest="clickhouse_port", + help=_help_port + ) + + parser.addoption( + "--clickhouse-dbname", + action="store", + dest="clickhouse_dbname", + help=_help_dbname + ) + + +clickhouse_proc = factories.clickhouse_proc() +clickhouse_noproc = factories.clickhouse_noproc() +clickhouse = factories.clickhouse("clickhouse_proc") \ No newline at end of file diff --git a/pytest-clickhouse/tests/__init__.py b/pytest-clickhouse/tests/__init__.py new file mode 100644 index 00000000..5ddd63fc --- /dev/null +++ b/pytest-clickhouse/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2024-present DecFox +# +# SPDX-License-Identifier: MIT diff --git a/pytest-clickhouse/tests/test_executor.py b/pytest-clickhouse/tests/test_executor.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest-clickhouse/tests/test_janitor.py b/pytest-clickhouse/tests/test_janitor.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest-clickhouse/tests/test_noopexecutor.py b/pytest-clickhouse/tests/test_noopexecutor.py new file mode 100644 index 00000000..e69de29b