From f9e71067a96b8e151cf46a72a5f352688990d944 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 12:05:59 +0300 Subject: [PATCH 01/17] package: CI is added (test) --- .github/dependabot.yml | 7 + .github/workflows/ci.yml | 80 +++ tests/__init__.py | 0 tests/conftest.py | 1070 +++++++++++++++++++++++++++++++++++++ tests/conftest_helpers.py | 812 ++++++++++++++++++++++++++++ tests/requirements.txt | 2 + 6 files changed, 1971 insertions(+) create mode 100755 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/conftest_helpers.py create mode 100644 tests/requirements.txt diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100755 index 0000000..0a24199 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..acc8676 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: CI + +on: + push: + branches: [ "main" ] + paths-ignore: + - "**/*.md" + + pull_request: + branches: [ "main" ] + paths-ignore: + - "**/*.md" + +jobs: + test-pglab: + runs-on: ubuntu-24.04 + name: "test: pglab | ubuntu-24.04 | pg16" + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python 3.12 + uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install PostgreSQL and PgLab system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + postgresql-16 \ + postgresql-client-16 \ + libpq-dev \ + iproute2 \ + iputils-ping \ + iptables \ + util-linux + + - name: Install Python test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r tests/requirements.txt + + - name: PgLab preflight + run: | + set -eux + "$(pg_config --bindir)/postgres" --version + pg_config --bindir + findmnt -n -o SOURCE,FSTYPE -T /var/tmp + mount | grep cgroup || true + cat /sys/fs/cgroup/cgroup.controllers + cat /sys/fs/cgroup/cgroup.subtree_control || true + + - name: Run privileged PgLab tests + run: | + set -eux + mkdir -p "${{ github.workspace }}/logs-pglab" + PYTHON="$(command -v python)" + PG_BIN="$(pg_config --bindir)" + sudo env \ + PATH="$PATH" \ + TESTGRES_PGLAB_TESTS=1 \ + PG_BIN="$PG_BIN" \ + PGLAB_OS_USER="$USER" \ + PGLAB_DB_USER=postgres \ + PGLAB_TEST_ROOT=/var/tmp \ + TEST_CFG__LOG_DIR="${{ github.workspace }}/logs-pglab" \ + "$PYTHON" -m pytest -vv -s tests + + - name: Upload PgLab logs + uses: actions/upload-artifact@v7 + if: always() + with: + name: testgres--pglab-logs--id${{ github.run_id }} + path: logs-pglab/ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b7d119e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,1070 @@ +# ///////////////////////////////////////////////////////////////////////////// +# PyTest Configuration + +from .conftest_helpers import TestStartupData +from .conftest_helpers import TestServices +from .conftest_helpers import TestExitStatus + +import pluggy +import pytest +import os +import logging +import pathlib +import math +import datetime +import typing +import enum +import threading + +import _pytest.outcomes +import _pytest.logging + +from packaging.version import Version + +# ///////////////////////////////////////////////////////////////////////////// + +T_TUPLE__str_int = typing.Tuple[str, int] + +# ///////////////////////////////////////////////////////////////////////////// +# T_PLUGGY_RESULT + +if Version(pluggy.__version__) <= Version("1.2"): + T_PLUGGY_RESULT = pluggy._result._Result +else: + T_PLUGGY_RESULT = pluggy.Result + +# ///////////////////////////////////////////////////////////////////////////// + +g_error_msg_count_key = pytest.StashKey[int]() +g_warning_msg_count_key = pytest.StashKey[int]() +g_critical_msg_count_key = pytest.StashKey[int]() + +# ///////////////////////////////////////////////////////////////////////////// +# T_TEST_PROCESS_KIND + + +class T_TEST_PROCESS_KIND(enum.Enum): + Master = 1 + Worker = 2 + + +# ///////////////////////////////////////////////////////////////////////////// +# T_TEST_PROCESS_MODE + + +class T_TEST_PROCESS_MODE(enum.Enum): + Collect = 1 + ExecTests = 2 + + +# ///////////////////////////////////////////////////////////////////////////// +# TPDATA + + +class TPDATA: + test_process_kind: typing.Optional[T_TEST_PROCESS_KIND] = None + test_process_mode: typing.Optional[T_TEST_PROCESS_MODE] = None + worker_log_is_created: typing.Optional[bool] = None + + cTotalTests: int = 0 + cNotExecutedTests: int = 0 + cExecutedTests: int = 0 + cPassedTests: int = 0 + cFailedTests: int = 0 + cXFailedTests: int = 0 + cSkippedTests: int = 0 + cNotXFailedTests: int = 0 + cWarningTests: int = 0 + cUnexpectedTests: int = 0 + cAchtungTests: int = 0 + + FailedTests: typing.List[T_TUPLE__str_int] = list() + XFailedTests: typing.List[T_TUPLE__str_int] = list() + NotXFailedTests: typing.List[str] = list() + WarningTests: typing.List[T_TUPLE__str_int] = list() + AchtungTests: typing.List[str] = list() + + cTotalDuration: datetime.timedelta = datetime.timedelta() + + cTotalErrors: int = 0 + cTotalWarnings: int = 0 + + # -------------------------------------------------------------------- + @staticmethod + def incrementTotalTestCount() -> None: + assert type(__class__.cTotalTests) is int + assert __class__.cTotalTests >= 0 + + __class__.cTotalTests += 1 + + assert __class__.cTotalTests > 0 + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementNotExecutedTestCount() -> None: + assert type(__class__.cNotExecutedTests) is int + assert __class__.cNotExecutedTests >= 0 + + __class__.cNotExecutedTests += 1 + + assert __class__.cNotExecutedTests > 0 + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementExecutedTestCount() -> int: + assert type(__class__.cExecutedTests) is int + assert __class__.cExecutedTests >= 0 + + __class__.cExecutedTests += 1 + + assert __class__.cExecutedTests > 0 + return __class__.cExecutedTests + + # -------------------------------------------------------------------- + @staticmethod + def incrementPassedTestCount() -> None: + assert type(__class__.cPassedTests) is int + assert __class__.cPassedTests >= 0 + + __class__.cPassedTests += 1 + + assert __class__.cPassedTests > 0 + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementFailedTestCount(testID: str, errCount: int) -> None: + assert type(testID) is str + assert type(errCount) is int + assert errCount > 0 + assert type(__class__.FailedTests) is list + assert type(__class__.cFailedTests) is int + assert __class__.cFailedTests >= 0 + + __class__.FailedTests.append((testID, errCount)) # raise? + __class__.cFailedTests += 1 + + assert len(__class__.FailedTests) > 0 + assert __class__.cFailedTests > 0 + assert len(__class__.FailedTests) == __class__.cFailedTests + + # -------- + assert type(__class__.cTotalErrors) is int + assert __class__.cTotalErrors >= 0 + + __class__.cTotalErrors += errCount + + assert __class__.cTotalErrors > 0 + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementXFailedTestCount(testID: str, errCount: int) -> None: + assert type(testID) is str + assert type(errCount) is int + assert errCount >= 0 + assert type(__class__.XFailedTests) is list + assert type(__class__.cXFailedTests) is int + assert __class__.cXFailedTests >= 0 + + __class__.XFailedTests.append((testID, errCount)) # raise? + __class__.cXFailedTests += 1 + + assert len(__class__.XFailedTests) > 0 + assert __class__.cXFailedTests > 0 + assert len(__class__.XFailedTests) == __class__.cXFailedTests + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementSkippedTestCount() -> None: + assert type(__class__.cSkippedTests) is int + assert __class__.cSkippedTests >= 0 + + __class__.cSkippedTests += 1 + + assert __class__.cSkippedTests > 0 + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementNotXFailedTests(testID: str) -> None: + assert type(testID) is str + assert type(__class__.NotXFailedTests) is list + assert type(__class__.cNotXFailedTests) is int + assert __class__.cNotXFailedTests >= 0 + + __class__.NotXFailedTests.append(testID) # raise? + __class__.cNotXFailedTests += 1 + + assert len(__class__.NotXFailedTests) > 0 + assert __class__.cNotXFailedTests > 0 + assert len(__class__.NotXFailedTests) == __class__.cNotXFailedTests + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementWarningTestCount(testID: str, warningCount: int) -> None: + assert type(testID) is str + assert type(warningCount) is int + assert testID != "" + assert warningCount > 0 + assert type(__class__.WarningTests) is list + assert type(__class__.cWarningTests) is int + assert __class__.cWarningTests >= 0 + + __class__.WarningTests.append((testID, warningCount)) # raise? + __class__.cWarningTests += 1 + + assert len(__class__.WarningTests) > 0 + assert __class__.cWarningTests > 0 + assert len(__class__.WarningTests) == __class__.cWarningTests + + # -------- + assert type(__class__.cTotalWarnings) is int + assert __class__.cTotalWarnings >= 0 + + __class__.cTotalWarnings += warningCount + + assert __class__.cTotalWarnings > 0 + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementUnexpectedTests() -> None: + assert type(__class__.cUnexpectedTests) is int + assert __class__.cUnexpectedTests >= 0 + + __class__.cUnexpectedTests += 1 + + assert __class__.cUnexpectedTests > 0 + return + + # -------------------------------------------------------------------- + @staticmethod + def incrementAchtungTestCount(testID: str) -> None: + assert type(testID) is str + assert type(__class__.AchtungTests) is list + assert type(__class__.cAchtungTests) is int + assert __class__.cAchtungTests >= 0 + + __class__.AchtungTests.append(testID) # raise? + __class__.cAchtungTests += 1 + + assert len(__class__.AchtungTests) > 0 + assert __class__.cAchtungTests > 0 + assert len(__class__.AchtungTests) == __class__.cAchtungTests + return + + +# ///////////////////////////////////////////////////////////////////////////// + + +def timedelta_to_human_text(delta: datetime.timedelta) -> str: + assert isinstance(delta, datetime.timedelta) + + C_SECONDS_IN_MINUTE = 60 + C_SECONDS_IN_HOUR = 60 * C_SECONDS_IN_MINUTE + + v = delta.seconds + + cHours = int(v / C_SECONDS_IN_HOUR) + v = v - cHours * C_SECONDS_IN_HOUR + cMinutes = int(v / C_SECONDS_IN_MINUTE) + cSeconds = v - cMinutes * C_SECONDS_IN_MINUTE + + result = "" if delta.days == 0 else "{0} day(s) ".format(delta.days) + + result = result + "{:02d}:{:02d}:{:02d}.{:06d}".format( + cHours, cMinutes, cSeconds, delta.microseconds + ) + + return result + + +# ///////////////////////////////////////////////////////////////////////////// + + +def helper__build_test_id(item: pytest.Function) -> str: + assert item is not None + assert isinstance(item, pytest.Function) + + testID = "" + + if item.cls is not None: + testID = item.cls.__module__ + "." + item.cls.__name__ + "::" + + testID = testID + item.name + + return testID + + +# ///////////////////////////////////////////////////////////////////////////// + + +def helper__exc_to_text(exc: BaseException) -> str: + assert isinstance(exc, BaseException) + return TestServices.ExceptionToHumanText(exc) + + +# ///////////////////////////////////////////////////////////////////////////// + + +def helper__makereport__setup( + item: pytest.Function, call: pytest.CallInfo, outcome: T_PLUGGY_RESULT +): + assert item is not None + assert call is not None + assert outcome is not None + # it may be pytest.Function or _pytest.unittest.TestCaseFunction + assert isinstance(item, pytest.Function) + assert type(call) is pytest.CallInfo + assert type(outcome) is T_PLUGGY_RESULT + + C_LINE1 = "******************************************************" + + # logging.info("pytest_runtest_makereport - setup") + + TPDATA.incrementTotalTestCount() + + rep: pytest.TestReport = outcome.get_result() + assert rep is not None + assert type(rep) is pytest.TestReport + + if rep.outcome == "skipped": + TPDATA.incrementNotExecutedTestCount() + return + + testID = helper__build_test_id(item) + + if rep.outcome == "passed": + testNumber = TPDATA.incrementExecutedTestCount() + + logging.info(C_LINE1) + logging.info("* START TEST {0}".format(testID)) + logging.info("*") + logging.info("* Path : {0}".format(item.path)) + logging.info("* Number: {0}".format(testNumber)) + logging.info("*") + return + + assert rep.outcome != "passed" + + TPDATA.incrementAchtungTestCount(testID) + + logging.info(C_LINE1) + logging.info("* ACHTUNG TEST {0}".format(testID)) + logging.info("*") + logging.info("* Path : {0}".format(item.path)) + logging.info("* Outcome is [{0}]".format(rep.outcome)) + + if rep.outcome == "failed": + assert call.excinfo is not None + assert call.excinfo.value is not None + logging.info("*") + logging.error(helper__exc_to_text(call.excinfo.value)) + + logging.info("*") + return + + +# ------------------------------------------------------------------------ +def helper__makereport__call( + item: pytest.Function, call: pytest.CallInfo, outcome: T_PLUGGY_RESULT +): + assert item is not None + assert call is not None + assert outcome is not None + # it may be pytest.Function or _pytest.unittest.TestCaseFunction + assert isinstance(item, pytest.Function) + assert type(call) is pytest.CallInfo + assert type(outcome) is T_PLUGGY_RESULT + + # -------- + item_error_msg_count1 = item.stash.get(g_error_msg_count_key, 0) + assert type(item_error_msg_count1) is int + assert item_error_msg_count1 >= 0 + + item_error_msg_count2 = item.stash.get(g_critical_msg_count_key, 0) + assert type(item_error_msg_count2) is int + assert item_error_msg_count2 >= 0 + + item_error_msg_count = item_error_msg_count1 + item_error_msg_count2 + + # -------- + item_warning_msg_count = item.stash.get(g_warning_msg_count_key, 0) + assert type(item_warning_msg_count) is int + assert item_warning_msg_count >= 0 + + # -------- + rep = outcome.get_result() + assert rep is not None + assert type(rep) is pytest.TestReport + + # -------- + testID = helper__build_test_id(item) + + # -------- + assert call.start <= call.stop + + startDT = datetime.datetime.fromtimestamp(call.start) + assert type(startDT) is datetime.datetime + stopDT = datetime.datetime.fromtimestamp(call.stop) + assert type(stopDT) is datetime.datetime + + testDurration = stopDT - startDT + assert type(testDurration) is datetime.timedelta + + # -------- + exitStatus: typing.Optional[TestExitStatus] = None + exitStatusInfo = None + if rep.outcome == "skipped": + assert call.excinfo is not None # research + assert call.excinfo.value is not None # research + + if type(call.excinfo.value) is _pytest.outcomes.Skipped: + assert not hasattr(rep, "wasxfail") + + exitStatus = TestExitStatus.SKIPPED + reasonText = str(call.excinfo.value) + reasonMsgTempl = "SKIP REASON: {0}" + + TPDATA.incrementSkippedTestCount() + + elif type(call.excinfo.value) is _pytest.outcomes.XFailed: + exitStatus = TestExitStatus.XFAILED + reasonText = str(call.excinfo.value) + reasonMsgTempl = "XFAIL REASON: {0}" + + TPDATA.incrementXFailedTestCount(testID, item_error_msg_count) + + else: + exitStatus = TestExitStatus.XFAILED + assert hasattr(rep, "wasxfail") + assert rep.wasxfail is not None + assert type(rep.wasxfail) is str + + reasonText = rep.wasxfail + reasonMsgTempl = "XFAIL REASON: {0}" + + if type(call.excinfo.value) is SIGNAL_EXCEPTION: + pass + else: + logging.error(helper__exc_to_text(call.excinfo.value)) + item_error_msg_count += 1 + + TPDATA.incrementXFailedTestCount(testID, item_error_msg_count) + + assert type(reasonText) is str + + if reasonText != "": + assert type(reasonMsgTempl) is str + logging.info("*") + logging.info("* " + reasonMsgTempl.format(reasonText)) + + elif rep.outcome == "failed": + assert call.excinfo is not None + assert call.excinfo.value is not None + + if type(call.excinfo.value) is SIGNAL_EXCEPTION: + assert item_error_msg_count > 0 + pass + else: + logging.error(helper__exc_to_text(call.excinfo.value)) + item_error_msg_count += 1 + + assert item_error_msg_count > 0 + TPDATA.incrementFailedTestCount(testID, item_error_msg_count) + + exitStatus = TestExitStatus.FAILED + elif rep.outcome == "passed": + assert call.excinfo is None + + if hasattr(rep, "wasxfail"): + assert type(rep.wasxfail) is str + + TPDATA.incrementNotXFailedTests(testID) + + warnMsg = "NOTE: Test is marked as xfail" + + if rep.wasxfail != "": + warnMsg += " [" + rep.wasxfail + "]" + + logging.info(warnMsg) + exitStatus = TestExitStatus.NOT_XFAILED + else: + assert not hasattr(rep, "wasxfail") + + TPDATA.incrementPassedTestCount() + exitStatus = TestExitStatus.PASSED + else: + TPDATA.incrementUnexpectedTests() + exitStatus = TestExitStatus.UNEXPECTED + exitStatusInfo = rep.outcome + # [2025-03-28] It may create a useless problem in new environment. + # assert False + + # -------- + if item_warning_msg_count > 0: + TPDATA.incrementWarningTestCount(testID, item_warning_msg_count) + + # -------- + assert exitStatus is not None + assert type(exitStatus) is TestExitStatus + + assert exitStatus != TestExitStatus.FAILED or item_error_msg_count > 0 + + TestServices.CleanTestTmpDirBeforeExit( + item, + exitStatus, + ) + + # -------- + assert type(TPDATA.cTotalDuration) is datetime.timedelta + assert type(testDurration) is datetime.timedelta + + TPDATA.cTotalDuration += testDurration + + assert testDurration <= TPDATA.cTotalDuration + + # -------- + exitStatusLineData = exitStatus.value + + if exitStatusInfo is not None: + exitStatusLineData += " [{}]".format(exitStatusInfo) + + # -------- + logging.info("*") + logging.info("* DURATION : {0}".format(timedelta_to_human_text(testDurration))) + logging.info("*") + logging.info("* EXIT STATUS : {0}".format(exitStatusLineData)) + logging.info("* ERROR COUNT : {0}".format(item_error_msg_count)) + logging.info("* WARNING COUNT: {0}".format(item_warning_msg_count)) + logging.info("*") + logging.info("* STOP TEST {0}".format(testID)) + logging.info("*") + return + + +# ///////////////////////////////////////////////////////////////////////////// + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item: pytest.Function, call: pytest.CallInfo): + # + # https://docs.pytest.org/en/7.1.x/how-to/writing_hook_functions.html#hookwrapper-executing-around-other-hooks + # + # Note that hook wrappers don’t return results themselves, + # they merely perform tracing or other side effects around the actual hook implementations. + # + # https://docs.pytest.org/en/7.1.x/reference/reference.html#test-running-runtest-hooks + # + assert item is not None + assert call is not None + # it may be pytest.Function or _pytest.unittest.TestCaseFunction + assert isinstance(item, pytest.Function) + assert type(call) is pytest.CallInfo + + outcome = yield + assert outcome is not None + assert type(outcome) is T_PLUGGY_RESULT + + assert type(call.when) is str + + if call.when == "collect": + return + + if call.when == "setup": + helper__makereport__setup(item, call, outcome) + return + + if call.when == "call": + helper__makereport__call(item, call, outcome) + return + + if call.when == "teardown": + return + + errMsg = "[pytest_runtest_makereport] unknown 'call.when' value: [{0}].".format( + call.when + ) + + raise RuntimeError(errMsg) + + +# ///////////////////////////////////////////////////////////////////////////// + + +class LogWrapper2: + _guard: threading.Lock + _old_method: typing.Any + _err_counter: typing.Optional[int] + _warn_counter: typing.Optional[int] + + _critical_counter: typing.Optional[int] + + # -------------------------------------------------------------------- + def __init__(self): + self._guard = threading.Lock() + self._old_method = None + self._err_counter = None + self._warn_counter = None + + self._critical_counter = None + return + + # -------------------------------------------------------------------- + def __enter__(self): + assert self._guard is not None + # assert isinstance(self._guard, threading.Lock) + assert self._old_method is None + assert self._err_counter is None + assert self._warn_counter is None + + assert self._critical_counter is None + + assert logging.root is not None + assert isinstance(logging.root, logging.RootLogger) + + self._old_method = logging.root.handle + self._err_counter = 0 + self._warn_counter = 0 + + self._critical_counter = 0 + + logging.root.handle = self + return self + + # -------------------------------------------------------------------- + def __exit__(self, exc_type, exc_val, exc_tb): + assert self._guard is not None + # assert isinstance(self._guard, threading.Lock) + assert self._old_method is not None + assert self._err_counter is not None + assert self._warn_counter is not None + + assert logging.root is not None + assert isinstance(logging.root, logging.RootLogger) + + assert logging.root.handle is self + + logging.root.handle = self._old_method + + self._old_method = None + self._err_counter = None + self._warn_counter = None + self._critical_counter = None + return False + + # -------------------------------------------------------------------- + def __call__(self, record: logging.LogRecord): + assert record is not None + assert isinstance(record, logging.LogRecord) + assert self._guard is not None + # assert isinstance(self._guard, threading.Lock) + assert self._old_method is not None + assert self._err_counter is not None + assert self._warn_counter is not None + assert self._critical_counter is not None + + assert type(self._err_counter) is int + assert self._err_counter >= 0 + assert type(self._warn_counter) is int + assert self._warn_counter >= 0 + assert type(self._critical_counter) is int + assert self._critical_counter >= 0 + + r = self._old_method(record) + + with self._guard: + if record.levelno == logging.ERROR: + self._err_counter += 1 + assert self._err_counter > 0 + elif record.levelno == logging.WARNING: + self._warn_counter += 1 + assert self._warn_counter > 0 + elif record.levelno == logging.CRITICAL: + self._critical_counter += 1 + assert self._critical_counter > 0 + + return r + + +# ///////////////////////////////////////////////////////////////////////////// + + +class SIGNAL_EXCEPTION(Exception): + def __init__(self): + pass + + +# ///////////////////////////////////////////////////////////////////////////// + + +@pytest.hookimpl(hookwrapper=True) +def pytest_pyfunc_call(pyfuncitem: pytest.Function): + assert pyfuncitem is not None + assert isinstance(pyfuncitem, pytest.Function) + + assert logging.root is not None + assert isinstance(logging.root, logging.RootLogger) + assert logging.root.handle is not None + + debug__log_handle_method = logging.root.handle + assert debug__log_handle_method is not None + + debug__log_error_method = logging.error + assert debug__log_error_method is not None + + debug__log_warning_method = logging.warning + assert debug__log_warning_method is not None + + pyfuncitem.stash[g_error_msg_count_key] = 0 + pyfuncitem.stash[g_warning_msg_count_key] = 0 + pyfuncitem.stash[g_critical_msg_count_key] = 0 + + try: + with LogWrapper2() as logWrapper: + assert type(logWrapper) is LogWrapper2 + assert logWrapper._old_method is not None + assert type(logWrapper._err_counter) is int + assert logWrapper._err_counter == 0 + assert type(logWrapper._warn_counter) is int + assert logWrapper._warn_counter == 0 + assert type(logWrapper._critical_counter) is int + assert logWrapper._critical_counter == 0 + assert logging.root.handle is logWrapper + + r = yield + + assert r is not None + assert type(r) is T_PLUGGY_RESULT + + assert logWrapper._old_method is not None + assert type(logWrapper._err_counter) is int + assert logWrapper._err_counter >= 0 + assert type(logWrapper._warn_counter) is int + assert logWrapper._warn_counter >= 0 + assert type(logWrapper._critical_counter) is int + assert logWrapper._critical_counter >= 0 + assert logging.root.handle is logWrapper + + assert g_error_msg_count_key in pyfuncitem.stash + assert g_warning_msg_count_key in pyfuncitem.stash + assert g_critical_msg_count_key in pyfuncitem.stash + + assert pyfuncitem.stash[g_error_msg_count_key] == 0 + assert pyfuncitem.stash[g_warning_msg_count_key] == 0 + assert pyfuncitem.stash[g_critical_msg_count_key] == 0 + + pyfuncitem.stash[g_error_msg_count_key] = logWrapper._err_counter + pyfuncitem.stash[g_warning_msg_count_key] = logWrapper._warn_counter + pyfuncitem.stash[g_critical_msg_count_key] = logWrapper._critical_counter + + if r.exception is not None: + pass + elif logWrapper._err_counter > 0: + r.force_exception(SIGNAL_EXCEPTION()) + elif logWrapper._critical_counter > 0: + r.force_exception(SIGNAL_EXCEPTION()) + finally: + assert logging.error is debug__log_error_method + assert logging.warning is debug__log_warning_method + assert logging.root.handle == debug__log_handle_method + pass + + +# ///////////////////////////////////////////////////////////////////////////// + + +def helper__calc_W(n: int) -> int: + assert n > 0 + + x = int(math.log10(n)) + assert type(x) is int + assert x >= 0 + x += 1 + return x + + +# ------------------------------------------------------------------------ +def helper__print_test_list(tests: typing.List[str]) -> None: + assert type(tests) is list + + assert helper__calc_W(9) == 1 + assert helper__calc_W(10) == 2 + assert helper__calc_W(11) == 2 + assert helper__calc_W(99) == 2 + assert helper__calc_W(100) == 3 + assert helper__calc_W(101) == 3 + assert helper__calc_W(999) == 3 + assert helper__calc_W(1000) == 4 + assert helper__calc_W(1001) == 4 + + W = helper__calc_W(len(tests)) + + templateLine = "{0:0" + str(W) + "d}. {1}" + + nTest = 0 + + for t in tests: + assert type(t) is str + assert t != "" + nTest += 1 + logging.info(templateLine.format(nTest, t)) + + +# ------------------------------------------------------------------------ +def helper__print_test_list2(tests: typing.List[T_TUPLE__str_int]) -> None: + assert type(tests) is list + + assert helper__calc_W(9) == 1 + assert helper__calc_W(10) == 2 + assert helper__calc_W(11) == 2 + assert helper__calc_W(99) == 2 + assert helper__calc_W(100) == 3 + assert helper__calc_W(101) == 3 + assert helper__calc_W(999) == 3 + assert helper__calc_W(1000) == 4 + assert helper__calc_W(1001) == 4 + + W = helper__calc_W(len(tests)) + + templateLine = "{0:0" + str(W) + "d}. {1} ({2})" + + nTest = 0 + + for t in tests: + assert type(t) is tuple + assert len(t) == 2 + assert type(t[0]) is str + assert type(t[1]) is int + assert t[0] != "" + assert t[1] >= 0 + nTest += 1 + logging.info(templateLine.format(nTest, t[0], t[1])) + + +# ///////////////////////////////////////////////////////////////////////////// +# SUMMARY BUILDER + + +@pytest.hookimpl(trylast=True) +def pytest_sessionfinish(): + # + # NOTE: It should execute after logging.pytest_sessionfinish + # + + assert TPDATA.test_process_kind is not None + assert type(TPDATA.test_process_kind) is T_TEST_PROCESS_KIND + + if TPDATA.test_process_kind == T_TEST_PROCESS_KIND.Master: + return + + assert TPDATA.test_process_kind == T_TEST_PROCESS_KIND.Worker + + assert TPDATA.test_process_mode is not None + assert type(TPDATA.test_process_mode) is T_TEST_PROCESS_MODE + + if TPDATA.test_process_mode == T_TEST_PROCESS_MODE.Collect: + return + + assert TPDATA.test_process_mode == T_TEST_PROCESS_MODE.ExecTests + + assert type(TPDATA.worker_log_is_created) is bool + assert TPDATA.worker_log_is_created + + C_LINE1 = "---------------------------" + + def LOCAL__print_line1_with_header(header: str): + assert type(C_LINE1) is str + assert type(header) is str + assert header != "" + logging.info(C_LINE1 + " [" + header + "]") + + def LOCAL__print_test_list( + header: str, test_count: int, test_list: typing.List[str] + ): + assert type(header) is str + assert type(test_count) is int + assert type(test_list) is list + assert header != "" + assert test_count >= 0 + assert len(test_list) == test_count + + LOCAL__print_line1_with_header(header) + logging.info("") + if len(test_list) > 0: + helper__print_test_list(test_list) + logging.info("") + + def LOCAL__print_test_list2( + header: str, test_count: int, test_list: typing.List[T_TUPLE__str_int] + ): + assert type(header) is str + assert type(test_count) is int + assert type(test_list) is list + assert header != "" + assert test_count >= 0 + assert len(test_list) == test_count + + LOCAL__print_line1_with_header(header) + logging.info("") + if len(test_list) > 0: + helper__print_test_list2(test_list) + logging.info("") + + # fmt: off + LOCAL__print_test_list( + "ACHTUNG TESTS", + TPDATA.cAchtungTests, + TPDATA.AchtungTests, + ) + + LOCAL__print_test_list2( + "FAILED TESTS", + TPDATA.cFailedTests, + TPDATA.FailedTests + ) + + LOCAL__print_test_list2( + "XFAILED TESTS", + TPDATA.cXFailedTests, + TPDATA.XFailedTests, + ) + + LOCAL__print_test_list( + "NOT XFAILED TESTS", + TPDATA.cNotXFailedTests, + TPDATA.NotXFailedTests, + ) + + LOCAL__print_test_list2( + "WARNING TESTS", + TPDATA.cWarningTests, + TPDATA.WarningTests, + ) + # fmt: on + + LOCAL__print_line1_with_header("SUMMARY STATISTICS") + logging.info("") + logging.info("[TESTS]") + logging.info(" TOTAL : {0}".format(TPDATA.cTotalTests)) + logging.info(" EXECUTED : {0}".format(TPDATA.cExecutedTests)) + logging.info(" NOT EXECUTED : {0}".format(TPDATA.cNotExecutedTests)) + logging.info(" ACHTUNG : {0}".format(TPDATA.cAchtungTests)) + logging.info("") + logging.info(" PASSED : {0}".format(TPDATA.cPassedTests)) + logging.info(" FAILED : {0}".format(TPDATA.cFailedTests)) + logging.info(" XFAILED : {0}".format(TPDATA.cXFailedTests)) + logging.info(" NOT XFAILED : {0}".format(TPDATA.cNotXFailedTests)) + logging.info(" SKIPPED : {0}".format(TPDATA.cSkippedTests)) + logging.info(" WITH WARNINGS: {0}".format(TPDATA.cWarningTests)) + logging.info(" UNEXPECTED : {0}".format(TPDATA.cUnexpectedTests)) + logging.info("") + + assert type(TPDATA.cTotalDuration) is datetime.timedelta + + LOCAL__print_line1_with_header("TIME") + logging.info("") + logging.info( + " TOTAL DURATION: {0}".format( + timedelta_to_human_text(TPDATA.cTotalDuration) + ) + ) + logging.info("") + + LOCAL__print_line1_with_header("TOTAL INFORMATION") + logging.info("") + logging.info(" TOTAL ERROR COUNT : {0}".format(TPDATA.cTotalErrors)) + logging.info(" TOTAL WARNING COUNT: {0}".format(TPDATA.cTotalWarnings)) + logging.info("") + + +# ///////////////////////////////////////////////////////////////////////////// + + +def helper__detect_test_process_kind(config: pytest.Config) -> T_TEST_PROCESS_KIND: + assert isinstance(config, pytest.Config) + + # + # xdist' master process registers DSession plugin. + # + p = config.pluginmanager.get_plugin("dsession") + + if p is not None: + return T_TEST_PROCESS_KIND.Master + + return T_TEST_PROCESS_KIND.Worker + + +# ------------------------------------------------------------------------ +def helper__detect_test_process_mode(config: pytest.Config) -> T_TEST_PROCESS_MODE: + assert isinstance(config, pytest.Config) + + if config.getvalue("collectonly"): + return T_TEST_PROCESS_MODE.Collect + + return T_TEST_PROCESS_MODE.ExecTests + + +# ------------------------------------------------------------------------ +@pytest.hookimpl(trylast=True) +def helper__pytest_configure__logging(config: pytest.Config) -> None: + assert isinstance(config, pytest.Config) + + log_name = TestStartupData.GetCurrentTestWorkerSignature() + log_name += ".log" + + log_dir = TestStartupData.GetRootLogDir() + + pathlib.Path(log_dir).mkdir(exist_ok=True) + + logging_plugin = config.pluginmanager.get_plugin( + "logging-plugin", + ) + + assert logging_plugin is not None + assert isinstance(logging_plugin, _pytest.logging.LoggingPlugin) + + log_file_path = os.path.join(log_dir, log_name) + assert log_file_path is not None + assert type(log_file_path) is str + + logging_plugin.set_log_path(log_file_path) + return + + +# ------------------------------------------------------------------------ +@pytest.hookimpl(trylast=True) +def pytest_configure(config: pytest.Config) -> None: + assert isinstance(config, pytest.Config) + + assert TPDATA.test_process_kind is None + assert TPDATA.test_process_mode is None + assert TPDATA.worker_log_is_created is None + + TPDATA.test_process_mode = helper__detect_test_process_mode(config) + TPDATA.test_process_kind = helper__detect_test_process_kind(config) + + assert type(TPDATA.test_process_kind) is T_TEST_PROCESS_KIND + assert type(TPDATA.test_process_mode) is T_TEST_PROCESS_MODE + + if TPDATA.test_process_kind == T_TEST_PROCESS_KIND.Master: + pass + else: + assert TPDATA.test_process_kind == T_TEST_PROCESS_KIND.Worker + + if TPDATA.test_process_mode == T_TEST_PROCESS_MODE.Collect: + TPDATA.worker_log_is_created = False + else: + assert TPDATA.test_process_mode == T_TEST_PROCESS_MODE.ExecTests + helper__pytest_configure__logging(config) + TPDATA.worker_log_is_created = True + + return + + +# ///////////////////////////////////////////////////////////////////////////// diff --git a/tests/conftest_helpers.py b/tests/conftest_helpers.py new file mode 100644 index 0000000..ec6ef68 --- /dev/null +++ b/tests/conftest_helpers.py @@ -0,0 +1,812 @@ +# ///////////////////////////////////////////////////////////////////////////// +# PyTest Configuration Helpers + +from __future__ import annotations + +import os +import pytest +import logging +import time +import traceback +import typing +import shutil +import enum +import fnmatch +import datetime + + +# ///////////////////////////////////////////////////////////////////////////// + +C_ROOT_DIR__RELATIVE = ".." + + +# ///////////////////////////////////////////////////////////////////////////// +# TestExitStatus + +class TestExitStatus(enum.Enum): + FAILED = "FAILED" + PASSED = "PASSED" + XFAILED = "XFAILED" + NOT_XFAILED = "NOT XFAILED" + SKIPPED = "SKIPPED" + UNEXPECTED = "UNEXPECTED" + + +# ///////////////////////////////////////////////////////////////////////////// +# TestConfigPropNames + + +class TestConfigPropNames: + TEST_CFG__NO_CLEANUP = "TEST_CFG__NO_CLEANUP" + + TEST_CFG__TEMP_DIR = "TEST_CFG__TEMP_DIR" + + TEST_CFG__LOG_DIR = "TEST_CFG__LOG_DIR" + + TEST_CFG__ENABLE_XFAIL = "TEST_CFG__ENABLE_XFAIL" + + +# ///////////////////////////////////////////////////////////////////////////// +# ThrowError + + +class ThrowError: + @staticmethod + def EnvVarIsNotDefined(envVarName: str) -> typing.NoReturn: + assert type(envVarName) is str + raise RuntimeError("System env variable [{}] is not defined.".format( + envVarName, + )) + + # -------------------------------------------------------------------- + @staticmethod + def EnvVarHasBadValue(envVarName: str) -> typing.NoReturn: + assert type(envVarName) is str + raise RuntimeError("System env variable [{}] has bad value.".format( + envVarName, + )) + + # -------------------------------------------------------------------- + @staticmethod + def EnvVarHasBadValue2(envVarName: str, envVarValue) -> typing.NoReturn: + assert type(envVarName) is str + raise RuntimeError("System env variable [{}] has bad value [{}].".format( + envVarName, + envVarValue, + )) + +# ///////////////////////////////////////////////////////////////////////////// +# TestConfigHelper + + +class TestConfigHelper: + @staticmethod + def NoCleanup( + exit_status: typing.Optional[TestExitStatus], + ) -> bool: + assert exit_status is None or type(exit_status) is TestExitStatus + + v = os.environ.get(TestConfigPropNames.TEST_CFG__NO_CLEANUP) + + if v is None: + return False + + vv = str(v).upper() + + if vv in __class__.sm_NO: + return False + + if vv in __class__.sm_YES: + return True + + if exit_status is None: + return False + + v2 = vv.split(",") + + if exit_status.name in v2: + return True + + return False + + # -------------------------------------------------------------------- + @staticmethod + def EnableXFail() -> bool: + if TestConfigPropNames.TEST_CFG__ENABLE_XFAIL not in os.environ.keys(): + return False + + v = os.environ[TestConfigPropNames.TEST_CFG__ENABLE_XFAIL] + + return __class__.Helper__ToBoolean(v, TestConfigPropNames.TEST_CFG__ENABLE_XFAIL) + + # -------------------------------------------------------------------- + @staticmethod + def GetEnvValue__STR(envName: str) -> typing.Optional[str]: + assert type(envName) is str + return os.getenv(envName) + + # -------------------------------------------------------------------- + @staticmethod + def GetReqEnvValue__STR(envName: str) -> str: + assert type(envName) is str + + v = os.getenv(envName) + + if v is None: + ThrowError.EnvVarIsNotDefined(envName) + assert False + + return v + + # Helper methods ----------------------------------------------------- + sm_YES: list[str] = ["1", "TRUE", "YES", "ON"] + + sm_NO: list[str] = ["0", "FALSE", "NO", "OFF"] + + # -------------------------------------------------------------------- + @staticmethod + def Helper__ToBoolean(v, envVarName: str) -> bool: + assert type(envVarName) is str + + typeV = type(v) + + if typeV is bool: + return v + + if typeV is str: + vv = str(v).upper() + assert type(vv) is str + + if vv in __class__.sm_YES: + return True + + if vv in __class__.sm_NO: + return False + + ThrowError.EnvVarHasBadValue2(envVarName, vv) + return False + + if typeV is int: + if v == 0: + return False + + if v == 1: + return True + + ThrowError.EnvVarHasBadValue2(envVarName, v) + return False + + ThrowError.EnvVarHasBadValue(envVarName) + return False + +# ///////////////////////////////////////////////////////////////////////////// +# TestStartupData__Helper + + +class TestStartupData__Helper: + sm_StartTS = datetime.datetime.now() + + # -------------------------------------------------------------------- + @staticmethod + def GetStartTS() -> datetime.datetime: + assert type(__class__.sm_StartTS) is datetime.datetime + return __class__.sm_StartTS + + # -------------------------------------------------------------------- + @staticmethod + def CalcRootDir() -> str: + r = os.path.abspath(__file__) + r = os.path.dirname(r) + r = os.path.join(r, C_ROOT_DIR__RELATIVE) + r = os.path.abspath(r) + return r + + # -------------------------------------------------------------------- + @staticmethod + def CalcRootTmpDir() -> str: + if TestConfigPropNames.TEST_CFG__TEMP_DIR in os.environ: + resultPath = os.environ[TestConfigPropNames.TEST_CFG__TEMP_DIR] + else: + rootDir = __class__.CalcRootDir() + resultPath = os.path.join(rootDir, "tmp") + + assert type(resultPath) is str + return resultPath + + # -------------------------------------------------------------------- + @staticmethod + def CalcRootLogDir() -> str: + if TestConfigPropNames.TEST_CFG__LOG_DIR in os.environ: + resultPath = os.environ[TestConfigPropNames.TEST_CFG__LOG_DIR] + else: + rootDir = __class__.CalcRootDir() + resultPath = os.path.join(rootDir, "logs") + + assert type(resultPath) is str + return resultPath + + # -------------------------------------------------------------------- + @staticmethod + def CalcCurrentTestWorkerSignature() -> str: + currentPID = os.getpid() + assert type(currentPID) is int + + startTS = __class__.sm_StartTS + assert type(startTS) is datetime.datetime + + result = "pytest-{0:04d}{1:02d}{2:02d}_{3:02d}{4:02d}{5:02d}".format( + startTS.year, + startTS.month, + startTS.day, + startTS.hour, + startTS.minute, + startTS.second, + ) + + gwid = os.environ.get("PYTEST_XDIST_WORKER") + + if gwid is not None: + result += "--xdist_" + str(gwid) + + result += "--" + "pid" + str(currentPID) + return result + + +# ///////////////////////////////////////////////////////////////////////////// +# TestStartupData + + +class TestStartupData: + sm_RootDir: str = TestStartupData__Helper.CalcRootDir() + sm_RootTmpDir: str = TestStartupData__Helper.CalcRootTmpDir() + sm_RootTmpDataDir: str = os.path.join(sm_RootTmpDir, "data") + sm_CurrentTestWorkerSignature: str = ( + TestStartupData__Helper.CalcCurrentTestWorkerSignature() + ) + sm_RootTmpDataDirForCurrentTestWorker: str = os.path.join( + sm_RootTmpDataDir, sm_CurrentTestWorkerSignature + ) + + sm_RootLogDir: str = TestStartupData__Helper.CalcRootLogDir() + + # -------------------------------------------------------------------- + @staticmethod + def GetRootDir() -> str: + assert type(__class__.sm_RootDir) is str + return __class__.sm_RootDir + + # -------------------------------------------------------------------- + @staticmethod + def GetRootLogDir() -> str: + assert type(__class__.sm_RootLogDir) is str + return __class__.sm_RootLogDir + + # -------------------------------------------------------------------- + @staticmethod + def GetCurrentTestWorkerSignature() -> str: + assert type(__class__.sm_CurrentTestWorkerSignature) is str + return __class__.sm_CurrentTestWorkerSignature + + # -------------------------------------------------------------------- + @staticmethod + def GetRootTmpDataDirForCurrentTestWorker() -> str: + assert type(__class__.sm_RootTmpDataDirForCurrentTestWorker) is str + return __class__.sm_RootTmpDataDirForCurrentTestWorker + + +# ///////////////////////////////////////////////////////////////////////////// +# class TestTempDirCleaner2 + + +class TestTempDirCleaner2: + @staticmethod + def exec( + root_path: str, + artifact_patterns: typing.Iterable[str], + ): + assert type(root_path) is str + assert isinstance(artifact_patterns, typing.Iterable) + + stack: typing.List[__class__.tagStackItem] = [] + + __class__.Helper__push(stack, root_path) + + while len(stack) > 0: + head = stack[-1] + assert type(head) is __class__.tagStackItem + + if len(head.data[1]) > 0: + __class__.Helper__push( + stack, + os.path.join(head.path, head.data[1].pop()), + ) + continue + + stack.pop() + + # delete files + cNotDeleted = 0 + for f in head.data[2]: + assert type(f) is str + assert f != "" + + full_file_path = os.path.join(head.path, f) + + # Получаем путь относительно корня очистки для проверки масок (например: "pg_wal/0001.history") + # Это нужно, чтобы работали маски вида "**/pg_wal/*.history" + rel_path = os.path.relpath(full_file_path, root_path) + + # Проверяем, подходит ли файл под какую-либо маску артефактов + is_artifact = False + for pattern in artifact_patterns: + # fnmatch отлично понимает структуры с '/' и '*' + if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(f, pattern): + is_artifact = True + break + + if is_artifact: + cNotDeleted += 1 + continue + + if not __class__.Helper__safe_delete_file(full_file_path): + cNotDeleted += 1 + continue + + if cNotDeleted > 0: + continue + + x = __class__.Helper__walk(head.path) + + if x is None: + # ACHTUNG + continue + + if len(x[1]) > 0: + continue + + if len(x[2]) > 0: + continue + + __class__.Helper__safe_delete_dir(head.path) + continue + return + + # -------------------------------------------------------------------- + T_WALK_RESULT = typing.Tuple[ + str, + typing.List[str], + typing.List[str], + ] + + # -------------------------------------------------------------------- + class tagStackItem: + path: str + data: TestTempDirCleaner2.T_WALK_RESULT + + def __init__(self, path, data): + assert type(data) is tuple + assert len(data) == 3 + assert type(data[0]) is str + assert type(data[1]) is list # dirs + assert type(data[2]) is list # files + + self.path = path + self.data = data + return + + # -------------------------------------------------------------------- + @staticmethod + def Helper__walk(path: str) -> typing.Optional[T_WALK_RESULT]: + try: + x = os.walk(path).__next__() + except StopIteration: + return None + + assert x is not None + assert type(x) is tuple + assert len(x) == 3 + assert type(x[0]) is str + assert type(x[1]) is list # dirs + assert type(x[2]) is list # files + return x + + # -------------------------------------------------------------------- + @staticmethod + def Helper__push( + stack: typing.List[tagStackItem], + path: str, + ) -> bool: + assert type(path) is str + assert path != "" + x = __class__.Helper__walk(path) + if x is None: + # ACHTUNG + return False + stack.append(__class__.tagStackItem(path, x)) + return True + + # -------------------------------------------------------------------- + @staticmethod + def Helper__safe_delete_file(path: str) -> bool: + assert type(path) is str + assert path != "" + try: + os.remove(path) + except Exception as e: + msg = "File [{}] is not deleted. Reason ({}): {}".format( + path, + type(e).__name__, + e, + ) + logging.info(msg) + return False + return True + + # -------------------------------------------------------------------- + @staticmethod + def Helper__safe_delete_dir(path: str) -> bool: + assert type(path) is str + assert path != "" + try: + os.rmdir(path) + except Exception as e: + msg = "Dir [{}] is not deleted. Reason ({}): {}".format( + path, + type(e).__name__, + e, + ) + logging.info(msg) + return False + return True + +# ///////////////////////////////////////////////////////////////////////////// +# TestServices + + +class TestServices: + C_UNPACKED_TMP_DIR_SIZE_TRESHOLD = 5 * 1024 * 1024 + + # -------------------------------------------------------------------- + @staticmethod + def GetRootDir() -> str: + return TestStartupData.GetRootDir() + + # -------------------------------------------------------------------- + @staticmethod + def GetRootTmpDir() -> str: + return TestStartupData.GetRootTmpDataDirForCurrentTestWorker() + + # -------------------------------------------------------------------- + @staticmethod + def MakeRootTmpDirForGlobalResources(globalResourceID: str) -> str: + assert isinstance(globalResourceID, str) + return os.path.join(__class__.GetRootTmpDir(), ".global", globalResourceID) + + # -------------------------------------------------------------------- + @staticmethod + def GetCurTestTmpDir(request: pytest.FixtureRequest) -> str: + assert isinstance(request, pytest.FixtureRequest) + return __class__.Helper__GetCurTestTmpDir(request.node) + + # -------------------------------------------------------------------- + @staticmethod + def Helper__GetCurTestTmpDir(function: pytest.Function) -> str: + assert isinstance(function, pytest.Function) + + rootDir = TestServices.GetRootDir() + rootTmpDir = TestServices.GetRootTmpDir() + + # [2024-12-18] It is not a fact now. + # assert rootTmpDir.startswith(rootDir) + + testPath = str(function.path) + + if not testPath.startswith(rootDir): + raise Exception( + "Root dir {0} is not found in testPath {1}.".format(rootDir, testPath) + ) + + testPath2 = testPath[len(rootDir) + 1:] + + result = os.path.join(rootTmpDir, testPath2) + + if function.cls is not None: + clsName = function.cls.__name__ + result = os.path.join(result, clsName) + + result = os.path.join(result, function.name) + + return result + + # -------------------------------------------------------------------- + sm_ArtifactRules = { + "**/*.log", + "**/*.conf", + } + + # -------------------------------------------------------------------- + @staticmethod + def CleanTestTmpDirBeforeExit( + function: pytest.Function, + exit_status: TestExitStatus, + ): + assert isinstance(function, pytest.Function) + assert type(exit_status) is TestExitStatus + + tmpDir = __class__.Helper__GetCurTestTmpDir(function) + assert type(tmpDir) is str + + if not os.path.exists(tmpDir): + return + + if TestConfigHelper.NoCleanup(exit_status): + logging.info("A final data cleanup is disabled [test exit status is {}].".format( + exit_status.name, + )) + else: + logging.info("Tmp directory [{}] is cleaned...".format( + tmpDir, + )) + + TestTempDirCleaner2.exec( + tmpDir, + __class__.sm_ArtifactRules, + ) + + if not os.path.exists(tmpDir): + return + + tmpDirSize = __class__.Helper__GetFolderSize(tmpDir) + + if tmpDirSize < __class__.C_UNPACKED_TMP_DIR_SIZE_TRESHOLD: + return + + logging.info("Tmp directory [{}] will be archived [size: {}]...".format( + tmpDir, + __class__.Helper__FormatSize(tmpDirSize), + )) + + shutil.make_archive( + tmpDir, + 'zip', + tmpDir, + ) + + shutil.rmtree(tmpDir) + return + + # -------------------------------------------------------------------- + @staticmethod + def CleanDirBeforeExit(dir_path: str): + assert type(dir_path) is str + + if not os.path.exists(dir_path): + return + + if TestConfigHelper.NoCleanup(None): + logging.info("A final data cleanup is disabled.") + return + + logging.info("Directory [{0}] is cleaned...".format(dir_path)) + + TestTempDirCleaner2.exec(dir_path, __class__.sm_ArtifactRules) + return + + # -------------------------------------------------------------------- + @staticmethod + def Helper__FormatSize(bytes_size: int) -> str: + assert type(bytes_size) is int + assert bytes_size >= 0 + + bytes_size_f = float(bytes_size) + for unit in ('B', 'KB', 'MB', 'GB', 'TB'): + if bytes_size_f < 1024: + return __class__.Helper__FormatSizeBuilder(bytes_size_f, unit) + bytes_size_f /= 1024 + continue + return __class__.Helper__FormatSizeBuilder(bytes_size_f, "PB") + + # -------------------------------------------------------------------- + @staticmethod + def Helper__FormatSizeBuilder( + bytes_size_f: float, + unit: str, + ) -> str: + assert type(bytes_size_f) is float + assert bytes_size_f >= 0 + assert type(unit) is str + + return "{:.2f} {}".format( + bytes_size_f, + unit, + ) + + # -------------------------------------------------------------------- + @staticmethod + def Helper__GetFolderSize(folder_path: str) -> int: + total_size = 0 + + for root, _, files in os.walk(folder_path): + for file in files: + file_path = os.path.join(root, file) + # Пропускаем символические ссылки, чтобы избежать ошибок и зацикливания + if not os.path.islink(file_path): + total_size += os.path.getsize(file_path) + continue + continue + + return total_size + + # -------------------------------------------------------------------- + @staticmethod + def PrintExceptionOK(e: Exception): + assert isinstance(e, Exception) + + logging.info( + "OK. We catch an exception. {}".format( + __class__.ExceptionToHumanText(e), + ) + ) + return + + # -------------------------------------------------------------------- + @staticmethod + def ThrowWeWaitAnException() -> typing.NoReturn: + raise Exception("We wait an exception!") + + # -------------------------------------------------------------------- + @staticmethod + def ThrowWeWaitAnXFailPleaseUpdateTest() -> typing.NoReturn: + raise Exception("We wait an xfail, please update test!") + + # -------------------------------------------------------------------- + @staticmethod + def LogCurrentExceptionAndThrowXFailItIsAnExpectedFailure() -> typing.NoReturn: + logging.exception("We catch an expected problem.") + raise pytest.xfail("It is an expected failure.") + + # -------------------------------------------------------------------- + @staticmethod + def SleepWithPrint( + sleepTimeInSec: float, + message: typing.Optional[str] = None, + ): + assert message is None or type(message) is str + + prefix = "" + + if message is not None and message != "": + prefix = message + + if not prefix.endswith("."): + prefix += "." + + prefix += " " + + logging.info("{}Sleep {} second(s).".format( + prefix, + sleepTimeInSec, + )) + time.sleep(sleepTimeInSec) + return + + # -------------------------------------------------------------------- + @staticmethod + def ExceptionToHumanText(exc: BaseException) -> str: + assert isinstance(exc, BaseException) + + if __class__.Helper__GetPrevExc(exc) is None: + return __class__.Helper__ExceptionToHumanText__Single(exc) + + return __class__.Helper__ExceptionToHumanText__Chain(exc) + + # -------------------------------------------------------------------- + @staticmethod + def Helper__ExceptionToHumanText__Single(exc: BaseException) -> str: + assert isinstance(exc, BaseException) + + if isinstance(exc, AssertionError): + err_msg = "Exception ({}).".format(type(exc).__name__) + assert type(err_msg) is str + + err_msg2 = __class__.Helper__GetTraceBack(exc) + assert type(err_msg2) is str + + if err_msg2 != "": + err_msg += " " + err_msg2 + + assert type(err_msg) is str + return err_msg + + err_msg = "Exception ({})".format(type(exc).__name__) + assert type(err_msg) is str + + err_msg2 = str(exc).strip() + + if err_msg2 == "": + err_msg += "." + else: + err_msg += ": " + err_msg2 + + assert type(err_msg) is str + return err_msg + + # -------------------------------------------------------------------- + @staticmethod + def Helper__ExceptionToHumanText__Chain(exc: BaseException) -> str: + assert isinstance(exc, BaseException) + + chain: typing.List[BaseException] = [] + curr = exc + + processed_exc_ids: typing.Set[int] = set() + + while curr is not None: + assert isinstance(curr, BaseException) + if id(curr) in processed_exc_ids: + logging.error("Cycle in exception chain: {}".format( + " --> ".join( + [type(x).__name__ for x in reversed(chain)], + ) + )) + break + + processed_exc_ids.add(id(curr)) + + chain.append(curr) + curr = __class__.Helper__GetPrevExc(curr) + continue + + lines: typing.List[str] = [] + + lines.append("It is a chain of exceptions (len: {}):".format( + len(chain), + )) + + n = 0 + for e in reversed(chain): + n += 1 + line1 = "---- {}. Exception ({})".format(n, type(e).__name__) + line2 = __class__.Helper__GetExcMsg(e) + + if line2 == "": + lines.append(line1) + else: + lines.append(line1 + ":") + lines.append(line2) + continue + + lines.append("--------") + + return "\n".join(lines) + + # -------------------------------------------------------------------- + @staticmethod + def Helper__GetPrevExc(exc: BaseException) -> typing.Optional[BaseException]: + assert isinstance(exc, BaseException) + return exc.__cause__ or exc.__context__ + + # -------------------------------------------------------------------- + @staticmethod + def Helper__GetExcMsg(exc: BaseException) -> str: + assert isinstance(exc, BaseException) + + if isinstance(exc, AssertionError): + return __class__.Helper__GetTraceBack(exc) + + return str(exc) + + # -------------------------------------------------------------------- + @staticmethod + def Helper__GetTraceBack(exc: BaseException) -> str: + assert isinstance(exc, BaseException) + + exc_info_lines = traceback.format_exception( + type(exc), + exc, + exc.__traceback__, + ) + assert type(exc_info_lines) is list + return "".join(exc_info_lines).strip() + +# ///////////////////////////////////////////////////////////////////////////// diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..763c830 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +pytest +git+https://github.com/AndrewBille/testgres.git@6d982b41 From 2a23c8e021d8af2a302acd77bda6086ce0d31498 Mon Sep 17 00:00:00 2001 From: Andrey Bille Date: Mon, 19 Oct 2026 12:53:00 +0300 Subject: [PATCH 02/17] test_pglab_compat.py is added --- tests/test_pglab_compat.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/test_pglab_compat.py diff --git a/tests/test_pglab_compat.py b/tests/test_pglab_compat.py new file mode 100644 index 0000000..42a31b1 --- /dev/null +++ b/tests/test_pglab_compat.py @@ -0,0 +1,29 @@ +import inspect + +from pglab import NamespaceOperations +from testgres.backup import NodeBackup +from testgres.node import PostgresNode + + +def test_testgres_target_execution_context_api(): + replicate = inspect.signature(PostgresNode.replicate).parameters + assert "target_os_ops" in replicate + assert "target_host" in replicate + assert "target_port" in replicate + + backup_init = inspect.signature(NodeBackup.__init__).parameters + assert "os_ops" in backup_init + + spawn_replica = inspect.signature(NodeBackup.spawn_replica).parameters + assert "target_host" in spawn_replica + assert "target_port" in spawn_replica + + +def test_namespace_operations_exposes_explicit_pg_bin(): + ops = NamespaceOperations( + namespace="pglab-test-unused", + os_user="postgres", + db_user="postgres", + pg_bin="/opt/postgresql/bin", + ) + assert ops.environ("PG_BIN") == "/opt/postgresql/bin" From 6a128188e5869476a2825e014f816e3986e24e2a Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 13:07:19 +0300 Subject: [PATCH 03/17] CI: lint stage is added (flake8, ruff) --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ pglab.py | 4 ++-- pyproject.toml | 4 ++++ tests/test_pglab_faults.py | 1 + tests/test_pglab_netem_effects.py | 1 + 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acc8676..4cb57e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,36 @@ on: - "**/*.md" jobs: + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 flake8-pyproject ruff==0.15.22 + - name: Lint with flake8 pglab.py tests/ + run: | + flake8 . + - name: Lint with ruff + run: | + ruff check . + test-pglab: runs-on: ubuntu-24.04 name: "test: pglab | ubuntu-24.04 | pg16" + needs: lint steps: - name: Checkout diff --git a/pglab.py b/pglab.py index b00e1e8..8681950 100644 --- a/pglab.py +++ b/pglab.py @@ -5,14 +5,13 @@ import ipaddress import os import pwd -import shlex import signal import shutil import stat import subprocess from dataclasses import dataclass from pathlib import Path -from typing import Dict, Optional, Union, Any +from typing import Optional from testgres.api import get_new_node from testgres.backup import NodeBackup @@ -20,6 +19,7 @@ from testgres.operations.local_ops import LocalOperations import tempfile + class PgLabError(RuntimeError): pass diff --git a/pyproject.toml b/pyproject.toml index dd5ab9d..a1a2103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,3 +31,7 @@ py-modules = ["pglab"] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.flake8] +extend-ignore = ["E501"] +exclude = [".git", "__pycache__", "env", "venv"] diff --git a/tests/test_pglab_faults.py b/tests/test_pglab_faults.py index 6ce261c..c71fd58 100644 --- a/tests/test_pglab_faults.py +++ b/tests/test_pglab_faults.py @@ -296,6 +296,7 @@ def test_cleanup_when_postgres_was_sigkilled(capsys): p.init().start() postmaster_pid = p.execute("select pg_postmaster_start_time() is not null, pg_backend_pid()")[0][1] + assert postmaster_pid is not None # Find actual postmaster pid from postmaster.pid, then kill -9 it. pgdata = Path(p.pg.data_dir) diff --git a/tests/test_pglab_netem_effects.py b/tests/test_pglab_netem_effects.py index e8b74cd..b26a83c 100644 --- a/tests/test_pglab_netem_effects.py +++ b/tests/test_pglab_netem_effects.py @@ -322,6 +322,7 @@ def test_netem_loss_creates_real_replication_lag_then_recovers(lab): assert recovered_lag < lag + def test_replica_cannot_recover_after_required_wal_is_recycled(lab): """A long enough outage must become unrecoverable without WAL retention. From 36244ddb16542bc1fa39483e72c0ae6f9d5fc4e1 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 14:41:52 +0300 Subject: [PATCH 04/17] CI: run tests on py3.9-py3.14 --- .github/workflows/ci.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cb57e2..baa6a1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,17 +43,21 @@ jobs: test-pglab: runs-on: ubuntu-24.04 - name: "test: pglab | ubuntu-24.04 | pg16" + name: "test: pglab | ubuntu-24.04 | pg16 | py${{ matrix.python-version }}" needs: lint + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout uses: actions/checkout@v7 - - name: Set up Python 3.12 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install PostgreSQL and PgLab system dependencies @@ -103,5 +107,5 @@ jobs: uses: actions/upload-artifact@v7 if: always() with: - name: testgres--pglab-logs--id${{ github.run_id }} + name: testgres--pglab-logs--py${{ matrix.python-version }}--id${{ github.run_id }} path: logs-pglab/ From 16f77b46b543506a2fe4ca86978a593ea363271a Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 14:53:01 +0300 Subject: [PATCH 05/17] testgres branch 'target-execution-context' is used --- tests/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/requirements.txt b/tests/requirements.txt index 763c830..728e8ee 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,2 @@ pytest -git+https://github.com/AndrewBille/testgres.git@6d982b41 +git+https://github.com/AndrewBille/testgres.git@target-execution-context From 55af726495ca30acc4c0f3d893bff93c7aa97ef3 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 15:22:52 +0300 Subject: [PATCH 06/17] CI: test on python 3.8 is added --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baa6a1b..062e379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout From dd77716e0900097f4d790dd343c8a1f70e1df14f Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 15:26:30 +0300 Subject: [PATCH 07/17] CI: test on python 3.7 is added --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 062e379..dd51ca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout From a45180846ebb4403a6dff788735f74149461e44a Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 15:28:09 +0300 Subject: [PATCH 08/17] Revert "CI: test on python 3.7 is added" This reverts commit dd77716e0900097f4d790dd343c8a1f70e1df14f. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd51ca6..062e379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout From c24964582edb2e897caddecbd2138cfca1347465 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 15:46:46 +0300 Subject: [PATCH 09/17] CI: build-check is added --- .github/workflows/ci.yml | 28 +++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 062e379..0f5586b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,10 +41,36 @@ jobs: run: | ruff check . + build-check: + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python 3.12 + uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install build tools + run: | + python -m pip install --upgrade pip + python -m pip install build twine + + - name: Build package + run: | + python -m build + + - name: Check package metadata + run: | + twine check dist/* + test-pglab: runs-on: ubuntu-24.04 name: "test: pglab | ubuntu-24.04 | pg16 | py${{ matrix.python-version }}" - needs: lint + needs: build-check strategy: fail-fast: false matrix: diff --git a/pyproject.toml b/pyproject.toml index a1a2103..b5f791b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.9" license = "PostgreSQL" dependencies = [ - "testgres>=1.17.0", + "testgres @ git+https://github.com/AndrewBille/testgres.git@target-execution-context", ] classifiers = [ "Development Status :: 3 - Alpha", From 75402df1bb83a834a416377f9ce8ef8ccb605b74 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 15:51:41 +0300 Subject: [PATCH 10/17] classifiers is updated (python>=3.8) --- pyproject.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b5f791b..8f81ef8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "testgres-pglab" version = "0.1.0" description = "Linux netns/cgroup/netem fault injection for PostgreSQL tests using Testgres" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.8" license = "PostgreSQL" dependencies = [ "testgres @ git+https://github.com/AndrewBille/testgres.git@target-execution-context", @@ -17,6 +17,13 @@ classifiers = [ "Intended Audience :: Developers", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", + "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 :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Testing", "Topic :: Database", ] From 75bfaf0dea6922dd4cdc30dd3000380583afb8c6 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 16:50:04 +0300 Subject: [PATCH 11/17] https://github.com/postgrespro/testgres.git@master is used --- pyproject.toml | 2 +- tests/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8f81ef8..4f517ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.8" license = "PostgreSQL" dependencies = [ - "testgres @ git+https://github.com/AndrewBille/testgres.git@target-execution-context", + "testgres @ git+https://github.com/postgrespro/testgres.git@master", ] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/tests/requirements.txt b/tests/requirements.txt index 728e8ee..5095eb2 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,2 @@ pytest -git+https://github.com/AndrewBille/testgres.git@target-execution-context +git+https://github.com/postgrespro/testgres.git@master From 3228ead476e6299fa0c510affc74c32c1fb024d8 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 16:55:36 +0300 Subject: [PATCH 12/17] pglab.py -> src/__init__.py (+ __version__) --- example.py | 2 +- pyproject.toml | 13 ++++++++----- smoke.py | 2 +- pglab.py => src/__init__.py | 3 +++ tests/test_compat.py | 2 +- tests/test_pglab.py | 2 +- tests/test_pglab_compat.py | 2 +- tests/test_pglab_faults.py | 2 +- tests/test_pglab_netem.py | 2 +- tests/test_pglab_netem_effects.py | 2 +- tools/verify_install.py | 2 +- 11 files changed, 20 insertions(+), 14 deletions(-) rename pglab.py => src/__init__.py (99%) diff --git a/example.py b/example.py index ceec560..a8c445d 100755 --- a/example.py +++ b/example.py @@ -2,7 +2,7 @@ import os from pathlib import Path -from pglab import PgLab +from src import PgLab def need_env(name): diff --git a/pyproject.toml b/pyproject.toml index 4f517ff..337f34c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,15 @@ requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" +[tool.setuptools.package-dir] +"testgres_exts.pglab" = "src" + +[tool.setuptools.dynamic] +version = {attr = "testgres_exts.pglab.__version__"} + [project] -name = "testgres-pglab" -version = "0.1.0" +name = "testgres_exts.pglab" +dynamic = ["version"] description = "Linux netns/cgroup/netem fault injection for PostgreSQL tests using Testgres" readme = "README.md" requires-python = ">=3.8" @@ -33,9 +39,6 @@ test = [ "pytest>=8,<10", ] -[tool.setuptools] -py-modules = ["pglab"] - [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/smoke.py b/smoke.py index ceec560..a8c445d 100755 --- a/smoke.py +++ b/smoke.py @@ -2,7 +2,7 @@ import os from pathlib import Path -from pglab import PgLab +from src import PgLab def need_env(name): diff --git a/pglab.py b/src/__init__.py similarity index 99% rename from pglab.py rename to src/__init__.py index 8681950..01621bf 100644 --- a/pglab.py +++ b/src/__init__.py @@ -20,6 +20,9 @@ import tempfile +__version__ = "0.1.0" + + class PgLabError(RuntimeError): pass diff --git a/tests/test_compat.py b/tests/test_compat.py index 42a31b1..d9d97a9 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,6 +1,6 @@ import inspect -from pglab import NamespaceOperations +from src import NamespaceOperations from testgres.backup import NodeBackup from testgres.node import PostgresNode diff --git a/tests/test_pglab.py b/tests/test_pglab.py index 35202fc..a478199 100644 --- a/tests/test_pglab.py +++ b/tests/test_pglab.py @@ -6,7 +6,7 @@ import pytest -from pglab import PgLab, IOLimits +from src import PgLab, IOLimits PG_BIN = os.environ.get("PG_BIN", "/home/andrew/pgpro/build/master/bin") diff --git a/tests/test_pglab_compat.py b/tests/test_pglab_compat.py index 42a31b1..d9d97a9 100644 --- a/tests/test_pglab_compat.py +++ b/tests/test_pglab_compat.py @@ -1,6 +1,6 @@ import inspect -from pglab import NamespaceOperations +from src import NamespaceOperations from testgres.backup import NodeBackup from testgres.node import PostgresNode diff --git a/tests/test_pglab_faults.py b/tests/test_pglab_faults.py index c71fd58..631fa6f 100644 --- a/tests/test_pglab_faults.py +++ b/tests/test_pglab_faults.py @@ -7,7 +7,7 @@ import pytest -from pglab import PgLab, IOLimits +from src import PgLab, IOLimits PG_BIN = os.environ.get("PG_BIN", "/home/andrew/pgpro/build/master/bin") diff --git a/tests/test_pglab_netem.py b/tests/test_pglab_netem.py index 338b280..dfac203 100644 --- a/tests/test_pglab_netem.py +++ b/tests/test_pglab_netem.py @@ -7,7 +7,7 @@ import pytest -from pglab import PgLab, PgLabError +from src import PgLab, PgLabError PG_BIN = os.environ.get("PG_BIN", "/home/andrew/pgpro/build/master/bin") diff --git a/tests/test_pglab_netem_effects.py b/tests/test_pglab_netem_effects.py index b26a83c..ca78938 100644 --- a/tests/test_pglab_netem_effects.py +++ b/tests/test_pglab_netem_effects.py @@ -8,7 +8,7 @@ import pytest -from pglab import PgLab +from src import PgLab PG_BIN = os.environ.get("PG_BIN", "/home/andrew/pgpro/build/master/bin") diff --git a/tools/verify_install.py b/tools/verify_install.py index 923803c..7ed7fe8 100755 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -5,7 +5,7 @@ from importlib import metadata from pathlib import Path -import pglab +import src as pglab from testgres.backup import NodeBackup from testgres.node import PostgresNode From c6263da9361dbc43c819c260f21921f80bee4c61 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 17:03:50 +0300 Subject: [PATCH 13/17] CI: lint section name is corrected --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5586b..7207cf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install flake8 flake8-pyproject ruff==0.15.22 - - name: Lint with flake8 pglab.py tests/ + - name: Lint with flake8 run: | flake8 . - name: Lint with ruff From 323431f7887e908df2fe48c46c6368e04ee83548 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 17:05:57 +0300 Subject: [PATCH 14/17] pyproject.toml is updated (test options) --- pyproject.toml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 337f34c..133406b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,9 +39,13 @@ test = [ "pytest>=8,<10", ] -[tool.pytest.ini_options] -testpaths = ["tests"] - [tool.flake8] extend-ignore = ["E501"] exclude = [".git", "__pycache__", "env", "venv"] + +# Pytest settings +[tool.pytest.ini_options] +testpaths = ["tests"] +log_file_level = "NOTSET" +log_file_format = "%(levelname)8s [%(asctime)s] %(message)s" +log_file_date_format = "%Y-%m-%d %H:%M:%S" From c71cf6afbe7bc46cd369e203f226a886733824c2 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 17:11:25 +0300 Subject: [PATCH 15/17] setuptools>=61.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 133406b..6c2f34c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=77"] +requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [tool.setuptools.package-dir] From d13310489829f1f5aae1b5fa5e286c36efb45864 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 17:58:10 +0300 Subject: [PATCH 16/17] CI: python-publish.yml is added --- .github/workflows/python-publish.yml | 70 ++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..b1782e5 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,70 @@ +# This workflow will upload a Python Package to PyPI when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + release-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + + - name: Build release distributions + run: | + # NOTE: put your own distribution build steps here. + python -m pip install build + python -m build + + - name: Upload distributions + uses: actions/upload-artifact@v7 + with: + name: release-dists + path: dist/ + + pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + + # Dedicated environments with protections for publishing are strongly recommended. + # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules + environment: + name: pypi + # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: + # url: https://pypi.org/p/YOURPROJECT + # + # ALTERNATIVE: if your GitHub Release name is the PyPI project version string + # ALTERNATIVE: exactly, uncomment the following line instead: + # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} + + steps: + - name: Retrieve release distributions + uses: actions/download-artifact@v8 + with: + name: release-dists + path: dist/ + + - name: Publish release distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ From 921c737e08b6132f38f7334002ba1c7fc4961f92 Mon Sep 17 00:00:00 2001 From: "d.kovalenko" Date: Mon, 19 Oct 2026 18:17:22 +0300 Subject: [PATCH 17/17] testgres>=1.17,<1.18 --- pyproject.toml | 2 +- tests/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c2f34c..76a3c33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ readme = "README.md" requires-python = ">=3.8" license = "PostgreSQL" dependencies = [ - "testgres @ git+https://github.com/postgrespro/testgres.git@master", + "testgres>=1.17,<1.18", ] classifiers = [ "Development Status :: 3 - Alpha", diff --git a/tests/requirements.txt b/tests/requirements.txt index 5095eb2..201877e 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,2 @@ pytest -git+https://github.com/postgrespro/testgres.git@master +testgres>=1.17,<1.18