Coverage for src / ensembl / utils / plugin.py: 99%
72 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-21 10:45 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-21 10:45 +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
33DBFactory: TypeAlias = Callable[[StrPath | None, str | None, MetaData | None], UnitTestDB]
36def pytest_addoption(parser: Parser) -> None:
37 """Registers argparse-style options for Ensembl's unit testing.
39 `Pytest initialisation hook
40 <https://docs.pytest.org/en/latest/reference.html#_pytest.hookspec.pytest_addoption>`_.
42 Args:
43 parser: Parser for command line arguments and ini-file values.
45 """
46 # Add the Ensembl unitary test parameters to pytest parser
47 group = parser.getgroup("Ensembl unit testing")
48 group.addoption(
49 "--server",
50 action="store",
51 metavar="URL",
52 dest="server",
53 required=False,
54 default=os.getenv("DB_HOST", "sqlite:///"),
55 help="Server URL where to create the test database(s)",
56 )
57 group.addoption(
58 "--keep-dbs",
59 action="store_true",
60 dest="keep_dbs",
61 required=False,
62 help="Do not remove the test databases (default: False)",
63 )
66def pytest_configure(config: Config) -> None:
67 """Allows plugins and conftest files to perform initial configuration.
69 More information: https://docs.pytest.org/en/latest/reference/reference.html#std-hook-pytest_configure
71 Args:
72 config: The pytest config object.
74 """
75 # Load server information
76 server_url = make_url(config.getoption("server"))
77 # If password set, treat it as an environment variable that needs to be resolved
78 if server_url.password:
79 server_url = server_url.set(password=os.path.expandvars(server_url.password))
80 config.option.server = server_url.render_as_string(hide_password=False)
83def pytest_report_header(config: Config) -> str:
84 """Presents extra information in the report header.
86 Args:
87 config: Access to configuration values, pluginmanager and plugin hooks.
89 """
90 # Show server information, masking the password value
91 server = config.getoption("server")
92 server = re.sub(r"(//[^/]+:).*(@)", r"\1xxxxxx\2", server)
93 return f"server: {server}"
96@pytest.fixture(name="data_dir", scope="module")
97def local_data_dir(request: FixtureRequest) -> Path:
98 """Returns the path to the test data folder matching the test's name.
100 Args:
101 request: Fixture that provides information of the requesting test function.
103 """
104 return Path(request.module.__file__).with_suffix("")
107@pytest.fixture(name="assert_files")
108def fixture_assert_files() -> Callable[[StrPath, StrPath], None]:
109 """Returns a function that asserts if two text files are equal, or prints their differences."""
111 def _assert_files(result_path: StrPath, expected_path: StrPath) -> None:
112 """Asserts if two files are equal, or prints their differences.
114 Args:
115 result_path: Path to results (test-made) file.
116 expected_path: Path to expected file.
118 """
119 with open(result_path, "r") as result_fh:
120 results = result_fh.readlines()
121 with open(expected_path, "r") as expected_fh:
122 expected = expected_fh.readlines()
123 files_diff = list(
124 unified_diff(
125 results,
126 expected,
127 fromfile=f"Test-made file {Path(result_path).name}",
128 tofile=f"Expected file {Path(expected_path).name}",
129 )
130 )
131 assert_message = f"Test-made and expected files differ\n{' '.join(files_diff)}"
132 assert len(files_diff) == 0, assert_message
134 return _assert_files
137@pytest.fixture(name="db_factory", scope="module")
138def fixture_db_factory(request: FixtureRequest, data_dir: Path) -> Generator[DBFactory, None, None]:
139 """Yields a unit test database factory.
141 Args:
142 request: Fixture that provides information of the requesting test function.
143 data_dir: Fixture that provides the path to the test data folder matching the test's name.
145 """
146 created: dict[str, UnitTestDB] = {}
147 server_url = request.config.getoption("server")
149 def _db_factory(
150 src: StrPath | None, name: str | None = None, metadata: MetaData | None = None
151 ) -> UnitTestDB:
152 """Returns a unit test database.
154 Args:
155 src: Directory path where the test database schema and content files are located, if any.
156 name: Name to give to the new database. See `UnitTestDB` for more information.
157 metadata: SQLAlchemy ORM schema metadata to populate the schema of the test database.
159 """
160 if src is not None:
161 src_path = Path(src)
162 if not src_path.is_absolute():
163 src_path = data_dir / src_path
164 db_key = name if name else src_path.name
165 dump_dir: Path | None = src_path if src_path.exists() else None
166 else:
167 db_key = name if name else "dbkey"
168 dump_dir = None
169 return created.setdefault(
170 db_key, UnitTestDB(server_url, dump_dir=dump_dir, name=name, metadata=metadata)
171 )
173 yield _db_factory
174 # Drop all unit test databases unless the user has requested to keep them
175 if not request.config.getoption("keep_dbs"): 175 ↛ exitline 175 didn't return from function 'fixture_db_factory' because the condition on line 175 was always true
176 for test_db in created.values():
177 test_db.drop()
180@pytest.fixture(scope="module")
181def test_dbs(request: FixtureRequest, db_factory: Callable) -> dict[str, UnitTestDB]:
182 """Returns a dictionary of unit test databases with the database name as key.
184 Requires a list of dictionaries, each with keys `src`, `name` and `metadata`, passed via `request.param`.
185 At minimum either `src` or `name` needs to be provided. See `db_factory()` for details about each key's
186 value.
188 This fixture is a wrapper of `db_factory()` intended to be used via indirect parametrization,
189 for example::
191 from ensembl.core.models import Base
192 @pytest.mark.parametrize(
193 "test_dbs",
194 [
195 [
196 {"src": "core_db"},
197 {"src": "core_db", "name": "human"},
198 {"src": "core_db", "name": "cat", "metadata": Base.metadata},
199 ]
200 ],
201 indirect=True
202 )
203 def test_method(..., test_dbs: dict[str, UnitTestDB], ...):
206 Args:
207 request: Fixture that provides information of the requesting test function.
208 db_factory: Fixture that provides a unit test database factory.
210 """
211 databases = {}
212 for argument in request.param:
213 src = argument.get("src", None)
214 if src is not None:
215 src = Path(src)
216 name = argument.get("name", None)
217 try:
218 key = name or src.name
219 except AttributeError as exc:
220 raise TypeError("Expected at least 'src' or 'name' argument defined") from exc
221 databases[key] = db_factory(src=src, name=name, metadata=argument.get("metadata"))
222 return databases