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

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.""" 

16 

17from __future__ import annotations 

18 

19from difflib import unified_diff 

20import os 

21from pathlib import Path 

22import re 

23from typing import Callable, Generator, TypeAlias 

24 

25import pytest 

26from pytest import Config, FixtureRequest, Parser 

27from sqlalchemy.engine import make_url 

28from sqlalchemy.schema import MetaData 

29 

30from ensembl.utils import StrPath 

31from ensembl.utils.database import UnitTestDB 

32 

33DBFactory: TypeAlias = Callable[[StrPath | None, str | None, MetaData | None], UnitTestDB] 

34 

35 

36def pytest_addoption(parser: Parser) -> None: 

37 """Registers argparse-style options for Ensembl's unit testing. 

38 

39 `Pytest initialisation hook 

40 <https://docs.pytest.org/en/latest/reference.html#_pytest.hookspec.pytest_addoption>`_. 

41 

42 Args: 

43 parser: Parser for command line arguments and ini-file values. 

44 

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 ) 

64 

65 

66def pytest_configure(config: Config) -> None: 

67 """Allows plugins and conftest files to perform initial configuration. 

68 

69 More information: https://docs.pytest.org/en/latest/reference/reference.html#std-hook-pytest_configure 

70 

71 Args: 

72 config: The pytest config object. 

73 

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) 

81 

82 

83def pytest_report_header(config: Config) -> str: 

84 """Presents extra information in the report header. 

85 

86 Args: 

87 config: Access to configuration values, pluginmanager and plugin hooks. 

88 

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}" 

94 

95 

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. 

99 

100 Args: 

101 request: Fixture that provides information of the requesting test function. 

102 

103 """ 

104 return Path(request.module.__file__).with_suffix("") 

105 

106 

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.""" 

110 

111 def _assert_files(result_path: StrPath, expected_path: StrPath) -> None: 

112 """Asserts if two files are equal, or prints their differences. 

113 

114 Args: 

115 result_path: Path to results (test-made) file. 

116 expected_path: Path to expected file. 

117 

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 

133 

134 return _assert_files 

135 

136 

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. 

140 

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. 

144 

145 """ 

146 created: dict[str, UnitTestDB] = {} 

147 server_url = request.config.getoption("server") 

148 

149 def _db_factory( 

150 src: StrPath | None, name: str | None = None, metadata: MetaData | None = None 

151 ) -> UnitTestDB: 

152 """Returns a unit test database. 

153 

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. 

158 

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 ) 

172 

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() 

178 

179 

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. 

183 

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. 

187 

188 This fixture is a wrapper of `db_factory()` intended to be used via indirect parametrization, 

189 for example:: 

190 

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], ...): 

204 

205 

206 Args: 

207 request: Fixture that provides information of the requesting test function. 

208 db_factory: Fixture that provides a unit test database factory. 

209 

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