Coverage for src/ensembl/utils/plugin.py: 93%
72 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-06 14:10 +0000
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-06 14:10 +0000
1# See the NOTICE file distributed with this work for additional information
2# regarding copyright ownership.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15"""Ensembl's pytest plugin with useful unit testing hooks and fixtures."""
17from __future__ import annotations
19from difflib import unified_diff
20import os
21from pathlib import Path
22import re
23from typing import Callable, Generator, TypeAlias
25import pytest
26from pytest import Config, FixtureRequest, Parser
27from sqlalchemy.engine import make_url
28from sqlalchemy.schema import MetaData
30from ensembl.utils import StrPath
31from ensembl.utils.database import UnitTestDB
34DBFactory: TypeAlias = Callable[[StrPath | None, str | None, MetaData | None], UnitTestDB]
37def pytest_addoption(parser: Parser) -> None:
38 """Registers argparse-style options for Ensembl's unit testing.
40 `Pytest initialisation hook
41 <https://docs.pytest.org/en/latest/reference.html#_pytest.hookspec.pytest_addoption>`_.
43 Args:
44 parser: Parser for command line arguments and ini-file values.
46 """
47 # Add the Ensembl unitary test parameters to pytest parser
48 group = parser.getgroup("Ensembl unit testing")
49 group.addoption(
50 "--server",
51 action="store",
52 metavar="URL",
53 dest="server",
54 required=False,
55 default=os.getenv("DB_HOST", "sqlite:///"),
56 help="Server URL where to create the test database(s)",
57 )
58 group.addoption(
59 "--keep-dbs",
60 action="store_true",
61 dest="keep_dbs",
62 required=False,
63 help="Do not remove the test databases (default: False)",
64 )
67def pytest_configure(config: Config) -> None:
68 """Allows plugins and conftest files to perform initial configuration.
70 More information: https://docs.pytest.org/en/latest/reference/reference.html#std-hook-pytest_configure
72 Args:
73 config: The pytest config object.
75 """
76 # Load server information
77 server_url = make_url(config.getoption("server"))
78 # If password set, treat it as an environment variable that needs to be resolved
79 if server_url.password: 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
80 server_url = server_url.set(password=os.path.expandvars(server_url.password))
81 config.option.server = server_url.render_as_string(hide_password=False)
84def pytest_report_header(config: Config) -> str:
85 """Presents extra information in the report header.
87 Args:
88 config: Access to configuration values, pluginmanager and plugin hooks.
90 """
91 # Show server information, masking the password value
92 server = config.getoption("server")
93 server = re.sub(r"(//[^/]+:).*(@)", r"\1xxxxxx\2", server)
94 return f"server: {server}"
97@pytest.fixture(name="data_dir", scope="module")
98def local_data_dir(request: FixtureRequest) -> Path:
99 """Returns the path to the test data folder matching the test's name.
101 Args:
102 request: Fixture that provides information of the requesting test function.
104 """
105 return Path(request.module.__file__).with_suffix("")
108@pytest.fixture(name="assert_files")
109def fixture_assert_files() -> Callable[[StrPath, StrPath], None]:
110 """Returns a function that asserts if two text files are equal, or prints their differences."""
112 def _assert_files(result_path: StrPath, expected_path: StrPath) -> None:
113 """Asserts if two files are equal, or prints their differences.
115 Args:
116 result_path: Path to results (test-made) file.
117 expected_path: Path to expected file.
119 """
120 with open(result_path, "r") as result_fh:
121 results = result_fh.readlines()
122 with open(expected_path, "r") as expected_fh:
123 expected = expected_fh.readlines()
124 files_diff = list(
125 unified_diff(
126 results,
127 expected,
128 fromfile=f"Test-made file {Path(result_path).name}",
129 tofile=f"Expected file {Path(expected_path).name}",
130 )
131 )
132 assert_message = f"Test-made and expected files differ\n{' '.join(files_diff)}"
133 assert len(files_diff) == 0, assert_message
135 return _assert_files
138@pytest.fixture(name="db_factory", scope="module")
139def fixture_db_factory(request: FixtureRequest, data_dir: Path) -> Generator[DBFactory, None, None]:
140 """Yields a unit test database factory.
142 Args:
143 request: Fixture that provides information of the requesting test function.
144 data_dir: Fixture that provides the path to the test data folder matching the test's name.
146 """
147 created: dict[str, UnitTestDB] = {}
148 server_url = request.config.getoption("server")
150 def _db_factory(
151 src: StrPath | None, name: str | None = None, metadata: MetaData | None = None
152 ) -> UnitTestDB:
153 """Returns a unit test database.
155 Args:
156 src: Directory path where the test database schema and content files are located, if any.
157 name: Name to give to the new database. See `UnitTestDB` for more information.
158 metadata: SQLAlchemy ORM schema metadata to populate the schema of the test database.
160 """
161 if src is not None:
162 src_path = Path(src)
163 if not src_path.is_absolute():
164 src_path = data_dir / src_path
165 db_key = name if name else src_path.name
166 dump_dir: Path | None = src_path if src_path.exists() else None
167 else:
168 db_key = name if name else "dbkey"
169 dump_dir = None
170 return created.setdefault(
171 db_key, UnitTestDB(server_url, dump_dir=dump_dir, name=name, metadata=metadata)
172 )
174 yield _db_factory
175 # Drop all unit test databases unless the user has requested to keep them
176 if not request.config.getoption("keep_dbs"): 176 ↛ exitline 176 didn't return from function 'fixture_db_factory' because the condition on line 176 was always true
177 for test_db in created.values():
178 test_db.drop()
181@pytest.fixture(scope="module")
182def test_dbs(request: FixtureRequest, db_factory: Callable) -> dict[str, UnitTestDB]:
183 """Returns a dictionary of unit test databases with the database name as key.
185 Requires a list of dictionaries, each with keys `src`, `name` and `metadata`, passed via `request.param`.
186 At minimum either `src` or `name` needs to be provided. See `db_factory()` for details about each key's
187 value.
189 This fixture is a wrapper of `db_factory()` intended to be used via indirect parametrization,
190 for example::
192 from ensembl.core.models import Base
193 @pytest.mark.parametrize(
194 "test_dbs",
195 [
196 [
197 {"src": "core_db"},
198 {"src": "core_db", "name": "human"},
199 {"src": "core_db", "name": "cat", "metadata": Base.metadata},
200 ]
201 ],
202 indirect=True
203 )
204 def test_method(..., test_dbs: dict[str, UnitTestDB], ...):
207 Args:
208 request: Fixture that provides information of the requesting test function.
209 db_factory: Fixture that provides a unit test database factory.
211 """
212 databases = {}
213 for argument in request.param:
214 src = argument.get("src", None)
215 if src is not None:
216 src = Path(src)
217 name = argument.get("name", None)
218 try:
219 key = name or src.name
220 except AttributeError as exc:
221 raise TypeError("Expected at least 'src' or 'name' argument defined") from exc
222 databases[key] = db_factory(src=src, name=name, metadata=argument.get("metadata"))
223 return databases