lsst-felis 28.2024.4800__py3-none-any.whl → 28.2025.500__py3-none-any.whl

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.

felis/diff.py ADDED
@@ -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)
felis/metadata.py CHANGED
@@ -125,29 +125,27 @@ 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.
130
+ table_name_postfix
131
+ A string to append to the table names when building the metadata.
132
132
  """
133
133
 
134
134
  def __init__(
135
135
  self,
136
136
  schema: Schema,
137
137
  apply_schema_to_metadata: bool = True,
138
- apply_schema_to_tables: bool = True,
139
138
  ignore_constraints: bool = False,
139
+ table_name_postfix: str = "",
140
140
  ) -> None:
141
141
  """Initialize the metadata builder."""
142
142
  self.schema = schema
143
143
  if not apply_schema_to_metadata:
144
144
  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
145
  self.metadata = MetaData(schema=schema.name if apply_schema_to_metadata else None)
148
146
  self._objects: dict[str, Any] = {}
149
- self.apply_schema_to_tables = apply_schema_to_tables
150
147
  self.ignore_constraints = ignore_constraints
148
+ self.table_name_postfix = table_name_postfix
151
149
 
152
150
  def build(self) -> MetaData:
153
151
  """Build the SQLAlchemy tables and constraints from the schema.
@@ -231,11 +229,10 @@ class MetaDataBuilder:
231
229
  description = table_obj.description
232
230
  columns = [self.build_column(column) for column in table_obj.columns]
233
231
  table = Table(
234
- name,
232
+ name + self.table_name_postfix,
235
233
  self.metadata,
236
234
  *columns,
237
235
  comment=description,
238
- schema=self.schema.name if self.apply_schema_to_tables else None,
239
236
  **optargs, # type: ignore[arg-type]
240
237
  )
241
238
 
felis/tap_schema.py CHANGED
@@ -91,9 +91,15 @@ class TableManager:
91
91
  self.table_name_postfix = table_name_postfix
92
92
  self.apply_schema_to_metadata = apply_schema_to_metadata
93
93
  self.schema_name = schema_name or TableManager._SCHEMA_NAME_STD
94
+ self.table_name_postfix = table_name_postfix
94
95
 
95
96
  if is_valid_engine(engine):
96
97
  assert isinstance(engine, Engine)
