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.
- {lsst_felis-28.2024.4800/python/lsst_felis.egg-info → lsst_felis-28.2025.402}/PKG-INFO +12 -8
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/README.rst +0 -2
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/pyproject.toml +10 -6
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/__init__.py +4 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/cli.py +60 -0
- lsst_felis-28.2025.402/python/felis/db/schema.py +62 -0
- lsst_felis-28.2025.402/python/felis/diff.py +229 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/metadata.py +0 -7
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/tap_schema.py +1 -3
- lsst_felis-28.2025.402/python/felis/version.py +2 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402/python/lsst_felis.egg-info}/PKG-INFO +12 -8
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/SOURCES.txt +4 -1
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/requires.txt +9 -6
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_cli.py +57 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_datamodel.py +8 -8
- lsst_felis-28.2025.402/tests/test_db.py +79 -0
- lsst_felis-28.2025.402/tests/test_diff.py +275 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_metadata.py +1 -1
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_tap_schema.py +18 -9
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_tap_schema_postgres.py +9 -5
- lsst_felis-28.2024.4800/python/felis/tests/utils.py +0 -122
- lsst_felis-28.2024.4800/python/felis/version.py +0 -2
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/COPYRIGHT +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/LICENSE +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/datamodel.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/__init__.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/dialects.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/sqltypes.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/utils.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/db/variants.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/py.typed +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/schemas/tap_schema_std.yaml +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/tap.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/tests/__init__.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/tests/postgresql.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/felis/types.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/entry_points.txt +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/top_level.txt +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/python/lsst_felis.egg-info/zip-safe +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/setup.cfg +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.402}/tests/test_postgres.py +0 -0
- {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
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 28.
|
|
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:
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: click
|
|
24
|
-
Requires-Dist:
|
|
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
|
|
@@ -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
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"click
|
|
27
|
-
"
|
|
28
|
-
"
|
|
25
|
+
"alembic",
|
|
26
|
+
"astropy",
|
|
27
|
+
"click",
|
|
28
|
+
"deepdiff",
|
|
29
|
+
"lsst-resources",
|
|
29
30
|
"lsst-utils",
|
|
30
|
-
"
|
|
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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 28.
|
|
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:
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: click
|
|
24
|
-
Requires-Dist:
|
|
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
|
|
@@ -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()
|