diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 77c9ad2..6e9cfb9 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -22,7 +22,10 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pylint psycopg2 packaging + pip install pylint + if [ -r requirements.txt ]; then + pip install -r requirements.txt + fi - name: Analysing the code with pylint run: | pylint $(git ls-files '*.py') diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 343e8a3..f147791 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ # # Special thank you to pylint-dev project at GitHub where inspiration # was taken. -name: unit tests +name: Unit tests on: pull_request: @@ -12,23 +12,12 @@ on: jobs: tests-linux: - name: run / ${{ matrix.python-version }} / Linux + name: Linux / Python ${{ matrix.python-version }} / Postgres ${{ matrix.postgres-version }} runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10"] - services: - timescale: - image: timescale/timescaledb:latest-pg15 - env: - POSTGRES_PASSWORD: xyzzy - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 + postgres-version: ["14", "15"] timeout-minutes: 25 steps: @@ -50,8 +39,6 @@ jobs: fi - name: Run PyTest env: - PGHOST: localhost - PGUSER: postgres - PGPASSWORD: xyzzy - PGPORT: 5432 + TEST_CONTAINER_TIMESCALE: timescale/timescaledb:latest-pg${{ matrix.postgres-version }} + TEST_CONTAINER_POSTGRES: postgres:${{ matrix.postgres-version }} run: python -m pytest diff --git a/requirements.txt b/requirements.txt index 8f82d3f..b83800f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,5 @@ -psycopg2>=2.9.2 +psycopg2>=2.9.0 +testcontainers>=3.7.1 +packaging>=21.0 +sqlalchemy>=1.1.2 + diff --git a/src/doctor/rules/__init__.py b/src/doctor/rules/__init__.py index f82a58d..b0ed988 100644 --- a/src/doctor/rules/__init__.py +++ b/src/doctor/rules/__init__.py @@ -28,7 +28,7 @@ def is_rule_file(fname): """Check if file is a rules file.""" if not isfile(fname): return False - if fname.endswith(['__init__.py', '_test.py']) or fname.startswith("test_"): + if fname.endswith(('__init__.py', '_test.py')) or fname.startswith("test_"): return False return True diff --git a/src/doctor/rules/compression_test.py b/src/doctor/rules/compression_test.py index b74d4a7..d8d2611 100644 --- a/src/doctor/rules/compression_test.py +++ b/src/doctor/rules/compression_test.py @@ -14,16 +14,12 @@ """Unit tests for compressed hypertable rules.""" -import os -import unittest -import psycopg2 - -from psycopg2.extras import RealDictCursor from timescaledb import Hypertable +from doctor.unittest import TimescaleDBTestCase from doctor.rules.compression import LinearSegmentBy, PointlessSegmentBy -class TestCompressionRules(unittest.TestCase): +class TestCompressionRules(TimescaleDBTestCase): """Test compression rules. This will create a hypertable where we segment-by a column that @@ -34,24 +30,15 @@ class TestCompressionRules(unittest.TestCase): def setUp(self): """Set up unit tests for compression rules.""" - user = os.getenv("PGUSER") - host = os.getenv("PGHOST") - port = os.getenv("PGPORT") or "5432" - dbname = os.getenv("PGDATABASE") - password = os.getenv("PGPASSWORD") - print(f"connecting to {host}:{port} database {dbname}") - self.__conn = psycopg2.connect(dbname=dbname, user=user, host=host, - password=password, port=port, - cursor_factory=RealDictCursor) table = Hypertable("conditions", "time", { 'time': "timestamptz not null", 'device_id': "integer", 'user_id': "integer", 'temperature': "float" }) - table.create(self.__conn) + table.create(self.connection) - with self.__conn.cursor() as cursor: + with self.connection.cursor() as cursor: cursor.execute( "INSERT INTO conditions " "SELECT time, (random()*30)::int, 1, random()*80 - 40 " @@ -64,17 +51,13 @@ def setUp(self): ")" ) cursor.execute("ANALYZE conditions") - self.__conn.commit() + self.connection.commit() def tearDown(self): """Tear down compression rules test.""" - with self.__conn.cursor() as cursor: + with self.connection.cursor() as cursor: cursor.execute("DROP TABLE conditions") - self.__conn.commit() - - def run_rule(self, rule): - """Run rule and return messages.""" - return rule.execute(self.__conn, rule.message) + self.connection.commit() def test_segmentby(self): """Test rule for detecting bad choice for segment-by column.""" diff --git a/src/doctor/unittest.py b/src/doctor/unittest.py new file mode 100644 index 0000000..17b44c7 --- /dev/null +++ b/src/doctor/unittest.py @@ -0,0 +1,101 @@ +# Copyright 2023 Timescale, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests support for Timescale Doctor rules. + +""" + +import os +import unittest + +from abc import ABCMeta + +import psycopg2 + +from psycopg2.extras import RealDictCursor +from testcontainers.postgres import PostgresContainer + +class TestCase(unittest.TestCase, metaclass=ABCMeta): + """Base class for Timescale Doctor unit tests. + + Test cases are executed in a container that depends on the + requirements of the test case. Subclasses of this class add the + actual container and is the test case class that should be used. + + """ + + container_name = None + + @property + def connection(self): + """Get database connection.""" + return self.__connection + + @property + def container(self): + """Get container the test is running in.""" + return self.__container + + def run_rule(self, rule): + """Run rule and return messages.""" + return rule.execute(self.connection, rule.message) + + @classmethod + def setUpClass(cls): + """Start a container and create a connection for all tests.""" + assert cls.container_name is not None + cls.__container = PostgresContainer(cls.container_name).start() + connstring = cls.__container.get_connection_url().replace("+psycopg2", "") + cls.__connection = psycopg2.connect(connstring, cursor_factory=RealDictCursor) + + @classmethod + def tearDownClass(cls): + """Close the connection and stop the container.""" + cls.__connection.close() + cls.__container.stop() + +class TimescaleDBTestCase(TestCase): + """Base class for test cases that need TimescaleDB. + + It will read the container name from the environment variable + "TEST_CONTAINER_TIMESCALE" if present, or default to + "timescaledb:latest-pg15". + + Typical usage:: + + from doctor.unittest import TimescaleDBTestCase + + class TestCompressionRules(TimescaleDBTestCase): + ... + + """ + + container_name = os.environ.get('TEST_CONTAINER_TIMESCALE', 'timescale/timescaledb:latest-pg15') + +class PostgreSQLTestCase(TestCase): + """Base class for test cases that use plain PostgreSQL. + + It will read the container name from the environment variable + "TEST_CONTAINER_POSTGRES" if present, or default to + "postgres:latest". + + Typical usage:: + + from doctor.unittest import PostgreSQLTestCase + + class TestCompressionRules(PostgreSQLTestCase): + ... + """ + + container_name = os.environ.get('TEST_CONTAINER_POSTGRES', 'postgres:latest')