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

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 

33 

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

35 

36 

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

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

39 

40 `Pytest initialisation hook 

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

42 

43 Args: 

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

45 

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 ) 

65 

66 

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

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

69 

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

71 

72 Args: 

73 config: The pytest config object. 

74 

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) 

82 

83 

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

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

86 

87 Args: 

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

89 

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

95 

96 

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. 

100 

101 Args: 

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

103 

104 """ 

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

106 

107 

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

111 

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

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

114 

115 Args: 

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

117 expected_path: Path to expected file. 

118 

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 

134 

135 return _assert_files 

136 

137 

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. 

141 

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. 

145 

146 """ 

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

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

149 

150 def _db_factory( 

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

152 ) -> UnitTestDB: 

153 """Returns a unit test database. 

154 

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. 

159 

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 ) 

173 

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

179 

180 

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. 

184 

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. 

188 

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

190 for example:: 

191 

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

205 

206 

207 Args: 

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

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

210 

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