Coverage for tests/test_cli.py: 30%
140 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-20 01:50 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-20 01:50 -0800
1# This file is part of felis.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22import os
23import shutil
24import tempfile
25import unittest
26from typing import Any
28import yaml
29from sqlalchemy import create_engine
31import felis.tap_schema as tap_schema
32from felis.datamodel import Schema
33from felis.db.utils import DatabaseContext
34from felis.metadata import MetaDataBuilder
35from felis.tests.run_cli import run_cli
37TEST_DIR = os.path.abspath(os.path.dirname(__file__))
38TEST_YAML = os.path.join(TEST_DIR, "data", "test.yml")
39TEST_SALES_YAML = os.path.join(TEST_DIR, "data", "sales.yaml")
42class CliTestCase(unittest.TestCase):
43 """Tests for CLI commands."""
45 def setUp(self) -> None:
46 """Set up a temporary directory for tests."""
47 self.tmpdir = tempfile.mkdtemp(dir=TEST_DIR)
48 self.sqlite_url = f"sqlite:///{self.tmpdir}/db.sqlite3"
49 print(f"Using temporary directory: {self.tmpdir}")
51 def tearDown(self) -> None:
52 """Clean up temporary directory."""
53 shutil.rmtree(self.tmpdir, ignore_errors=True)
55 def test_invalid_command(self) -> None:
56 """Test for invalid command."""
57 run_cli(["invalid"], expect_error=True)
59 def test_help(self) -> None:
60 """Test for help command."""
61 run_cli(["--help"], print_output=True)
63 def test_create(self) -> None:
64 """Test for create command."""
65 run_cli(["create", f"--engine-url={self.sqlite_url}", TEST_YAML])
67 def test_create_with_dry_run(self) -> None:
68 """Test for ``create --dry-run`` command."""
69 run_cli(["create", "--schema-name=main", f"--engine-url={self.sqlite_url}", "--dry-run", TEST_YAML])
71 def test_create_with_ignore_constraints(self) -> None:
72 """Test ``--ignore-constraints`` flag of ``create`` command."""
73 run_cli(
74 [
75 "create",
76 "--schema-name=main",
77 "--ignore-constraints",
78 f"--engine-url={self.sqlite_url}",
79 "--dry-run",
80 TEST_YAML,
81 ]
82 )
84 def test_validate(self) -> None:
85 """Test validate command."""
86 run_cli(["validate", TEST_YAML])
88 def test_validate_with_id_generation(self) -> None:
89 """Test that loading a schema with IDs works if ID generation is
90 enabled. This is the default behavior.
91 """
92 test_yaml = os.path.join(TEST_DIR, "data", "test_id_generation.yaml")
93 run_cli(["--id-generation", "validate", test_yaml])
95 def test_validate_with_id_generation_error(self) -> None:
96 """Test that loading a schema without IDs fails if ID generation is not
97 enabled.
98 """
99 test_yaml = os.path.join(TEST_DIR, "data", "test_id_generation.yaml")
100 run_cli(["--no-id-generation", "validate", test_yaml], expect_error=True)
102 def test_validate_with_extra_checks(self) -> None:
103 """Test schema validation flags."""
104 run_cli(
105 [
106 "validate",
107 "--check-description",
108 "--check-tap-principal",
109 "--check-tap-table-indexes",
110 TEST_YAML,
111 ]
112 )
114 def test_create_with_initialize_and_drop_error(self) -> None:
115 """Test that initialize and drop can't be used together."""
116 run_cli(["create", "--initialize", "--drop", TEST_YAML], expect_error=True)
118 def test_load_tap_schema(self) -> None:
119 """Test load-tap-schema command."""
120 # Create the TAP_SCHEMA database.
121 tap_schema_path = tap_schema.TableManager.get_tap_schema_std_path()
122 run_cli(["--id-generation", "create", f"--engine-url={self.sqlite_url}", tap_schema_path])
124 # Load the TAP_SCHEMA data.
125 run_cli(["load-tap-schema", f"--engine-url={self.sqlite_url}", TEST_YAML])
127 def test_init_tap_schema(self) -> None:
128 """Test init-tap-schema command."""
129 run_cli(["init-tap-schema", f"--engine-url={self.sqlite_url}"])
131 def test_init_tap_schema_mock(self) -> None:
132 """Test init-tap-schema command with a mock URL, which should throw
133 an error, as this is not supported.
134 """
135 run_cli(["init-tap-schema", "sqlite://"], expect_error=True)
137 def test_init_tap_schema_with_extensions(self) -> None:
138 """Test init-tap-schema command with default extensions."""
139 run_cli(
140 [
141 "init-tap-schema",
142 f"--engine-url={self.sqlite_url}",
143 "--extensions",
144 "resource://felis/config/tap_schema/tap_schema_extensions.yaml",
145 ]
146 )
148 def test_init_tap_schema_with_custom_extensions(self) -> None:
149 """Test init-tap-schema command with custom extensions file."""
150 extensions_file = os.path.join(self.tmpdir, "custom_extensions.yaml")
151 extensions_content = """
152 name: TAP_SCHEMA
153 tables:
154 - name: schemas
155 columns:
156 - name: field1
157 datatype: char
158 length: 64
159 description: A custom field
160 """
161 with open(extensions_file, "w") as f:
162 f.write(extensions_content)
164 run_cli(["init-tap-schema", f"--engine-url={self.sqlite_url}", "--extensions", extensions_file])
166 def test_diff(self) -> None:
167 """Test for ``diff`` command."""
168 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
169 test_diff2 = os.path.join(TEST_DIR, "data", "test_diff2.yaml")
171 run_cli(["diff", test_diff1, test_diff2])
173 def test_diff_database(self) -> None:
174 """Test for ``diff`` command with database."""
175 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
176 test_diff2 = os.path.join(TEST_DIR, "data", "test_diff2.yaml")
178 engine = create_engine(self.sqlite_url)
179 metadata_db = MetaDataBuilder(Schema.from_uri(test_diff1), apply_schema_to_metadata=False).build()
180 metadata_db.create_all(engine)
182 run_cli(["diff", f"--engine-url={self.sqlite_url}", test_diff2])
184 def test_diff_alembic(self) -> None:
185 """Test for ``diff`` command with ``--alembic`` comparator option."""
186 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
187 test_diff2 = os.path.join(TEST_DIR, "data", "test_diff2.yaml")
188 run_cli(["diff", "--comparator", "alembic", test_diff1, test_diff2], print_output=True)
190 def test_diff_error(self) -> None:
191 """Test for ``diff`` command with bad arguments."""
192 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
193 run_cli(["diff", test_diff1], expect_error=True)
195 def test_diff_error_on_change(self) -> None:
196 """Test for ``diff`` command with ``--error-on-change`` flag."""
197 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
198 test_diff2 = os.path.join(TEST_DIR, "data", "test_diff2.yaml")
199 run_cli(["diff", "--error-on-change", test_diff1, test_diff2], expect_error=True, print_output=True)
201 def test_dump_yaml(self) -> None:
202 """Test for ``dump`` command with YAML output."""
203 with tempfile.NamedTemporaryFile(delete=True, suffix=".yaml") as temp_file:
204 run_cli(["dump", TEST_YAML, temp_file.name], print_output=True)
206 @classmethod
207 def _check_strip_ids(cls, obj: Any) -> None:
208 """
209 Recursively check that a dict/list structure has no attributes with key
210 '@id'. Raises a ValueError if any '@id' key is found. This is used to
211 check the output of the `--strip-ids` option in the `dump` command.
212 """
213 if isinstance(obj, dict):
214 for k, v in obj.items():
215 if k == "@id":
216 raise ValueError("Found forbidden key '@id'")
217 cls._check_strip_ids(v)
218 elif isinstance(obj, list):
219 for item in obj:
220 cls._check_strip_ids(item)
222 def test_dump_yaml_with_strip_ids(self) -> None:
223 """Test for ``dump`` command with YAML output and stripped IDs."""
224 with tempfile.NamedTemporaryFile(delete=True, suffix=".yaml") as temp_file:
225 run_cli(["dump", "--strip-ids", TEST_YAML, temp_file.name], print_output=True)
226 dumped_data = temp_file.read().decode("utf-8")
227 try:
228 # Load the dumped YAML data to check for '@id' keys.
229 data = yaml.safe_load(dumped_data)
230 self._check_strip_ids(data)
231 except ValueError:
232 self.fail("Dumped YAML contains forbidden key '@id'")
234 def test_dump_json(self) -> None:
235 """Test for ``dump`` command with JSON output."""
236 with tempfile.NamedTemporaryFile(delete=True, suffix=".json") as temp_file:
237 run_cli(["dump", TEST_YAML, temp_file.name], print_output=True)
239 def test_dump_json_with_strip_ids(self) -> None:
240 """Test for ``dump`` command with JSON output."""
241 with tempfile.NamedTemporaryFile(delete=True, suffix=".json") as temp_file:
242 run_cli(["dump", "--strip-ids", TEST_YAML, temp_file.name], print_output=True)
243 dumped_data = temp_file.read().decode("utf-8")
244 try:
245 # Load the dumped YAML data to check for '@id' keys.
246 data = yaml.safe_load(dumped_data)
247 self._check_strip_ids(data)
248 except ValueError:
249 self.fail("Dumped YAML contains forbidden key '@id'")
251 def test_dump_with_invalid_file_extension_error(self) -> None:
252 """Test for ``dump`` command with JSON output."""
253 run_cli(["dump", TEST_YAML, "out.bad"], expect_error=True)
255 def test_create_and_drop_indexes(self) -> None:
256 """Test creating and dropping indexes using CLI commands with
257 SQLite.
258 """
259 # Load the schema to get expected indexes
260 schema = Schema.from_uri(TEST_SALES_YAML)
261 md_with_indexes = MetaDataBuilder(schema, skip_indexes=False).build()
263 engine = create_engine(self.sqlite_url)
265 def check_indexes_exist(should_exist: bool, message: str) -> None:
266 """Check if indexes exist or don't exist in the database."""
267 with engine.connect() as conn:
268 for table in md_with_indexes.tables.values():
269 for index in table.indexes:
270 if index.name is not None:
271 exists = DatabaseContext._index_exists(conn, table, index)
272 if should_exist:
273 self.assertTrue(
274 exists,
275 f"Index '{index.name}' {message}",
276 )
277 else:
278 self.assertFalse(
279 exists,
280 f"Index '{index.name}' {message}",
281 )
283 # Create database without indexes
284 run_cli(["create", "--skip-indexes", f"--engine-url={self.sqlite_url}", TEST_SALES_YAML])
286 # Check that indexes don't exist yet
287 check_indexes_exist(False, "should not exist after create with --skip-indexes")
289 # Create the indexes using CLI
290 run_cli(
291 ["create-indexes", f"--engine-url={self.sqlite_url}", TEST_SALES_YAML, "--schema-name", "main"]
292 )
294 # Check that indexes now exist
295 check_indexes_exist(True, "should exist after create-indexes")
297 # Create the indexes again; should not cause an error
298 run_cli(
299 ["create-indexes", f"--engine-url={self.sqlite_url}", TEST_SALES_YAML, "--schema-name", "main"]
300 )
302 # Drop the indexes using CLI
303 run_cli(["drop-indexes", f"--engine-url={self.sqlite_url}", TEST_SALES_YAML, "--schema-name", "main"])
305 # Check that indexes were dropped
306 check_indexes_exist(False, "should not exist after drop-indexes")
309if __name__ == "__main__": 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true
310 unittest.main()