crosscontract 0.2.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.
Files changed (45) hide show
  1. crosscontract/__init__.py +14 -0
  2. crosscontract/contracts/__init__.py +9 -0
  3. crosscontract/contracts/contracts/__init__.py +4 -0
  4. crosscontract/contracts/contracts/base_contract.py +103 -0
  5. crosscontract/contracts/contracts/cross_contract.py +64 -0
  6. crosscontract/contracts/schema/__init__.py +29 -0
  7. crosscontract/contracts/schema/adapters/__init__.py +18 -0
  8. crosscontract/contracts/schema/adapters/abstract_adapter.py +30 -0
  9. crosscontract/contracts/schema/adapters/pandera_adapter.py +557 -0
  10. crosscontract/contracts/schema/adapters/pydantic_adapter.py +288 -0
  11. crosscontract/contracts/schema/adapters/sqlalchemy_adapter.py +168 -0
  12. crosscontract/contracts/schema/adapters/utils.py +30 -0
  13. crosscontract/contracts/schema/exceptions/__init__.py +1 -0
  14. crosscontract/contracts/schema/exceptions/validation_error.py +222 -0
  15. crosscontract/contracts/schema/field_descriptors/__init__.py +20 -0
  16. crosscontract/contracts/schema/field_descriptors/descriptors.py +66 -0
  17. crosscontract/contracts/schema/field_descriptors/field_descriptors.py +89 -0
  18. crosscontract/contracts/schema/fields/__init__.py +18 -0
  19. crosscontract/contracts/schema/fields/base.py +61 -0
  20. crosscontract/contracts/schema/fields/datetime_field.py +37 -0
  21. crosscontract/contracts/schema/fields/list_field.py +55 -0
  22. crosscontract/contracts/schema/fields/numeric_field.py +44 -0
  23. crosscontract/contracts/schema/fields/string_field.py +48 -0
  24. crosscontract/contracts/schema/reference/__init__.py +4 -0
  25. crosscontract/contracts/schema/reference/foreign_key.py +150 -0
  26. crosscontract/contracts/schema/reference/primary_key.py +52 -0
  27. crosscontract/contracts/schema/schema.py +253 -0
  28. crosscontract/contracts/schema/validation/__init__.py +3 -0
  29. crosscontract/contracts/schema/validation/validate_dataframe.py +81 -0
  30. crosscontract/contracts/utils.py +35 -0
  31. crosscontract/contracts/valid_items.py +14 -0
  32. crosscontract/crossclient/__init__.py +3 -0
  33. crosscontract/crossclient/crossclient.py +146 -0
  34. crosscontract/crossclient/exceptions/__init__.py +24 -0
  35. crosscontract/crossclient/exceptions/exception_factory.py +104 -0
  36. crosscontract/crossclient/exceptions/exceptions.py +104 -0
  37. crosscontract/crossclient/logger.py +4 -0
  38. crosscontract/crossclient/services/__init__.py +4 -0
  39. crosscontract/crossclient/services/contract_resource.py +303 -0
  40. crosscontract/crossclient/services/contract_service.py +267 -0
  41. crosscontract/py.typed +0 -0
  42. crosscontract-0.2.2.dist-info/METADATA +23 -0
  43. crosscontract-0.2.2.dist-info/RECORD +45 -0
  44. crosscontract-0.2.2.dist-info/WHEEL +4 -0
  45. crosscontract-0.2.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,557 @@
