Coverage for tests/test_cli.py: 30%
132 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-19 01:33 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-19 01:33 -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_diff(self) -> None:
138 """Test for ``diff`` command."""
139 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
140 test_diff2 = os.path.join(TEST_DIR, "data", "test_diff2.yaml")
142 run_cli(["diff", test_diff1, test_diff2])
144 def test_diff_database(self) -> None:
145 """Test for ``diff`` command with database."""
146 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
147 test_diff2 = os.path.join(TEST_DIR, "data", "test_diff2.yaml")
149 engine = create_engine(self.sqlite_url)
150 metadata_db = MetaDataBuilder(Schema.from_uri(test_diff1), apply_schema_to_metadata=False).build()
151 metadata_db.create_all(engine)
153 run_cli(["diff", f"--engine-url={self.sqlite_url}", test_diff2])
155 def test_diff_alembic(self) -> None:
156 """Test for ``diff`` command with ``--alembic`` comparator option."""
157 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
158 test_diff2 = os.path.join(TEST_DIR, "data", "test_diff2.yaml")
159 run_cli(["diff", "--comparator", "alembic", test_diff1, test_diff2], print_output=True)
161 def test_diff_error(self) -> None:
162 """Test for ``diff`` command with bad arguments."""
163 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
164 run_cli(["diff", test_diff1], expect_error=True)
166 def test_diff_error_on_change(self) -> None:
167 """Test for ``diff`` command with ``--error-on-change`` flag."""
168 test_diff1 = os.path.join(TEST_DIR, "data", "test_diff1.yaml")
169 test_diff2 = os.path.join(TEST_DIR, "data", "test_diff2.yaml")
170 run_cli(["diff", "--error-on-change", test_diff1, test_diff2], expect_error=True, print_output=True)
172 def test_dump_yaml(self) -> None:
173 """Test for ``dump`` command with YAML output."""
174 with tempfile.NamedTemporaryFile(delete=True, suffix=".yaml") as temp_file:
175 run_cli(["dump", TEST_YAML, temp_file.name], print_output=True)
177 @classmethod
178 def _check_strip_ids(cls, obj: Any) -> None:
179 """
180 Recursively check that a dict/list structure has no attributes with key
181 '@id'. Raises a ValueError if any '@id' key is found. This is used to
182 check the output of the `--strip-ids` option in the `dump` command.
183 """
184 if isinstance(obj, dict):
185 for k, v in obj.items():
186 if k == "@id":
187 raise ValueError("Found forbidden key '@id'")
188 cls._check_strip_ids(v)
189 elif isinstance(obj, list):
190 for item in obj:
191 cls._check_strip_ids(item)
193 def test_dump_yaml_with_strip_ids(self) -> None:
194 """Test for ``dump`` command with YAML output and stripped IDs."""
195 with tempfile.NamedTemporaryFile(delete=True, suffix=".yaml") as temp_file:
196 run_cli(["dump", "--strip-ids", TEST_YAML, temp_file.name], print_output=True)
197 dumped_data = temp_file.read().decode("utf-8")
198 try:
199 # Load the dumped YAML data to check for '@id' keys.
200 data = yaml.safe_load(dumped_data)
201 self._check_strip_ids(data)
202 except ValueError:
203 self.fail("Dumped YAML contains forbidden key '@id'")
205 def test_dump_json(self) -> None:
206 """Test for ``dump`` command with JSON output."""
207 with tempfile.NamedTemporaryFile(delete=True, suffix=".json") as temp_file:
208 run_cli(["dump", TEST_YAML, temp_file.name], print_output=True)
210 def test_dump_json_with_strip_ids(self) -> None:
211 """Test for ``dump`` command with JSON output."""
212 with tempfile.NamedTemporaryFile(delete=True, suffix=".json") as temp_file:
213 run_cli(["dump", "--strip-ids", TEST_YAML, temp_file.name], print_output=True)
214 dumped_data = temp_file.read().decode("utf-8")
215 try:
216 # Load the dumped YAML data to check for '@id' keys.
217 data = yaml.safe_load(dumped_data)
218 self._check_strip_ids(data)
219 except ValueError:
220 self.fail("Dumped YAML contains forbidden key '@id'")
222 def test_dump_with_invalid_file_extension_error(self) -> None:
223 """Test for ``dump`` command with JSON output."""
224 run_cli(["dump", TEST_YAML, "out.bad"], expect_error=True)
226 def test_create_and_drop_indexes(self) -> None:
227 """Test creating and dropping indexes using CLI commands with
228 SQLite.
229 """
230 # Load the schema to get expected indexes
231 schema = Schema.from_uri(TEST_SALES_YAML)
232 md_with_indexes = MetaDataBuilder(schema, skip_indexes=False).build()
234 engine = create_engine(self.sqlite_url)
236 def check_indexes_exist(should_exist: bool, message: str) -> None:
237 """Check if indexes exist or don't exist in the database."""
238 with engine.connect() as conn:
239 for table in md_with_indexes.tables.values():
240 for index in table.indexes:
241 if index.name is not None:
242 exists = DatabaseContext._index_exists(conn, table, index)
243 if should_exist:
244 self.assertTrue(
245 exists,
246 f"Index '{index.name}' {message}",
247 )
248 else:
249 self.assertFalse(
250 exists,
251 f"Index '{index.name}' {message}",
252 )
254 # Create database without indexes
255 run_cli(["create", "--skip-indexes", f"--engine-url={self.sqlite_url}", TEST_SALES_YAML])
257 # Check that indexes don't exist yet
258 check_indexes_exist(False, "should not exist after create with --skip-indexes")
260 # Create the indexes using CLI
261 run_cli(
262 ["create-indexes", f"--engine-url={self.sqlite_url}", TEST_SALES_YAML, "--schema-name", "main"]
263 )
265 # Check that indexes now exist
266 check_indexes_exist(True, "should exist after create-indexes")
268 # Create the indexes again; should not cause an error
269 run_cli(
270 ["create-indexes", f"--engine-url={self.sqlite_url}", TEST_SALES_YAML, "--schema-name", "main"]
271 )
273 # Drop the indexes using CLI
274 run_cli(["drop-indexes", f"--engine-url={self.sqlite_url}", TEST_SALES_YAML, "--schema-name", "main"])
276 # Check that indexes were dropped
277 check_indexes_exist(False, "should not exist after drop-indexes")
280if __name__ == "__main__": 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 unittest.main()