lsst-felis 28.2024.4800__tar.gz → 28.2025.402__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lsst-felis might be problematic. Click here for more details.

Files changed (43) hide show
  1. {lsst_felis-28.2024.4800/python/lsst_felis.egg-info → lsst_felis-28.2025.402}/PKG-INFO +12 -8
  2. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/README.rst +0 -2
  3. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/pyproject.toml +10 -6
  4. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/__init__.py +4 -0
  5. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/cli.py +60 -0
  6. lsst_felis-28.2025.402/python/felis/db/schema.py +62 -0
  7. lsst_felis-28.2025.402/python/felis/diff.py +229 -0
  8. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/metadata.py +0 -7
  9. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/tap_schema.py +1 -3
  10. lsst_felis-28.2025.402/python/felis/version.py +2 -0
  11. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402/python/lsst_felis.egg-info}/PKG-INFO +12 -8
  12. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/SOURCES.txt +4 -1
  13. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/requires.txt +9 -6
  14. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_cli.py +57 -0
  15. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_datamodel.py +8 -8
  16. lsst_felis-28.2025.402/tests/test_db.py +79 -0
  17. lsst_felis-28.2025.402/tests/test_diff.py +275 -0
  18. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_metadata.py +1 -1
  19. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_tap_schema.py +18 -9
  20. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_tap_schema_postgres.py +9 -5
  21. lsst_felis-28.2024.4800/python/felis/tests/utils.py +0 -122
  22. lsst_felis-28.2024.4800/python/felis/version.py +0 -2
  23. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/COPYRIGHT +0 -0
  24. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/LICENSE +0 -0
  25. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/datamodel.py +0 -0
  26. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/__init__.py +0 -0
  27. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/dialects.py +0 -0
  28. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/sqltypes.py +0 -0
  29. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/utils.py +0 -0
  30. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/variants.py +0 -0
  31. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/py.typed +0 -0
  32. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/schemas/tap_schema_std.yaml +0 -0
  33. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/tap.py +0 -0
  34. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/tests/__init__.py +0 -0
  35. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/tests/postgresql.py +0 -0
  36. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/types.py +0 -0
  37. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
  38. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/entry_points.txt +0 -0
  39. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/top_level.txt +0 -0
  40. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/zip-safe +0 -0
  41. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/setup.cfg +0 -0
  42. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_postgres.py +0 -0
  43. {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_tap.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: lsst-felis
3
- Version: 28.2024.4800
3
+ Version: 28.2025.402
4
4
  Summary: A vocabulary for describing catalogs and acting on those descriptions
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License: GNU General Public License v3 or later (GPLv3+)
@@ -13,18 +13,22 @@ Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
16
17
  Classifier: Topic :: Scientific/Engineering :: Astronomy
17
18
  Requires-Python: >=3.11.0
18
19
  Description-Content-Type: text/markdown
19
20
  License-File: COPYRIGHT
20
21
  License-File: LICENSE
21
- Requires-Dist: astropy>=4
22
- Requires-Dist: sqlalchemy>=1.4
23
- Requires-Dist: click>=7
24
- Requires-Dist: pyyaml>=6
25
- Requires-Dist: pydantic<3,>=2
26
- Requires-Dist: lsst-utils
22
+ Requires-Dist: alembic
23
+ Requires-Dist: astropy
24
+ Requires-Dist: click
25
+ Requires-Dist: deepdiff
27
26
  Requires-Dist: lsst-resources
27
+ Requires-Dist: lsst-utils
28
+ Requires-Dist: numpy
29
+ Requires-Dist: pydantic<3,>=2
30
+ Requires-Dist: pyyaml
31
+ Requires-Dist: sqlalchemy
28
32
  Provides-Extra: test
29
33
  Requires-Dist: pytest>=3.2; extra == "test"
30
34
  Provides-Extra: dev
@@ -19,8 +19,6 @@ Felis
19
19
  :target: https://github.com/lsst/felis/tags
20
20
  :alt: Latest Tag
21
21
 
22
- YAML Schema Definition Language for Databases
23
-
24
22
  Overview
25
23
  --------
26
24
 
@@ -17,17 +17,21 @@ classifiers = [
17
17
  "Programming Language :: Python :: 3",
18
18
  "Programming Language :: Python :: 3.11",
19
19
  "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
20
21
  "Topic :: Scientific/Engineering :: Astronomy"
21
22
  ]
22
23
  keywords = ["lsst"]
23
24
  dependencies = [
24
- "astropy >= 4",
25
- "sqlalchemy >= 1.4",
26
- "click >= 7",
27
- "pyyaml >= 6",
28
- "pydantic >= 2, < 3",
25
+ "alembic",
26
+ "astropy",
27
+ "click",
28
+ "deepdiff",
29
+ "lsst-resources",
29
30
  "lsst-utils",
30
- "lsst-resources"
31
+ "numpy",
32
+ "pydantic >=2,<3",
33
+ "pyyaml",
34
+ "sqlalchemy"
31
35
  ]
32
36
  requires-python = ">=3.11.0"
33
37
  dynamic = ["version"]
@@ -19,4 +19,8 @@
19
19
  # You should have received a copy of the GNU General Public License
20
20
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
21
 
22
+ from .datamodel import Schema
23
+ from .db.schema import create_database
24
+ from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
25
+ from .metadata import MetaDataBuilder
22
26
  from .version import *
@@ -34,7 +34,9 @@ from sqlalchemy.engine.mock import MockConnection, create_mock_engine
34
34
 
35
35
  from . import __version__
36
36
  from .datamodel import Schema
37
+ from .db.schema import create_database
37
38
  from .db.utils import DatabaseContext, is_mock_url
39
+ from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
38
40
  from .metadata import MetaDataBuilder
39
41
  from .tap import Tap11Base, TapLoadingVisitor, init_tables
40
42
  from .tap_schema import DataLoader, TableManager
@@ -493,5 +495,63 @@ def validate(
493
495
  raise click.exceptions.Exit(rc)
494
496
 
495
497
 
498
+ @cli.command(
499
+ "diff",
500
+ help="""
501
+ Compare two schemas or a schema and a database for changes
502
+
503
+ Examples:
504
+
505
+ felis diff schema1.yaml schema2.yaml
506
+
507
+ felis diff -c alembic schema1.yaml schema2.yaml
508
+
509
+ felis diff --engine-url sqlite:///test.db schema.yaml
510
+ """,
511
+ )
512
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
513
+ @click.option(
514
+ "-c",
515
+ "--comparator",
516
+ type=click.Choice(["alembic", "deepdiff"], case_sensitive=False),
517
+ help="Comparator to use for schema comparison",
518
+ default="deepdiff",
519
+ )
520
+ @click.option("-E", "--error-on-change", is_flag=True, help="Exit with error code if schemas are different")
521
+ @click.argument("files", nargs=-1, type=click.File())
522
+ @click.pass_context
523
+ def diff(
524
+ ctx: click.Context,
525
+ engine_url: str | None,
526
+ comparator: str,
527
+ error_on_change: bool,
528
+ files: Iterable[IO[str]],
529
+ ) -> None:
530
+ schemas = [
531
+ Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in files
532
+ ]
533
+
534
+ diff: SchemaDiff
535
+ if len(schemas) == 2 and engine_url is None:
536
+ if comparator == "alembic":
537
+ db_context = create_database(schemas[0])
538
+ assert isinstance(db_context.engine, Engine)
539
+ diff = DatabaseDiff(schemas[1], db_context.engine)
540
+ else:
541
+ diff = FormattedSchemaDiff(schemas[0], schemas[1])
542
+ elif len(schemas) == 1 and engine_url is not None:
543
+ engine = create_engine(engine_url)
544
+ diff = DatabaseDiff(schemas[0], engine)
545
+ else:
546
+ raise click.ClickException(
547
+ "Invalid arguments - provide two schemas or a schema and a database engine URL"
548
+ )
549
+
550
+ diff.print()
551
+
552
+ if diff.has_changes and error_on_change:
553
+ raise click.ClickException("Schema was changed")
554
+
555
+
496
556
  if __name__ == "__main__":
497
557
  cli()
@@ -0,0 +1,62 @@
1
+ """Database utilities for Felis schemas."""
2
+
3
+ # This file is part of felis.
4
+ #
5
+ # Developed for the LSST Data Management System.
6
+ # This product includes software developed by the LSST Project
7
+ # (https://www.lsst.org).
8
+ # See the COPYRIGHT file at the top-level directory of this distribution
9
+ # for details of code ownership.
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ from sqlalchemy import Engine, create_engine
25
+
26
+ from ..datamodel import Schema
27
+ from ..metadata import MetaDataBuilder
28
+ from .utils import DatabaseContext
29
+
30
+ __all__ = ["create_database"]
31
+
32
+
33
+ def create_database(schema: Schema, engine_or_url_str: Engine | str | None = None) -> DatabaseContext:
34
+ """
35
+ Create a database from the specified `Schema`.
36
+
37
+ Parameters
38
+ ----------
39
+ schema
40
+ The schema to create.
41
+ engine_or_url_str
42
+ The SQLAlchemy engine or URL to use for database creation.
43
+ If None, an in-memory SQLite database will be created.
44
+
45
+ Returns
46
+ -------
47
+ `DatabaseContext`
48
+ The database context object.
49
+ """
50
+ if engine_or_url_str is not None:
51
+ engine = (
52
+ engine_or_url_str if isinstance(engine_or_url_str, Engine) else create_engine(engine_or_url_str)
53
+ )
54
+ else:
55
+ engine = create_engine("sqlite:///:memory:")
56
+ metadata = MetaDataBuilder(
57
+ schema, apply_schema_to_metadata=False if engine.url.drivername == "sqlite" else True
58
+ ).build()
59
+ ctx = DatabaseContext(metadata, engine)
60
+ ctx.initialize()
61
+ ctx.create_all()
62
+ return ctx
@@ -0,0 +1,229 @@
1
+ """Compare schemas and print the differences."""
2
+
3
+ # This file is part of felis.
4
+ #
5
+ # Developed for the LSST Data Management System.
6
+ # This product includes software developed by the LSST Project
7
+ # (https://www.lsst.org).
8
+ # See the COPYRIGHT file at the top-level directory of this distribution
9
+ # for details of code ownership.
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ import logging
25
+ import pprint
26
+ import re
27
+ from collections.abc import Callable
28
+ from typing import Any
29
+
30
+ from alembic.autogenerate import compare_metadata
31
+ from alembic.migration import MigrationContext
32
+ from deepdiff.diff import DeepDiff
33
+ from sqlalchemy import Engine, MetaData
34
+
35
+ from .datamodel import Schema
36
+ from .metadata import MetaDataBuilder
37
+
38
+ __all__ = ["SchemaDiff", "DatabaseDiff"]
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Change alembic log level to avoid unnecessary output
43
+ logging.getLogger("alembic").setLevel(logging.WARNING)
44
+
45
+
46
+ class SchemaDiff:
47
+ """
48
+ Compare two schemas using DeepDiff and print the differences.
49
+
50
+ Parameters
51
+ ----------
52
+ schema1
53
+ The first schema to compare.
54
+ schema2
55
+ The second schema to compare.
56
+ """
57
+
58
+ def __init__(self, schema1: Schema, schema2: Schema):
59
+ self.dict1 = schema1.model_dump(exclude_none=True)
60
+ self.dict2 = schema2.model_dump(exclude_none=True)
61
+ self.diff = DeepDiff(self.dict1, self.dict2, ignore_order=True)
62
+
63
+ def print(self) -> None:
64
+ """
65
+ Print the differences between the two schemas.
66
+ """
67
+ pprint.pprint(self.diff)
68
+
69
+ @property
70
+ def has_changes(self) -> bool:
71
+ """
72
+ Check if there are any differences between the two schemas.
73
+
74
+ Returns
75
+ -------
76
+ bool
77
+ True if there are differences, False otherwise.
78
+ """
79
+ return len(self.diff) > 0
80
+
81
+
82
+ class FormattedSchemaDiff(SchemaDiff):
83
+ """
84
+ Compare two schemas using DeepDiff and print the differences using a
85
+ customized output format.
86
+
87
+ Parameters
88
+ ----------
89
+ schema1
90
+ The first schema to compare.
91
+ schema2
92
+ The second schema to compare.
93
+ """
94
+
95
+ def __init__(self, schema1: Schema, schema2: Schema):
96
+ super().__init__(schema1, schema2)
97
+
98
+ def print(self) -> None:
99
+ """
100
+ Print the differences between the two schemas using a custom format.
101
+ """
102
+ handlers: dict[str, Callable[[dict[str, Any]], None]] = {
103
+ "values_changed": self._handle_values_changed,
104
+ "iterable_item_added": self._handle_iterable_item_added,
105
+ "iterable_item_removed": self._handle_iterable_item_removed,
106
+ "dictionary_item_added": self._handle_dictionary_item_added,
107
+ "dictionary_item_removed": self._handle_dictionary_item_removed,
108
+ }
109
+
110
+ for change_type, handler in handlers.items():
111
+ if change_type in self.diff:
112
+ handler(self.diff[change_type])
113
+
114
+ def _print_header(self, id_dict: dict[str, Any], keys: list[int | str]) -> None:
115
+ id = self._get_id(id_dict, keys)
116
+ print(f"{id} @ {self._get_key_display(keys)}")
117
+
118
+ def _handle_values_changed(self, changes: dict[str, Any]) -> None:
119
+ for key in changes:
120
+ keys = self._parse_deepdiff_path(key)
121
+ value1 = self._get_value_from_keys(self.dict1, keys)
122
+ value2 = self._get_value_from_keys(self.dict2, keys)
123
+ self._print_header(self.dict1, keys)
124
+ print(f"- {value1}")
125
+ print(f"+ {value2}")
126
+
127
+ def _handle_iterable_item_added(self, changes: dict[str, Any]) -> None:
128
+ for key in changes:
129
+ keys = self._parse_deepdiff_path(key)
130
+ value = self._get_value_from_keys(self.dict2, keys)
131
+ self._print_header(self.dict2, keys)
132
+ print(f"+ {value}")
133
+
134
+ def _handle_iterable_item_removed(self, changes: dict[str, Any]) -> None:
135
+ for key in changes:
136
+ keys = self._parse_deepdiff_path(key)
137
+ value = self._get_value_from_keys(self.dict1, keys)
138
+ self._print_header(self.dict1, keys)
139
+ print(f"- {value}")
140
+
141
+ def _handle_dictionary_item_added(self, changes: dict[str, Any]) -> None:
142
+ for key in changes:
143
+ keys = self._parse_deepdiff_path(key)
144
+ value = self._get_value_from_keys(self.dict2, keys)
145
+ self._print_header(self.dict2, keys)
146
+ print(f"+ {value}")
147
+
148
+ def _handle_dictionary_item_removed(self, changes: dict[str, Any]) -> None:
149
+ for key in changes:
150
+ keys = self._parse_deepdiff_path(key)
151
+ value = self._get_value_from_keys(self.dict1, keys)
152
+ self._print_header(self.dict1, keys)
153
+ print(f"- {value}")
154
+
155
+ @staticmethod
156
+ def _get_id(values: dict, keys: list[str | int]) -> str:
157
+ value = values
158
+ last_id = None
159
+
160
+ for key in keys:
161
+ if isinstance(value, dict) and "id" in value:
162
+ last_id = value["id"]
163
+ value = value[key]
164
+
165
+ if isinstance(value, dict) and "id" in value:
166
+ last_id = value["id"]
167
+
168
+ if last_id is not None:
169
+ return last_id
170
+ else:
171
+ raise ValueError("No 'id' found in the specified path")
172
+
173
+ @staticmethod
174
+ def _get_key_display(keys: list[str | int]) -> str:
175
+ return ".".join(str(k) for k in keys)
176
+
177
+ @staticmethod
178
+ def _parse_deepdiff_path(path: str) -> list[str | int]:
179
+ if path.startswith("root"):
180
+ path = path[4:]
181
+
182
+ pattern = re.compile(r"\['([^']+)'\]|\[(\d+)\]")
183
+ matches = pattern.findall(path)
184
+
185
+ keys = []
186
+ for match in matches:
187
+ if match[0]: # String key
188
+ keys.append(match[0])
189
+ elif match[1]: # Integer index
190
+ keys.append(int(match[1]))
191
+
192
+ return keys
193
+
194
+ @staticmethod
195
+ def _get_value_from_keys(data: dict, keys: list[str | int]) -> Any:
196
+ value = data
197
+ for key in keys:
198
+ value = value[key]
199
+ return value
200
+
201
+
202
+ class DatabaseDiff(SchemaDiff):
203
+ """
204
+ Compare a schema with a database and print the differences.
205
+
206
+ Parameters
207
+ ----------
208
+ schema
209
+ The schema to compare.
210
+ engine
211
+ The database engine to compare with.
212
+ """
213
+
214
+ def __init__(self, schema: Schema, engine: Engine):
215
+ db_metadata = MetaData()
216
+ with engine.connect() as connection:
217
+ db_metadata.reflect(bind=connection)
218
+ mc = MigrationContext.configure(
219
+ connection, opts={"compare_type": True, "target_metadata": db_metadata}
220
+ )
221
+ schema_metadata = MetaDataBuilder(schema, apply_schema_to_metadata=False).build()
222
+ self.diff = compare_metadata(mc, schema_metadata)
223
+
224
+ def print(self) -> None:
225
+ """
226
+ Print the differences between the schema and the database.
227
+ """
228
+ if self.has_changes:
229
+ pprint.pprint(self.diff)
@@ -125,8 +125,6 @@ class MetaDataBuilder:
125
125
  The schema object from which to build the SQLAlchemy metadata.
126
126
  apply_schema_to_metadata
127
127
  Whether to apply the schema name to the metadata object.
128
- apply_schema_to_tables
129
- Whether to apply the schema name to the tables.
130
128
  ignore_constraints
131
129
  Whether to ignore constraints when building the metadata.
132
130
  """
@@ -135,18 +133,14 @@ class MetaDataBuilder:
135
133
  self,
136
134
  schema: Schema,
137
135
  apply_schema_to_metadata: bool = True,
138
- apply_schema_to_tables: bool = True,
139
136
  ignore_constraints: bool = False,
140
137
  ) -> None:
141
138
  """Initialize the metadata builder."""
142
139
  self.schema = schema
143
140
  if not apply_schema_to_metadata:
144
141
  logger.debug("Schema name will not be applied to metadata")
145
- if not apply_schema_to_tables:
146
- logger.debug("Schema name will not be applied to tables")
147
142
  self.metadata = MetaData(schema=schema.name if apply_schema_to_metadata else None)
148
143
  self._objects: dict[str, Any] = {}
149
- self.apply_schema_to_tables = apply_schema_to_tables
150
144
  self.ignore_constraints = ignore_constraints
151
145
 
152
146
  def build(self) -> MetaData:
@@ -235,7 +229,6 @@ class MetaDataBuilder:
235
229
  self.metadata,
236
230
  *columns,
237
231
  comment=description,
238
- schema=self.schema.name if self.apply_schema_to_tables else None,
239
232
  **optargs, # type: ignore[arg-type]
240
233
  )
241
234
 
@@ -131,9 +131,7 @@ class TableManager:
131
131
  self.schema_name = self.schema.name
132
132
 
133
133
  self._metadata = MetaDataBuilder(
134
- self.schema,
135
- apply_schema_to_metadata=self.apply_schema_to_metadata,
136
- apply_schema_to_tables=self.apply_schema_to_metadata,
134
+ self.schema, apply_schema_to_metadata=self.apply_schema_to_metadata
137
135
  ).build()
138
136
 
139
137
  logger.debug("Loaded TAP_SCHEMA '%s' from YAML resource", self.schema_name)
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "28.2025.402"
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: lsst-felis
3
- Version: 28.2024.4800
3
+ Version: 28.2025.402
4
4
  Summary: A vocabulary for describing catalogs and acting on those descriptions
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License: GNU General Public License v3 or later (GPLv3+)
@@ -13,18 +13,22 @@ Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
16
17
  Classifier: Topic :: Scientific/Engineering :: Astronomy
17
18
  Requires-Python: >=3.11.0
18
19
  Description-Content-Type: text/markdown
19
20
  License-File: COPYRIGHT
20
21
  License-File: LICENSE
21
- Requires-Dist: astropy>=4
22
- Requires-Dist: sqlalchemy>=1.4
23
- Requires-Dist: click>=7
24
- Requires-Dist: pyyaml>=6
25
- Requires-Dist: pydantic<3,>=2
26
- Requires-Dist: lsst-utils
22
+ Requires-Dist: alembic
23
+ Requires-Dist: astropy
24
+ Requires-Dist: click
25
+ Requires-Dist: deepdiff
27
26
  Requires-Dist: lsst-resources
27
+ Requires-Dist: lsst-utils
28
+ Requires-Dist: numpy
29
+ Requires-Dist: pydantic<3,>=2
30
+ Requires-Dist: pyyaml
31
+ Requires-Dist: sqlalchemy
28
32
  Provides-Extra: test
29
33
  Requires-Dist: pytest>=3.2; extra == "test"
30
34
  Provides-Extra: dev
@@ -6,6 +6,7 @@ setup.cfg
6
6
  python/felis/__init__.py
7
7
  python/felis/cli.py
8
8
  python/felis/datamodel.py
9
+ python/felis/diff.py
9
10
  python/felis/metadata.py
10
11
  python/felis/py.typed
11
12
  python/felis/tap.py
@@ -14,13 +15,13 @@ python/felis/types.py
14
15
  python/felis/version.py
15
16
  python/felis/db/__init__.py
16
17
  python/felis/db/dialects.py
18
+ python/felis/db/schema.py
17
19
  python/felis/db/sqltypes.py
18
20
  python/felis/db/utils.py
19
21
  python/felis/db/variants.py
20
22
  python/felis/schemas/tap_schema_std.yaml
21
23
  python/felis/tests/__init__.py
22
24
  python/felis/tests/postgresql.py
23
- python/felis/tests/utils.py
24
25
  python/lsst_felis.egg-info/PKG-INFO
25
26
  python/lsst_felis.egg-info/SOURCES.txt
26
27
  python/lsst_felis.egg-info/dependency_links.txt
@@ -30,6 +31,8 @@ python/lsst_felis.egg-info/top_level.txt
30
31
  python/lsst_felis.egg-info/zip-safe
31
32
  tests/test_cli.py
32
33
  tests/test_datamodel.py
34
+ tests/test_db.py
35
+ tests/test_diff.py
33
36
  tests/test_metadata.py
34
37
  tests/test_postgres.py
35
38
  tests/test_tap.py
@@ -1,10 +1,13 @@
1
- astropy>=4
2
- sqlalchemy>=1.4
3
- click>=7
4
- pyyaml>=6
5
- pydantic<3,>=2
6
- lsst-utils
1
+ alembic
2
+ astropy
3
+ click
4
+ deepdiff
7
5
  lsst-resources
6
+ lsst-utils
7
+ numpy
8
+ pydantic<3,>=2
9
+ pyyaml
10
+ sqlalchemy
8
11
 
9
12
  [dev]
10
13
  documenteer[guide]<2
@@ -25,10 +25,13 @@ import tempfile
25
25
  import unittest
26
26
 
27
27
  from click.testing import CliRunner
28
+ from sqlalchemy import create_engine
28
29
 
29
30
  import felis.tap_schema as tap_schema
30
31
  from felis.cli import cli
32
+ from felis.datamodel import Schema
31
33
  from felis.db.dialects import get_supported_dialects
34
+ from felis.metadata import MetaDataBuilder
32
35
 
33
36
  TESTDIR = os.path.abspath(os.path.dirname(__file__))
34
37
  TEST_YAML = os.path.join(TESTDIR, "data", "test.yml")
@@ -188,6 +191,60 @@ class CliTestCase(unittest.TestCase):
188
191
  )
189
192
  self.assertEqual(result.exit_code, 0)
190
193
 
194
+ def test_diff(self) -> None:
195
+ """Test for ``diff`` command."""
196
+ test_diff1 = os.path.join(TESTDIR, "data", "test_diff1.yaml")
197
+ test_diff2 = os.path.join(TESTDIR, "data", "test_diff2.yaml")
198
+
199
+ runner = CliRunner()
200
+ result = runner.invoke(cli, ["diff", test_diff1, test_diff2], catch_exceptions=False)
201
+ self.assertEqual(result.exit_code, 0)
202
+
203
+ def test_diff_database(self) -> None:
204
+ """Test for ``diff`` command with database."""
205
+ test_diff1 = os.path.join(TESTDIR, "data", "test_diff1.yaml")
206
+ test_diff2 = os.path.join(TESTDIR, "data", "test_diff2.yaml")
207
+ db_url = f"sqlite:///{self.tmpdir}/tap_schema.sqlite3"
208
+
209
+ engine = create_engine(db_url)
210
+ metadata_db = MetaDataBuilder(Schema.from_uri(test_diff1), apply_schema_to_metadata=False).build()
211
+ metadata_db.create_all(engine)
212
+
213
+ runner = CliRunner()
214
+ result = runner.invoke(cli, ["diff", f"--engine-url={db_url}", test_diff2], catch_exceptions=False)
215
+ self.assertEqual(result.exit_code, 0)
216
+
217
+ def test_diff_alembic(self) -> None:
218
+ """Test for ``diff`` command with ``--alembic`` comparator option."""
219
+ test_diff1 = os.path.join(TESTDIR, "data", "test_diff1.yaml")
220
+ test_diff2 = os.path.join(TESTDIR, "data", "test_diff2.yaml")
221
+
222
+ runner = CliRunner()
223
+ result = runner.invoke(
224
+ cli, ["diff", "--comparator", "alembic", test_diff1, test_diff2], catch_exceptions=False
225
+ )
226
+ print(result.output)
227
+ self.assertEqual(result.exit_code, 0)
228
+
229
+ def test_diff_error(self) -> None:
230
+ """Test for ``diff`` command with bad arguments."""
231
+ test_diff1 = os.path.join(TESTDIR, "data", "test_diff1.yaml")
232
+ runner = CliRunner()
233
+ result = runner.invoke(cli, ["diff", test_diff1], catch_exceptions=False)
234
+ self.assertNotEqual(result.exit_code, 0)
235
+
236
+ def test_diff_error_on_change(self) -> None:
237
+ """Test for ``diff`` command with ``--error-on-change`` flag."""
238
+ test_diff1 = os.path.join(TESTDIR, "data", "test_diff1.yaml")
239
+ test_diff2 = os.path.join(TESTDIR, "data", "test_diff2.yaml")
240
+
241
+ runner = CliRunner()
242
+ result = runner.invoke(
243
+ cli, ["diff", "--error-on-change", test_diff1, test_diff2], catch_exceptions=False
244
+ )
245
+ print(result.output)
246
+ self.assertNotEqual(result.exit_code, 0)
247
+
191
248
 
192
249
  if __name__ == "__main__":
193
250
  unittest.main()