1
+ from datetime import UTC
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ import pandas as pd
5
+
6
+ if TYPE_CHECKING: # pragma: no cover
7
+ from crosscontract.contracts.schema import TableSchema
8
+
9
+ import pandera.pandas as pa
10
+ from pandera import DataType
11
+ from pandera.engines import pandas_engine
12
+
13
+ from crosscontract.contracts.schema.fields import (
14
+ DateTimeField,
15
+ IntegerField,
16
+ ListField,
17
+ NumberField,
18
+ StringField,
19
+ )
20
+ from crosscontract.contracts.schema.fields.base import BaseField
21
+ from crosscontract.contracts.schema.reference.foreign_key import ForeignKey
22
+
23
+ from .abstract_adapter import AbstractAdapter
24
+ from .utils import parse_datetime
25
+
26
+
27
+ def convert_schema_to_pandera(
28
+ schema: "TableSchema",
29
+ name: str = "ConvertedSchema",
30
+ ) -> pa.DataFrameSchema:
31
+ """Convert the DataContract to a Pandera DataFrameSchema.
32
+
33
+ Args:
34
+ schema (TableSchema): The Schema instance to convert.
35
+ name (str): The name of the resulting DataFrameSchema.
36
+
37
+ Returns:
38
+ pa.DataFrameSchema: A Pandera DataFrameSchema representing the schema of the
39
+ data described by the Schema.
40
+ """
41
+ return PanderaPandasAdapter.convert_schema(schema, name=name)
42
+
43
+
44
+ class PanderaPandasAdapter(AbstractAdapter):
45
+ """Adapter that converts a schema into a corresponding pandera schema that
46
+ allows to validate a Pandas DataFrame against the schema.
47
+ """
48
+
49
+ def convert(
50
+ self,
51
+ name: str = "ConvertedSchema",
52
+ primary_key_values: list[tuple[Any, ...]] | None = None,
53
+ foreign_key_values: dict[tuple[str, ...], list[tuple[Any, ...]]] | None = None,
54
+ skip_primary_key_validation: bool = False,
55
+ skip_foreign_key_validation: bool = False,
56
+ ) -> pa.DataFrameSchema:
57
+ """Convert the given TableSchema into a Pandera DataFrameSchema.
58
+
59
+ Args:
60
+ name (str): The name of the resulting DataFrameSchema.
61
+ primary_key_values (list[tuple[Any, ...]] | None): Existing primary key
62
+ values to check for uniqueness.
63
+ Note: The uniqueness of the primary key is validated is checked against
64
+ the union of the provided values and the values in the DataFrame.
65
+ foreign_key_values (dict[tuple[str, ...], list[tuple[Any, ...]]] | None):
66
+ Existing foreign key values to check against. This is provided as a
67
+ dictionary where the keys are the tuples of fields that refer to the
68
+ referenced values, and the values are lists of tuples representing the
69
+ existing referenced values.
70
+ Note: In the case of self-referencing foreign keys, the values in the
71
+ DataFrame are considered automatically, i.e., the referring fields
72
+ are validated against the union of the provided values and the
73
+ values in the DataFrame.
74
+ skip_primary_key_validation (bool): Whether to skip the validation of
75
+ primary key uniqueness.
76
+ skip_foreign_key_validation (bool): Whether to skip the validation of
77
+ foreign key integrity.
78
+
79
+ Returns:
80
+ pa.DataFrameSchema: A Pandera DataFrameSchema representing the schema
81
+ of the data described by the TableSchema.
82
+ """
83
+ columns: dict[str, pa.Column] = {}
84
+ for field in self.schema.field_iterator():
85
+ match field:
86
+ case IntegerField() | NumberField():
87
+ columns[field.name] = self._convert_numeric_field(field)
88
+ case StringField():
89
+ columns[field.name] = self._convert_string_field(field)
90
+ case DateTimeField():
91
+ columns[field.name] = self._convert_datetime_field(field)
92
+ case ListField():
93
+ columns[field.name] = self._convert_list_field(field)
94
+ case _: # pragma: no cover
95
+ # this should never happen because the schema validation should
96
+ # catch unsupported field types, but we add this case for type
97
+ # safety
98
+ raise NotImplementedError(
99
+ f"Field type '{field.type}' not yet supported"
100
+ )
101
+
102
+ # create the pandera schema with the columns and the name
103
+ pandera_schema = pa.DataFrameSchema(
104
+ columns=columns,
105
+ index=None, # Currently we do not support index columns
106
+ name=name,
107
+ coerce=True, # Useful for CSVs (str -> int)
108
+ strict=True, # Fails if DataFrame contains columns not in Schema
109
+ )
110
+
111
+ # Handle primary key constraints by adding a custom check to the schema
112
+ additional_checks: list[pa.Check] = []
113
+ if self.schema.primaryKey and not skip_primary_key_validation:
114
+ self._check_reference_inputs(primary_key_values) # Validate input format
115
+ additional_checks.append(
116
+ self._get_primary_key_check(
117
+ pk_fields=self.schema.primaryKey.root,
118
+ primary_key_values=primary_key_values,
119
+ )
120
+ )
121
+
122
+ # Handle foreign key constraints by adding custom checks to the schema
123
+ if self.schema.foreignKeys and not skip_foreign_key_validation:
124
+ for fk in self.schema.foreignKeys:
125
+ valid_values = (
126
+ foreign_key_values.get(tuple(fk.fields))
127
+ if foreign_key_values
128
+ else None
129
+ )
130
+ self._check_reference_inputs(valid_values) # Validate input format
131
+ additional_checks.append(
132
+ self._get_foreign_key_check(fk=fk, foreign_key_values=valid_values)
133
+ )
134
+
135
+ # add the additional checks to the pandera schema checks, ensuring we
136
+ # don't overwrite any existing checks
137
+ pandera_schema.checks = (pandera_schema.checks or []) + additional_checks
138
+
139
+ return pandera_schema
140
+
141
+ @classmethod
142
+ def convert_schema(
143
+ cls,
144
+ schema: "TableSchema",
145
+ name: str = "ConvertedSchema",
146
+ primary_key_values: list[tuple[Any, ...]] | None = None,
147
+ foreign_key_values: dict[tuple[str, ...], list[tuple[Any, ...]]] | None = None,
148
+ skip_primary_key_validation: bool = False,
149
+ skip_foreign_key_validation: bool = False,
150
+ ) -> pa.DataFrameSchema:
151
+ """Class method to convert a TableSchema into a Pandera DataFrameSchema without
152
+ needing to instantiate the adapter.
153
+
154
+ Args:
155
+ schema (TableSchema): The TableSchema to convert.
156
+ name (str): The name of the resulting DataFrameSchema.
157
+ primary_key_values (list[tuple[Any, ...]] | None): Existing primary key
158
+ values to check for uniqueness.
159
+ Note: The uniqueness of the primary key is validated is checked against
160
+ the union of the provided values and the values in the DataFrame.
161
+ foreign_key_values (dict[tuple[str, ...], list[tuple[Any, ...]]] | None):
162
+ Existing foreign key values to check against. This is provided as a
163
+ dictionary where the keys are the tuples of fields that refer to the
164
+ referenced values, and the values are lists of tuples representing the
165
+ existing referenced values.
166
+ Note: In the case of self-referencing foreign keys, the values in the
167
+ DataFrame are considered automatically, i.e., the referring fields
168
+ are validated against the union of the provided values and the
169
+ values in the DataFrame.
170
+ skip_primary_key_validation (bool): Whether to skip the validation of
171
+ primary key uniqueness.
172
+ skip_foreign_key_validation (bool): Whether to skip the validation of
173
+ foreign key integrity.
174
+
175
+ Returns:
176
+ pa.DataFrameSchema: A Pandera DataFrameSchema representing the schema of the
177
+ data described by the TableSchema.
178
+ """
179
+ return super().convert_schema(
180
+ schema,
181
+ name=name,
182
+ primary_key_values=primary_key_values,
183
+ skip_primary_key_validation=skip_primary_key_validation,
184
+ foreign_key_values=foreign_key_values,
185
+ skip_foreign_key_validation=skip_foreign_key_validation,
186
+ )
187
+
188
+ def _init_pandera_kwargs(
189
+ self, field: BaseField, pandera_type: type | str | DataType
190
+ ) -> dict[str, Any]:
191
+ """Initialize the keyword arguments for creating a pandera Column based on
192
+ the given field.
193
+
194
+ Args:
195
+ field (BaseField): The field for which to initialize the pandera kwargs.
196
+ pandera_type (type | str): The pandera type for the field.
197
+
198
+ Returns:
199
+ dict[str, Any]: The initialized keyword arguments for creating a pandera
200
+ Column.
201
+ """
202
+ kwargs: dict[str, Any] = {
203
+ "name": field.name,
204
+ "dtype": pandera_type,
205
+ "title": field.title,
206
+ "description": field.description,
207
+ "checks": [],
208
+ }
209
+
210
+ kwargs["required"] = field.constraints.required
211
+ if not kwargs["required"]:
212
+ kwargs["nullable"] = True
213
+ kwargs["unique"] = field.constraints.unique
214
+
215
+ # check constraints
216
+ # Handle enum constraint
217
+ if enum_constraint := getattr(field.constraints, "enum", None):
218
+ kwargs["checks"].append(pa.Check.isin(enum_constraint))
219
+ return kwargs
220
+
221
+ def _convert_numeric_field(self, field: IntegerField | NumberField) -> pa.Column:
222
+ """Convert a numeric field (IntegerField or NumberField) into a pandera
223
+ Column definition.
224
+
225
+ Args:
226
+ field (IntegerField | NumberField): The numeric field to convert.
227
+
228
+ Returns:
229
+ pa.Column: A pandera Column representing the numeric field.
230
+ """
231
+ pandera_type: type | str | None = None
232
+ if isinstance(field, IntegerField):
233
+ pandera_type = "Int64"
234
+ elif isinstance(field, NumberField):
235
+ pandera_type = float
236
+ else:
237
+ raise ValueError("Field must be an IntegerField or NumberField")
238
+
239
+ kwargs = self._init_pandera_kwargs(field, pandera_type)
240
+
241
+ # Handle minimum and maximum constraints
242
+ if field.constraints.minimum is not None:
243
+ kwargs["checks"].append(pa.Check.ge(field.constraints.minimum))
244
+ if field.constraints.maximum is not None:
245
+ kwargs["checks"].append(pa.Check.le(field.constraints.maximum))
246
+
247
+ return pa.Column(**kwargs)
248
+
249
+ def _convert_string_field(self, field: StringField) -> pa.Column:
250
+ """Convert a StringField into a pandera Column definition.
251
+
252
+ Args:
253
+ field (StringField): The StringField to convert.
254
+
255
+ Returns:
256
+ pa.Column: A pandera Column representing the StringField.
257
+ """
258
+ kwargs = self._init_pandera_kwargs(field, str)
259
+
260
+ # Handle pattern constraint
261
+ if field.constraints.pattern is not None:
262
+ kwargs["regex"] = field.constraints.pattern
263
+
264
+ # Handle minLength and maxLength constraints
265
+ if (
266
+ field.constraints.minLength is not None
267
+ or field.constraints.maxLength is not None
268
+ ):
269
+ kwargs["checks"].append(
270
+ pa.Check.str_length(
271
+ min_value=field.constraints.minLength,
272
+ max_value=field.constraints.maxLength,
273
+ )
274
+ )
275
+
276
+ return pa.Column(**kwargs)
277
+
278
+ def _convert_list_field(self, field: ListField) -> pa.Column:
279
+ """Convert a ListField into a pandera Column definition.
280
+
281
+ Args:
282
+ field (ListField): The ListField to convert.
283
+
284
+ Returns:
285
+ pa.Column: A pandera Column representing the ListField.
286
+ """
287
+ # determine the pandera type for the list items
288
+ type_mapping: dict[str, type | str] = {
289
+ "string": list[str],
290
+ "integer": list[int],
291
+ "number": list[float],
292
+ "boolean": list[bool],
293
+ }
294
+ pandera_type = type_mapping.get(field.itemType)
295
+ if pandera_type is None: # pragma: no cover
296
+ # this is already validated at the schema level, so this should never
297
+ # happen but we add this check for type safety
298
+ raise ValueError(f"Unsupported itemType: {field.itemType}")
299
+
300
+ # initialize the pandera kwargs for the list field
301
+ kwargs = self._init_pandera_kwargs(field, pandera_type)
302
+
303
+ # Handle minLength and maxLength constraints
304
+ if field.constraints.minLength is not None:
305
+ kwargs["checks"].append(
306
+ pa.Check(
307
+ lambda s: s.apply(
308
+ lambda lst, m=field.constraints.minLength: len(lst) >= m
309
+ )
310
+ )
311
+ )
312
+ if field.constraints.maxLength is not None:
313
+ kwargs["checks"].append(
314
+ pa.Check(
315
+ lambda s: s.apply(
316
+ lambda lst, m=field.constraints.maxLength: len(lst) <= m
317
+ )
318
+ )
319
+ )
320
+
321
+ return pa.Column(**kwargs)
322
+
323
+ def _convert_datetime_field(self, field: DateTimeField) -> pa.Column:
324
+ """Convert a DateTimeField into a pandera Column definition.
325
+
326
+ Args:
327
+ field (DateTimeField): The DateTimeField to convert.
328
+
329
+ Returns:
330
+ pa.Column: A pandera Column representing the DateTimeField.
331
+ """
332
+ kwargs = self._init_pandera_kwargs(
333
+ field,
334
+ pandas_engine.DateTime(tz=UTC, to_datetime_kwargs={"format": field.format}), # type: ignore[call-arg]
335
+ )
336
+
337
+ # Handle minimum and maximum constraints
338
+ if field.constraints.minimum is not None:
339
+ minimum = field.constraints.minimum
340
+ kwargs["checks"].append(
341
+ pa.Check(
342
+ lambda s: s.apply(
343
+ lambda dt, m=minimum, fmt=field.format: parse_datetime(dt, fmt) # type: ignore[operator]
344
+ >= parse_datetime(m, fmt)
345
+ )
346
+ )
347
+ )
348
+ if field.constraints.maximum is not None:
349
+ maximum = field.constraints.maximum
350
+ kwargs["checks"].append(
351
+ pa.Check(
352
+ lambda s: s.apply(
353
+ lambda dt, m=maximum, fmt=field.format: parse_datetime(dt, fmt) # type: ignore[operator]
354
+ <= parse_datetime(m, fmt)
355
+ )
356
+ )
357
+ )
358
+
359
+ return pa.Column(**kwargs)
360
+
361
+ @staticmethod
362
+ def _check_reference_inputs(given: Any):
363
+ """Check that the given input for reference validation is in the correct
364
+ format, i.e., a list of tuples
365
+
366
+ Args:
367
+ given (Any): The input to check.
368
+
369
+ Raises:
370
+ ValueError: If the input is not a list of tuples.
371
+ """
372
+ if given is None:
373
+ return
374
+ # check outer structure is a list, set, or tuple:
375
+ raise_error = False
376
+ if not isinstance(given, (list, set, tuple)):
377
+ raise_error = True
378
+ # check that the inner structure is a tuple:
379
+ elif not all(isinstance(item, tuple) for item in given):
380
+ raise_error = True
381
+ if raise_error:
382
+ raise ValueError(
383
+ "Existing references must be must be provided as a list of tuples, "
384
+ "where each tuple represents a valid referenced key. Example: "
385
+ "[(10,), (11,)] and not [10, 11] or [[10], [11]]."
386
+ )
387
+
388
+ @staticmethod
389
+ def _check_pk_integrity(
390
+ df_sub: pd.DataFrame,
391
+ pk_fields: list[str],
392
+ existing_pk_set: set[tuple[Any, ...]],
393
+ ) -> pd.Series:
394
+ # 1. Ensure no nulls in the columns
395
+ has_nulls = df_sub[pk_fields].isna().any(axis=1)
396
+
397
+ # 2. Check values in the DataFrame are internally unique
398
+ is_internally_unique = ~df_sub.duplicated(subset=pk_fields, keep=False)
399
+
400
+ # 3. Check values against existing primary key values
401
+ if existing_pk_set:
402
+ current_keys = pd.MultiIndex.from_frame(df_sub[pk_fields])
403
+ is_externally_unique = pd.Series(
404
+ ~current_keys.isin(existing_pk_set),
405
+ index=df_sub.index,
406
+ )
407
+ return is_internally_unique & is_externally_unique & ~has_nulls
408
+ return is_internally_unique & ~has_nulls
409
+
410
+ @staticmethod
411
+ def _get_primary_key_check(
412
+ pk_fields: list[str],
413
+ primary_key_values: list[tuple[Any, ...]] | None,
414
+ ) -> pa.Check:
415
+ """Provide primary key uniqueness checks. The check ensures that primary
416
+ key values are unique within the DataFrame and against existing primary
417
+ key values.
418
+
419
+ Args:
420
+ pk_fields (list[str]): The fields that make up the primary key.
421
+ primary_key_values (list[tuple[Any, ...]] | None): Existing primary
422
+ key values to check for uniqueness.
423
+
424
+ Returns:
425
+ pa.Check: A Pandera Check object that can be added to a DataFrameSchema.
426
+ """
427
+ existing_pk_set = set(primary_key_values) if primary_key_values else set()
428
+
429
+ def check_pk_integrity(df_sub: pd.DataFrame) -> pd.Series:
430
+ return PanderaPandasAdapter._check_pk_integrity(
431
+ df_sub=df_sub,
432
+ pk_fields=pk_fields,
433
+ existing_pk_set=existing_pk_set,
434
+ )
435
+
436
+ return pa.Check(
437
+ check_pk_integrity,
438
+ name=f"PrimaryKeyError: {list(pk_fields)}",
439
+ error=(
440
+ f"PrimaryKeyError: Primary key {pk_fields} must be non-null and "
441
+ "unique within the dataset and compared to existing primary key values."
442
+ ),
443
+ )
444
+
445
+ @staticmethod
446
+ def _check_fk_integrity(
447
+ df_sub: pd.DataFrame,
448
+ fk_fields: list[str],
449
+ valid_values: set,
450
+ referenced_fields: list[str] | None,
451
+ ) -> pd.Series:
452
+ """Check function for the integrity of a foreign key constraint of a
453
+ DataFrame. The function checks whether the values in the foreign key
454
+ fields of the DataFrame exist in the set of valid referenced values,
455
+ which is the union of the provided valid values and the values in the
456
+ DataFrame itself in case of self-referencing foreign keys.
457
+
458
+ The function is usually not directly called, but is used within a Pandera
459
+ check
460
+
461
+ Args:
462
+ df_sub (pd.DataFrame): The DataFrame to check.
463
+ fk_fields (list[str]): The fields that make up the foreign key.
464
+ valid_values (set): The set of valid referenced values to check against.
465
+ referenced_fields (list[str] | None): The fields in the DataFrame that
466
+ hold the values to check against in case of self-referencing foreign
467
+ keys. If None, it is assumed that this is not a self-referencing
468
+ foreign key.
469
+
470
+ Returns:
471
+ pd.Series: A boolean Series indicating whether each row in the DataFrame
472
+ satisfies the foreign key constraint.
473
+ """
474
+ # 1. Prepare valid set for this check
475
+ current_valid = valid_values.copy()
476
+
477
+ # If self-reference, add current dataframe values to valid set
478
+ if referenced_fields is not None:
479
+ internal_reference = df_sub[referenced_fields].apply(tuple, axis=1)
480
+ current_valid = set(current_valid).union(internal_reference)
481
+
482
+ # 2. Select the data
483
+ # We interpret empty strings as nulls
484
+ subset = df_sub[fk_fields].replace("", pd.NA)
485
+
486
+ # 3. Identify rows containing Nulls
487
+ # (Standard SQL: Nulls pass FK check)
488
+ is_null_row = subset.isna().any(axis=1)
489
+
490
+ # 4. Create a tuple for all rows
491
+ keys_to_check = pd.MultiIndex.from_frame(subset)
492
+
493
+ # 5. Check Existence
494
+ # This returns a boolean Series aligned with df_sub.index
495
+ is_present = pd.Series(keys_to_check.isin(current_valid), index=df_sub.index)
496
+
497
+ # 6. Final Logic: Valid if (Present in Reference) OR (Is Null)
498
+ return is_present | is_null_row
499
+
500
+ @staticmethod
501
+ def _get_foreign_key_check(
502
+ fk: ForeignKey,
503
+ foreign_key_values: list[tuple[Any, ...]] | None = None,
504
+ ) -> pa.Check:
505
+ """Provide a single foreign key integrity check. The check ensures that values
506
+ in the foreign key fields exist in the referenced dataset.
507
+
508
+ Args:
509
+ fk (ForeignKey): The foreign key to create the check for.
510
+ foreign_key_values (list[tuple[Any, ...]] | None):
511
+ Existing foreign key values to check against.
512
+
513
+ Returns:
514
+ pa.Check: A Pandera Check object that can be added to a DataFrameSchema.
515
+
516
+ Raises:
517
+ ValueError: If no referenced values are provided for validation.
518
+ """
519
+ fk_fields = fk.fields
520
+
521
+ # Get external valid values
522
+ valid_values = set(foreign_key_values) if foreign_key_values else set()
523
+
524
+ # Handle Self-Reference
525
+ # the fields that hold the valid values in case of self-reference
526
+ referenced_fields = (
527
+ fk.reference.fields if fk.reference.resource is None else None
528
+ )
529
+
530
+ # If no external values and not self-reference, we can't validate
531
+ # so we raise a ValueError
532
+ if not valid_values and referenced_fields is None:
533
+ raise ValueError(
534
+ f"Cannot validate foreign key {fk_fields} as no referenced values "
535
+ "are provided."
536
+ )
537
+
538
+ # Capture closure variables
539
+ def check_fk_integrity(
540
+ df_sub: pd.DataFrame,
541
+ ) -> pd.Series:
542
+ return PanderaPandasAdapter._check_fk_integrity(
543
+ df_sub=df_sub,
544
+ fk_fields=fk_fields,
545
+ valid_values=valid_values,
546
+ referenced_fields=referenced_fields,
547
+ )
548
+
549
+ return pa.Check(
550
+ check_fk_integrity,
551
+ name=f"ForeignKeyError: {list(fk_fields)}",
552
+ error=(
553
+ f"ForeignKeyError: Values in {fk_fields} do not exist in referenced "
554
+ f"table."
555
+ ),
556
+ ignore_na=False, # We handle NAs explicitly
557
+ )