mapkgsutils 0.0.2__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.
mapkgsutils/diff.py ADDED
@@ -0,0 +1,338 @@
1
+ """Diff operations for comparing MappingSets between releases.
2
+
3
+ Uses Polars for efficient comparison of large mapping datasets.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ import polars as pl
13
+
14
+ if TYPE_CHECKING:
15
+ from sssom_schema import MappingSet
16
+
17
+ __all__ = [
18
+ "MappingDiff",
19
+ "diff_mapping_sets",
20
+ "diff_sssom_files",
21
+ "summarize_diff",
22
+ ]
23
+
24
+
25
+ @dataclass
26
+ class MappingDiff:
27
+ """Result of comparing two MappingSets."""
28
+
29
+ old_version: str | None
30
+ new_version: str | None
31
+ datasource: str
32
+
33
+ # Added mappings (object_id, subject_id) - in new but not in old
34
+ added: pl.DataFrame = field(default_factory=pl.DataFrame)
35
+ # Removed mappings (object_id, subject_id) - in old but not in new
36
+ removed: pl.DataFrame = field(default_factory=pl.DataFrame)
37
+ # Changed mappings (same object_id, different subject_id)
38
+ changed: pl.DataFrame = field(default_factory=pl.DataFrame)
39
+ # Intersection mappings (in both old and new)
40
+ intersection: pl.DataFrame = field(default_factory=pl.DataFrame)
41
+
42
+ @property
43
+ def added_count(self) -> int:
44
+ """Number of added mappings."""
45
+ return len(self.added)
46
+
47
+ @property
48
+ def removed_count(self) -> int:
49
+ """Number of removed mappings."""
50
+ return len(self.removed)
51
+
52
+ @property
53
+ def changed_count(self) -> int:
54
+ """Number of changed mappings."""
55
+ return len(self.changed)
56
+
57
+ @property
58
+ def intersection_count(self) -> int:
59
+ """Number of mappings in both sets."""
60
+ return len(self.intersection)
61
+
62
+ @property
63
+ def total_changes(self) -> int:
64
+ """Total number of changes."""
65
+ return self.added_count + self.removed_count + self.changed_count
66
+
67
+ @property
68
+ def has_changes(self) -> bool:
69
+ """Whether there are any changes."""
70
+ return self.total_changes > 0
71
+
72
+
73
+ def mapping_set_to_dataframe(mapping_set: MappingSet) -> pl.DataFrame:
74
+ """Convert a MappingSet to a Polars DataFrame for comparison.
75
+
76
+ Args:
77
+ mapping_set: The MappingSet to convert (sssom_schema.MappingSet).
78
+
79
+ Returns:
80
+ Polars DataFrame with columns: subject_id, object_id.
81
+ """
82
+ rows = []
83
+ mappings = mapping_set.mappings or []
84
+ for mapping in mappings:
85
+ rows.append(
86
+ {
87
+ "subject_id": mapping.subject_id,
88
+ "object_id": mapping.object_id,
89
+ }
90
+ )
91
+
92
+ if not rows:
93
+ return pl.DataFrame(schema={"subject_id": pl.Utf8, "object_id": pl.Utf8})
94
+
95
+ return pl.DataFrame(rows)
96
+
97
+
98
+ def diff_mapping_sets(
99
+ old_set: MappingSet,
100
+ new_set: MappingSet,
101
+ datasource: str = "unknown",
102
+ ) -> MappingDiff:
103
+ """Compare two MappingSets and find differences.
104
+
105
+ Args:
106
+ old_set: The older/previous MappingSet (sssom_schema.MappingSet).
107
+ new_set: The newer/current MappingSet (sssom_schema.MappingSet).
108
+ datasource: Name of the datasource for the diff.
109
+
110
+ Returns:
111
+ MappingDiff with added, removed, and changed mappings.
112
+ """
113
+ old_df = mapping_set_to_dataframe(old_set)
114
+ new_df = mapping_set_to_dataframe(new_set)
115
+
116
+ return _diff_dataframes(
117
+ old_df=old_df,
118
+ new_df=new_df,
119
+ old_version=old_set.mapping_set_version,
120
+ new_version=new_set.mapping_set_version,
121
+ datasource=datasource,
122
+ )
123
+
124
+
125
+ def diff_sssom_files(
126
+ old_file: Path | str,
127
+ new_file: Path | str,
128
+ datasource: str = "unknown",
129
+ ) -> MappingDiff:
130
+ """Compare two SSSOM TSV files and find differences.
131
+
132
+ Args:
133
+ old_file: Path to the older SSSOM file.
134
+ new_file: Path to the newer SSSOM file.
135
+ datasource: Name of the datasource.
136
+
137
+ Returns:
138
+ MappingDiff with added, removed, and changed mappings.
139
+ """
140
+ old_path = Path(old_file)
141
+ new_path = Path(new_file)
142
+
143
+ # Read SSSOM files (skip metadata lines starting with #)
144
+ old_df = _read_sssom_to_dataframe(old_path)
145
+ new_df = _read_sssom_to_dataframe(new_path)
146
+
147
+ # Extract versions from metadata if available
148
+ old_version = _extract_sssom_version(old_path)
149
+ new_version = _extract_sssom_version(new_path)
150
+
151
+ return _diff_dataframes(
152
+ old_df=old_df,
153
+ new_df=new_df,
154
+ old_version=old_version,
155
+ new_version=new_version,
156
+ datasource=datasource,
157
+ )
158
+
159
+
160
+ def _read_sssom_to_dataframe(path: Path) -> pl.DataFrame:
161
+ """Read an SSSOM TSV file to a Polars DataFrame.
162
+
163
+ Handles the SSSOM metadata header (lines starting with #).
164
+
165
+ Args:
166
+ path: Path to the SSSOM file.
167
+
168
+ Returns:
169
+ Polars DataFrame with subject_id and object_id columns.
170
+ """
171
+ # Count header lines
172
+ header_lines = 0
173
+ with path.open() as f:
174
+ for line in f:
175
+ if line.startswith("#"):
176
+ header_lines += 1
177
+ else:
178
+ break
179
+
180
+ # Read the TSV, skipping metadata
181
+ df = pl.read_csv(
182
+ path,
183
+ separator="\t",
184
+ skip_rows=header_lines,
185
+ infer_schema_length=10000,
186
+ )
187
+
188
+ if "subject_id" in df.columns and "object_id" in df.columns:
189
+ df = df.select(["subject_id", "object_id"])
190
+
191
+ return df
192
+
193
+
194
+ def _extract_sssom_version(path: Path) -> str | None:
195
+ """Extract version from SSSOM metadata header.
196
+
197
+ Args:
198
+ path: Path to the SSSOM file.
199
+
200
+ Returns:
201
+ Version string or None if not found.
202
+ """
203
+ with path.open() as f:
204
+ for line in f:
205
+ if not line.startswith("#"):
206
+ break
207
+ if "mapping_set_version" in line.lower():
208
+ # Format: #mapping_set_version: "1.0"
209
+ parts = line.split(":", 1)
210
+ if len(parts) == 2:
211
+ return parts[1].strip().strip('"').strip("'")
212
+ return None
213
+
214
+
215
+ def _diff_dataframes(
216
+ old_df: pl.DataFrame,
217
+ new_df: pl.DataFrame,
218
+ old_version: str | None,
219
+ new_version: str | None,
220
+ datasource: str,
221
+ ) -> MappingDiff:
222
+ """Compare two DataFrames and find differences.
223
+
224
+ Args:
225
+ old_df: The older DataFrame.
226
+ new_df: The newer DataFrame.
227
+ old_version: Version of the old set.
228
+ new_version: Version of the new set.
229
+ datasource: Datasource name.
230
+
231
+ Returns:
232
+ MappingDiff with added, removed, and changed mappings.
233
+ """
234
+ # Ensure consistent schema
235
+ if old_df.is_empty():
236
+ old_df = pl.DataFrame(schema={"subject_id": pl.Utf8, "object_id": pl.Utf8})
237
+ if new_df.is_empty():
238
+ new_df = pl.DataFrame(schema={"subject_id": pl.Utf8, "object_id": pl.Utf8})
239
+
240
+ # Find mappings by (subject_id, object_id) pairs
241
+ old_pairs = old_df.select(["subject_id", "object_id"]).unique()
242
+ new_pairs = new_df.select(["subject_id", "object_id"]).unique()
243
+
244
+ # Added: in new but not in old
245
+ added = new_pairs.join(
246
+ old_pairs,
247
+ on=["subject_id", "object_id"],
248
+ how="anti",
249
+ )
250
+
251
+ # Removed: in old but not in new
252
+ removed = old_pairs.join(
253
+ new_pairs,
254
+ on=["subject_id", "object_id"],
255
+ how="anti",
256
+ )
257
+
258
+ # Changed: same object_id but different subject_id
259
+ # Get unique secondary IDs with their primary mappings
260
+ old_by_sec = old_df.select(
261
+ [
262
+ pl.col("object_id"),
263
+ pl.col("subject_id").alias("old_subject_id"),
264
+ ]
265
+ ).unique()
266
+
267
+ new_by_sec = new_df.select(
268
+ [
269
+ pl.col("object_id"),
270
+ pl.col("subject_id").alias("new_subject_id"),
271
+ ]
272
+ ).unique()
273
+
274
+ # Join on object_id and filter where primary changed
275
+ changed = old_by_sec.join(new_by_sec, on="object_id", how="inner").filter(
276
+ pl.col("old_subject_id") != pl.col("new_subject_id")
277
+ )
278
+
279
+ # Intersection: in both old and new (same subject_id, object_id pair)
280
+ intersection = old_pairs.join(
281
+ new_pairs,
282
+ on=["subject_id", "object_id"],
283
+ how="inner",
284
+ )
285
+
286
+ return MappingDiff(
287
+ old_version=old_version,
288
+ new_version=new_version,
289
+ datasource=datasource,
290
+ added=added,
291
+ removed=removed,
292
+ changed=changed,
293
+ intersection=intersection,
294
+ )
295
+
296
+
297
+ def summarize_diff(diff: MappingDiff) -> str:
298
+ """Generate a human-readable summary of a diff.
299
+
300
+ Args:
301
+ diff: The MappingDiff to summarize.
302
+
303
+ Returns:
304
+ A formatted string summary.
305
+ """
306
+ lines = [
307
+ f"Diff Summary for {diff.datasource}",
308
+ f" Old version: {diff.old_version or 'unknown'}",
309
+ f" New version: {diff.new_version or 'unknown'}",
310
+ "",
311
+ f" Intersection: {diff.intersection_count:>8}",
312
+ f" Added mappings: {diff.added_count:>8}",
313
+ f" Removed mappings: {diff.removed_count:>8}",
314
+ f" Changed mappings: {diff.changed_count:>8}",
315
+ f" Total changes: {diff.total_changes:>8}",
316
+ ]
317
+
318
+ if diff.added_count > 0 and diff.added_count <= 10:
319
+ lines.append("")
320
+ lines.append(" Added:")
321
+ for row in diff.added.iter_rows(named=True):
322
+ lines.append(f" {row['object_id']} -> {row['subject_id']}")
323
+
324
+ if diff.removed_count > 0 and diff.removed_count <= 10:
325
+ lines.append("")
326
+ lines.append(" Removed:")
327
+ for row in diff.removed.iter_rows(named=True):
328
+ lines.append(f" {row['object_id']} -> {row['subject_id']}")
329
+
330
+ if diff.changed_count > 0 and diff.changed_count <= 10:
331
+ lines.append("")
332
+ lines.append(" Changed:")
333
+ for row in diff.changed.iter_rows(named=True):
334
+ lines.append(
335
+ f" {row['object_id']}: {row['old_subject_id']} -> {row['new_subject_id']}"
336
+ )
337
+
338
+ return "\n".join(lines)