98
+ if table_name_postfix != "":
99
+ logger.warning(
100
+ "Table name postfix '%s' will be ignored when reflecting TAP_SCHEMA database",
101
+ table_name_postfix,
102
+ )
97
103
  logger.debug(
98
104
  "Reflecting TAP_SCHEMA database from existing database at %s",
99
105
  engine.url._replace(password="***"),
@@ -133,7 +139,7 @@ class TableManager:
133
139
  self._metadata = MetaDataBuilder(
134
140
  self.schema,
135
141
  apply_schema_to_metadata=self.apply_schema_to_metadata,
136
- apply_schema_to_tables=self.apply_schema_to_metadata,
142
+ table_name_postfix=self.table_name_postfix,
137
143
  ).build()
138
144
 
139
145
  logger.debug("Loaded TAP_SCHEMA '%s' from YAML resource", self.schema_name)
@@ -355,7 +361,7 @@ class TableManager:
355
361
  engine
356
362
  The SQLAlchemy engine to use to create the tables.
357
363
  """
358
- logger.info("Creating TAP_SCHEMA database '%s'", self.metadata.schema)
364
+ logger.info("Creating TAP_SCHEMA database '%s'", self.schema_name)
359
365
  self._create_schema(engine)
360
366
  self.metadata.create_all(engine)
361
367
 
@@ -426,7 +432,7 @@ class DataLoader:
426
432
  # Execute the inserts if not in dry run mode.
427
433
  self._execute_inserts()
428
434
  else:
429
- logger.info("Dry run: not loading data into database")
435
+ logger.info("Dry run - not loading data into database")
430
436
 
431
437
  def _insert_schemas(self) -> None:
432
438
  """Insert the schema data into the schemas table."""
@@ -567,7 +573,7 @@ class DataLoader:
567
573
  def _print_sql(self) -> None:
568
574
  """Print the generated inserts to stdout."""
569
575
  for insert_str in self._compiled_inserts():
570
- print(insert_str)
576
+ print(insert_str + ";")
571
577
 
572
578
  def _write_sql_to_file(self) -> None:
573
579
  """Write the generated insert statements to a file."""
@@ -575,7 +581,7 @@ class DataLoader:
575
581
  raise ValueError("No output path specified")
576
582
  with open(self.output_path, "w") as outfile:
577
583
  for insert_str in self._compiled_inserts():
578
- outfile.write(insert_str + "\n")
584
+ outfile.write(insert_str + ";" + "\n")
579
585
 
580
586
  def _insert(self, table_name: str, record: list[Any] | dict[str, Any]) -> None:
581
587
  """Generate an insert statement for a record.
felis/version.py CHANGED
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "28.2024.4800"
2
+ __version__ = "28.2025.500"
@@ -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.500
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
@@ -0,0 +1,26 @@
1
+ felis/__init__.py,sha256=r1KFSnc55gziwUuYb9s2EfwrI_85aa3LpaKwk6rUvvs,1108
2
+ felis/cli.py,sha256=Wf-sEUZ-B9zzn4M1huY2ruV1nkgVmpzX8f8iuFfyxZc,14469
3
+ felis/datamodel.py,sha256=NczAA4HBBC4-uxPNsrKAFX-hdlgvCT2qqEJCEqDy4yg,39265
4
+ felis/diff.py,sha256=0N4OcBCzbL9DW_XGAeuvGsQ0zIhq8fY-Kx2QdvLv-Ds,7492
5
+ felis/metadata.py,sha256=cYx_qizkLBqcoxWV46h4TbwTi1KVJAkuA2OuUmD-K5k,13536
6
+ felis/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ felis/tap_schema.py,sha256=DgHH4hBf4q_F540TAR9GTKcALwUkk8iTw5pzQlmv1DA,22753
8
+ felis/types.py,sha256=m80GSGfNHQ3-NzRuTzKOyRXLJboPxdk9kzpp1SO8XdY,5510
9
+ felis/version.py,sha256=S4DyUuQLyaEbonnBItCg8kyZ-_mgVePitRnB-HX_q2Y,54
10
+ felis/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ felis/db/dialects.py,sha256=n5La-shu-8fHLIyf8rrazHDyrzATmMCdELtKV_0ymxI,3517
12
+ felis/db/schema.py,sha256=NOFXzBoBQcgpoRlgT3LoC70FKp7pCSmFEJ7rU8FIT-c,2101
13
+ felis/db/sqltypes.py,sha256=JJy97U8KzAOg5pFi2xZgSjvU8CXXgrzkvCsmo6FLRG4,11060
14
+ felis/db/utils.py,sha256=SIl2ryOT2Zn5n0BqdNDxC1HcOoxh0doaKk_hMUGvwAc,14116
15
+ felis/db/variants.py,sha256=eahthrbVeV8ZdGamWQccNmWgx6CCscGrU0vQRs5HZK8,5260
16
+ felis/schemas/tap_schema_std.yaml,sha256=sPW-Vk72nY0PFpCvP5d8L8fWvhkif-x32sGtcfDZ8bU,7131
17
+ felis/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ felis/tests/postgresql.py,sha256=B_xk4fLual5-viGDqP20r94okuc0pbSvytRH_L0fvMs,4035
19
+ lsst_felis-28.2025.500.dist-info/COPYRIGHT,sha256=vJAFLFTSF1mhy9eIuA3P6R-3yxTWKQgpig88P-1IzRw,129
20
+ lsst_felis-28.2025.500.dist-info/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
21
+ lsst_felis-28.2025.500.dist-info/METADATA,sha256=47DRp1MC63ZQA0CbZR8KWLxoh-dnv6V7iN-gtBO2Xms,1410
22
+ lsst_felis-28.2025.500.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
23
+ lsst_felis-28.2025.500.dist-info/entry_points.txt,sha256=Gk2XFujA_Gp52VBk45g5kim8TDoMDJFPctsMqiq72EM,40
24
+ lsst_felis-28.2025.500.dist-info/top_level.txt,sha256=F4SvPip3iZRVyISi50CHhwTIAokAhSxjWiVcn4IVWRI,6
25
+ lsst_felis-28.2025.500.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
26
+ lsst_felis-28.2025.500.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5