patito 0.5.1__py3-none-any.whl → 0.6.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.
patito/validators.py CHANGED
@@ -1,31 +1,35 @@
1
1
  """Module for validating datastructures with respect to model specifications."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
- import sys
5
- from typing import TYPE_CHECKING, Type, Union, cast
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Optional,
9
+ Sequence,
10
+ Type,
11
+ Union,
12
+ _UnionGenericAlias,
13
+ cast,
14
+ )
6
15
 
7
16
  import polars as pl
8
- from typing_extensions import get_args, get_origin
17
+ from pydantic.aliases import AliasGenerator
18
+ from typing_extensions import get_args
9
19
 
20
+ from patito._pydantic.dtypes import is_optional
10
21
  from patito.exceptions import (
11
22
  ColumnDTypeError,
23
+ DataFrameValidationError,
12
24
  ErrorWrapper,
13
25
  MissingColumnsError,
14
26
  MissingValuesError,
15
27
  RowValueError,
16
- SuperflousColumnsError,
17
- ValidationError,
28
+ SuperfluousColumnsError,
18
29
  )
19
30
 
20
- if sys.version_info >= (3, 10): # pragma: no cover
21
- from types import UnionType # pyright: ignore
22
-
23
- UNION_TYPES = (Union, UnionType)
24
- else:
25
- UNION_TYPES = (Union,) # pragma: no cover
26
-
27
31
  try:
28
- import pandas as pd
32
+ import pandas as pd # type: ignore
29
33
 
30
34
  _PANDAS_AVAILABLE = True
31
35
  except ImportError:
@@ -38,7 +42,7 @@ if TYPE_CHECKING:
38
42
  VALID_POLARS_TYPES = {
39
43
  "enum": {pl.Categorical},
40
44
  "boolean": {pl.Boolean},
41
- "string": {pl.Utf8, pl.Datetime, pl.Date},
45
+ "string": {pl.String, pl.Datetime, pl.Date},
42
46
  "number": {pl.Float32, pl.Float64},
43
47
  "integer": {
44
48
  pl.Int8,
@@ -53,24 +57,8 @@ VALID_POLARS_TYPES = {
53
57
  }
54
58
 
55
59
 
56
- def _is_optional(type_annotation: Type) -> bool:
57
- """
58
- Return True if the given type annotation is an Optional annotation.
59
-
60
- Args:
61
- type_annotation: The type annotation to be checked.
62
-
63
- Returns:
64
- True if the outermost type is Optional.
65
- """
66
- return (get_origin(type_annotation) in UNION_TYPES) and (
67
- type(None) in get_args(type_annotation)
68
- )
69
-
70
-
71
- def _dewrap_optional(type_annotation: Type) -> Type:
72
- """
73
- Return the inner, wrapped type of an Optional.
60
+ def _dewrap_optional(type_annotation: Type[Any] | Any) -> Type:
61
+ """Return the inner, wrapped type of an Optional.
74
62
 
75
63
  Is a no-op for non-Optional types.
76
64
 
@@ -79,6 +67,7 @@ def _dewrap_optional(type_annotation: Type) -> Type:
79
67
 
80
68
  Returns:
81
69
  The input type, but with the outermost Optional removed.
70
+
82
71
  """
83
72
  return (
84
73
  next( # pragma: no cover
@@ -86,54 +75,94 @@ def _dewrap_optional(type_annotation: Type) -> Type:
86
75
  for valid_type in get_args(type_annotation)
87
76
  if valid_type is not type(None) # noqa: E721
88
77
  )
89
- if _is_optional(type_annotation)
78
+ if is_optional(type_annotation)
90
79
  else type_annotation
91
80
  )
92
81
 
93
82
 
83
+ def _transform_df(dataframe: pl.DataFrame, schema: type[Model]) -> pl.DataFrame:
84
+ """Transform any properties of the dataframe according to the model.
85
+
86
+ Currently only supports using AliasGenerator to transform column names to match a model.
87
+
88
+ Args:
89
+ dataframe: Polars DataFrame to be validated.
90
+ schema: Patito model which specifies how the dataframe should be structured.
91
+
92
+ """
93
+ # Check if an alias generator is present in model_config
94
+ if alias_gen := schema.model_config.get("alias_generator"):
95
+ if isinstance(alias_gen, AliasGenerator):
96
+ alias_func = alias_gen.validation_alias or alias_gen.alias
97
+ assert (
98
+ alias_func is not None
99
+ ), "An AliasGenerator must contain a transforming function"
100
+ else: # alias_gen is a function
101
+ alias_func = alias_gen
102
+
103
+ new_cols: list[str] = [
104
+ alias_func(field_name) for field_name in dataframe.columns
105
+ ] # type: ignore
106
+ dataframe.columns = new_cols
107
+ return dataframe
108
+
109
+
94
110
  def _find_errors( # noqa: C901
95
111
  dataframe: pl.DataFrame,
96
112
  schema: Type[Model],
113
+ columns: Optional[Sequence[str]] = None,
114
+ allow_missing_columns: bool = False,
115
+ allow_superfluous_columns: bool = False,
97
116
  ) -> list[ErrorWrapper]:
98
- """
99
- Validate the given dataframe.
117
+ """Validate the given dataframe.
100
118
 
101
119
  Args:
102
120
  dataframe: Polars DataFrame to be validated.
103
121
  schema: Patito model which specifies how the dataframe should be structured.
122
+ columns: If specified, only validate the given columns. Missing columns will
123
+ check if any specified columns are missing from the inputted dataframe,
124
+ and superfluous columns will check if any columns not specified in the
125
+ schema are present in the columns list.
126
+ allow_missing_columns: If True, missing columns will not be considered an error.
127
+ allow_superfluous_columns: If True, additional columns will not be considered an error.
104
128
 
105
129
  Returns:
106
130
  A list of patito.exception.ErrorWrapper instances. The specific validation
107
131
  error can be retrieved from the "exc" attribute on each error wrapper instance.
108
132
 
109
133
  MissingColumnsError: If there are any missing columns.
110
- SuperflousColumnsError: If there are additional, non-specified columns.
134
+ SuperfluousColumnsError: If there are additional, non-specified columns.
111
135
  MissingValuesError: If there are nulls in a non-optional column.
112
136
  ColumnDTypeError: If any column has the wrong dtype.
113
137
  NotImplementedError: If validation has not been implement for the given
114
138
  type.
139
+
115
140
  """
116
141
  errors: list[ErrorWrapper] = []
117
- # Check if any columns are missing
118
- for missig_column in set(schema.columns) - set(dataframe.columns):
119
- errors.append(
120
- ErrorWrapper(
121
- MissingColumnsError("Missing column"),
122
- loc=missig_column,
142
+ schema_subset = columns or schema.columns
143
+ column_subset = columns or dataframe.columns
144
+ if not allow_missing_columns:
145
+ # Check if any columns are missing
146
+ for missing_column in set(schema_subset) - set(dataframe.columns):
147
+ errors.append(
148
+ ErrorWrapper(
149
+ MissingColumnsError("Missing column"),
150
+ loc=missing_column,
151
+ )
123
152
  )
124
- )
125
153
 
126
- # Check if any additional columns are included
127
- for superflous_column in set(dataframe.columns) - set(schema.columns):
128
- errors.append(
129
- ErrorWrapper(
130
- SuperflousColumnsError("Superflous column"),
131
- loc=superflous_column,
154
+ if not allow_superfluous_columns:
155
+ # Check if any additional columns are included
156
+ for superfluous_column in set(column_subset) - set(schema.columns):
157
+ errors.append(
158
+ ErrorWrapper(
159
+ SuperfluousColumnsError("Superfluous column"),
160
+ loc=superfluous_column,
161
+ )
132
162
  )
133
- )
134
163
 
135
164
  # Check if any non-optional columns have null values
136
- for column in schema.non_nullable_columns.intersection(dataframe.columns):
165
+ for column in schema.non_nullable_columns.intersection(column_subset):
137
166
  num_missing_values = dataframe.get_column(name=column).null_count()
138
167
  if num_missing_values:
139
168
  errors.append(
@@ -147,10 +176,12 @@ def _find_errors( # noqa: C901
147
176
  )
148
177
 
149
178
  for column, dtype in schema.dtypes.items():
179
+ if column not in column_subset:
180
+ continue
150
181
  if not isinstance(dtype, pl.List):
151
182
  continue
152
183
 
153
- annotation = schema.__annotations__[column] # type: ignore[unreachable]
184
+ annotation = schema.model_fields[column].annotation # type: ignore[unreachable]
154
185
 
155
186
  # Retrieve the annotation of the list itself,
156
187
  # dewrapping any potential Optional[...]
@@ -158,7 +189,7 @@ def _find_errors( # noqa: C901
158
189
 
159
190
  # Check if the list items themselves should be considered nullable
160
191
  item_type = get_args(list_type)[0]
161
- if _is_optional(item_type):
192
+ if is_optional(item_type):
162
193
  continue
163
194
 
164
195
  num_missing_values = (
@@ -189,7 +220,8 @@ def _find_errors( # noqa: C901
189
220
  valid_dtypes = schema.valid_dtypes
190
221
  dataframe_datatypes = dict(zip(dataframe.columns, dataframe.dtypes))
191
222
  for column_name, column_properties in schema._schema_properties().items():
192
- if column_name not in dataframe.columns:
223
+ column_info = schema.column_infos[column_name]
224
+ if column_name not in dataframe.columns or column_name not in column_subset:
193
225
  continue
194
226
 
195
227
  polars_type = dataframe_datatypes[column_name]
@@ -204,23 +236,16 @@ def _find_errors( # noqa: C901
204
236
  )
205
237
 
206
238
  # Test for when only specific values are accepted
207
- if "enum" in column_properties:
208
- permissible_values = set(column_properties["enum"])
209
- if column_name in schema.nullable_columns:
210
- permissible_values.add(None)
211
- actual_values = set(dataframe[column_name].unique())
212
- impermissible_values = actual_values - permissible_values
213
- if impermissible_values:
214
- errors.append(
215
- ErrorWrapper(
216
- RowValueError(
217
- f"Rows with invalid values: {impermissible_values}."
218
- ),
219
- loc=column_name,
220
- )
221
- )
239
+ e = _find_enum_errors(
240
+ df=dataframe,
241
+ column_name=column_name,
242
+ props=column_properties,
243
+ schema=schema,
244
+ )
245
+ if e is not None:
246
+ errors.append(e)
222
247
 
223
- if column_properties.get("unique", False):
248
+ if column_info.unique:
224
249
  # Coalescing to 0 in the case of dataframe of height 0
225
250
  num_duplicated = dataframe[column_name].is_duplicated().sum() or 0
226
251
  if num_duplicated > 0:
@@ -231,47 +256,117 @@ def _find_errors( # noqa: C901
231
256
  )
232
257
  )
233
258
 
259
+ # Intercept struct columns, and process errors separately
260
+ if schema.dtypes[column_name] == pl.Struct:
261
+ nested_schema = schema.model_fields[column_name].annotation
262
+
263
+ # Additional unpack required if structs column is optional
264
+ if type(nested_schema) == _UnionGenericAlias:
265
+ nested_schema = nested_schema.__args__[0]
266
+
267
+ # We need to filter out any null rows as the submodel won't know
268
+ # that all of a row's columns may be null
269
+ dataframe = dataframe.filter(pl.col(column_name).is_not_null())
270
+ if dataframe.is_empty():
271
+ continue
272
+
273
+ struct_errors = _find_errors(
274
+ dataframe=dataframe.select(column_name).unnest(column_name),
275
+ schema=nested_schema,
276
+ )
277
+
278
+ # Format nested errors
279
+ for error in struct_errors:
280
+ error._loc = f"{column_name}.{error._loc}"
281
+
282
+ errors.extend(struct_errors)
283
+
284
+ # No need to do any more checks
285
+ continue
286
+
287
+ # Intercept list of structs columns, and process errors separately
288
+ elif schema.dtypes[column_name] == pl.List(pl.Struct):
289
+ nested_schema = schema.model_fields[column_name].annotation.__args__[0]
290
+
291
+ # Additional unpack required if structs column is optional
292
+ if type(nested_schema) == _UnionGenericAlias:
293
+ nested_schema = nested_schema.__args__[0]
294
+
295
+ # We need to filter out any null rows as the submodel won't know
296
+ # that all of a row's columns may be null
297
+ dataframe = dataframe.filter(pl.col(column_name).is_not_null())
298
+ if dataframe.is_empty():
299
+ continue
300
+
301
+ list_struct_errors = _find_errors(
302
+ dataframe=dataframe.select(column_name)
303
+ .explode(column_name)
304
+ .unnest(column_name),
305
+ schema=nested_schema,
306
+ )
307
+
308
+ # Format nested errors
309
+ for error in list_struct_errors:
310
+ error._loc = f"{column_name}.{error._loc}"
311
+
312
+ errors.extend(list_struct_errors)
313
+
314
+ # No need to do any more checks
315
+ continue
316
+
234
317
  # Check for bounded value fields
235
318
  col = pl.col(column_name)
236
319
  filters = {
237
- "maximum": lambda v: col <= v,
238
- "exclusiveMaximum": lambda v: col < v,
239
- "minimum": lambda v: col >= v,
240
- "exclusiveMinimum": lambda v: col > v,
241
- "multipleOf": lambda v: (col == 0) | ((col % v) == 0),
242
- "const": lambda v: col == v,
243
- "pattern": lambda v: col.str.contains(v),
244
- "minLength": lambda v: col.str.lengths() >= v,
245
- "maxLength": lambda v: col.str.lengths() <= v,
320
+ "maximum": lambda v, col=col: col <= v,
321
+ "exclusiveMaximum": lambda v, col=col: col < v,
322
+ "minimum": lambda v, col=col: col >= v,
323
+ "exclusiveMinimum": lambda v, col=col: col > v,
324
+ "multipleOf": lambda v, col=col: (col == 0) | ((col % v) == 0),
325
+ "const": lambda v, col=col: col == v,
326
+ "pattern": lambda v, col=col: col.str.contains(v),
327
+ "minLength": lambda v, col=col: col.str.len_chars() >= v,
328
+ "maxLength": lambda v, col=col: col.str.len_chars() <= v,
246
329
  }
247
- checks = [
330
+ if "anyOf" in column_properties:
331
+ checks = [
332
+ check(x[key])
333
+ for key, check in filters.items()
334
+ for x in column_properties["anyOf"]
335
+ if key in x
336
+ ]
337
+ else:
338
+ checks = []
339
+ checks += [
248
340
  check(column_properties[key])
249
341
  for key, check in filters.items()
250
342
  if key in column_properties
251
343
  ]
252
344
  if checks:
253
- lazy_df = dataframe.lazy()
345
+ n_invalid_rows = 0
254
346
  for check in checks:
255
- lazy_df = lazy_df.filter(check)
256
- valid_rows = lazy_df.collect()
257
- invalid_rows = dataframe.height - valid_rows.height
258
- if invalid_rows > 0:
347
+ lazy_df = dataframe.lazy()
348
+ lazy_df = lazy_df.filter(
349
+ ~check
350
+ ) # get failing rows (nulls will evaluate to null on boolean check, we only want failures (false)))
351
+ invalid_rows = lazy_df.collect()
352
+ n_invalid_rows += invalid_rows.height
353
+ if n_invalid_rows > 0:
259
354
  errors.append(
260
355
  ErrorWrapper(
261
356
  RowValueError(
262
- f"{invalid_rows} row{'' if invalid_rows == 1 else 's'} "
357
+ f"{n_invalid_rows} row{'' if n_invalid_rows == 1 else 's'} "
263
358
  "with out of bound values."
264
359
  ),
265
360
  loc=column_name,
266
361
  )
267
362
  )
268
363
 
269
- if "constraints" in column_properties:
270
- custom_constraints = column_properties["constraints"]
364
+ if column_info.constraints is not None:
365
+ custom_constraints = column_info.constraints
271
366
  if isinstance(custom_constraints, pl.Expr):
272
367
  custom_constraints = [custom_constraints]
273
- constraints = pl.all_horizontal(
274
- [constraint.is_not() for constraint in custom_constraints]
368
+ constraints = pl.any_horizontal(
369
+ [constraint.not_() for constraint in custom_constraints]
275
370
  )
276
371
  if "_" in constraints.meta.root_names():
277
372
  # An underscore is an alias for the current field
@@ -295,24 +390,62 @@ def _find_errors( # noqa: C901
295
390
  return errors
296
391
 
297
392
 
393
+ def _find_enum_errors(
394
+ df: pl.DataFrame, column_name: str, props: dict[str, Any], schema: Type[Model]
395
+ ) -> ErrorWrapper | None:
396
+ if "enum" not in props:
397
+ if "items" in props and "enum" in props["items"]:
398
+ return _find_enum_errors(df, column_name, props["items"], schema)
399
+ return None
400
+ permissible_values = set(props["enum"])
401
+ if column_name in schema.nullable_columns:
402
+ permissible_values.add(None)
403
+ if isinstance(df[column_name].dtype, pl.List):
404
+ actual_values = set(df[column_name].explode().unique())
405
+ else:
406
+ actual_values = set(df[column_name].unique())
407
+ impermissible_values = actual_values - permissible_values
408
+ if impermissible_values:
409
+ return ErrorWrapper(
410
+ RowValueError(f"Rows with invalid values: {impermissible_values}."),
411
+ loc=column_name,
412
+ )
413
+ return None
414
+
415
+
298
416
  def validate(
299
- dataframe: Union["pd.DataFrame", pl.DataFrame], schema: Type[Model]
417
+ dataframe: Union["pd.DataFrame", pl.DataFrame],
418
+ schema: Type[Model],
419
+ columns: Optional[Sequence[str]] = None,
420
+ allow_missing_columns: bool = False,
421
+ allow_superfluous_columns: bool = False,
300
422
  ) -> None:
301
- """
302
- Validate the given dataframe.
423
+ """Validate the given dataframe.
303
424
 
304
425
  Args:
305
426
  dataframe: Polars DataFrame to be validated.
306
427
  schema: Patito model which specifies how the dataframe should be structured.
428
+ columns: Optional list of columns to validate. If not provided, all columns
429
+ of the dataframe will be validated.
430
+ allow_missing_columns: If True, missing columns will not be considered an error.
431
+ allow_superfluous_columns: If True, additional columns will not be considered an error.
307
432
 
308
433
  Raises:
309
- ValidationError: If the given dataframe does not match the given schema.
434
+ DataFrameValidationError: If the given dataframe does not match the given schema.
435
+
310
436
  """
311
437
  if _PANDAS_AVAILABLE and isinstance(dataframe, pd.DataFrame):
312
438
  polars_dataframe = pl.from_pandas(dataframe)
313
439
  else:
314
440
  polars_dataframe = cast(pl.DataFrame, dataframe)
315
441
 
316
- errors = _find_errors(dataframe=polars_dataframe, schema=schema)
442
+ polars_dataframe = _transform_df(polars_dataframe, schema)
443
+ errors = _find_errors(
444
+ dataframe=polars_dataframe,
445
+ schema=schema,
446
+ columns=columns,
447
+ allow_missing_columns=allow_missing_columns,
448
+ allow_superfluous_columns=allow_superfluous_columns,
449
+ )
317
450
  if errors:
318
- raise ValidationError(errors=errors, model=schema)
451
+ raise DataFrameValidationError(errors=errors, model=schema)
@@ -1,29 +1,26 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: patito
3
- Version: 0.5.1
3
+ Version: 0.6.2
4
4
  Summary: A dataframe modelling library built on top of polars and pydantic.
5
- Home-page: https://github.com/kolonialno/patito
5
+ Home-page: https://github.com/JakobGM/patito
6
6
  License: MIT
7
7
  Keywords: validation,dataframe
8
8
  Author: Jakob Gerhard Martinussen
9
9
  Author-email: jakobgm@gmail.com
10
- Requires-Python: >=3.8,<4.0
10
+ Requires-Python: >=3.9
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.8
14
13
  Classifier: Programming Language :: Python :: 3.9
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Provides-Extra: caching
18
17
  Provides-Extra: docs
19
- Provides-Extra: duckdb
20
18
  Provides-Extra: pandas
21
19
  Requires-Dist: Sphinx (<7) ; extra == "docs"
22
- Requires-Dist: duckdb (>=0.6.0) ; (python_version >= "3.8" and python_version < "4.0") and (extra == "duckdb")
23
- Requires-Dist: pandas ; (python_version >= "3.8" and python_version < "4.0") and (extra == "pandas")
24
- Requires-Dist: polars (>=0.18.7)
25
- Requires-Dist: pyarrow (>=5.0.0) ; (python_version >= "3.8" and python_version < "4.0") and (extra == "caching" or extra == "duckdb")
26
- Requires-Dist: pydantic (>=1.7.0,<2.0.0)
20
+ Requires-Dist: pandas ; extra == "pandas"
21
+ Requires-Dist: polars (>=0.20.1)
22
+ Requires-Dist: pyarrow (>=5.0.0) ; extra == "caching"
23
+ Requires-Dist: pydantic (>=2.4.1)
27
24
  Requires-Dist: sphinx-autobuild ; extra == "docs"
28
25
  Requires-Dist: sphinx-autodoc-typehints ; extra == "docs"
29
26
  Requires-Dist: sphinx-rtd-theme ; extra == "docs"
@@ -31,10 +28,10 @@ Requires-Dist: sphinx-toolbox ; extra == "docs"
31
28
  Requires-Dist: sphinxcontrib-mermaid ; extra == "docs"
32
29
  Requires-Dist: typing-extensions
33
30
  Project-URL: Documentation, https://patito.readthedocs.io
34
- Project-URL: Repository, https://github.com/kolonialno/patito
31
+ Project-URL: Repository, https://github.com/JakobGM/patito
35
32
  Description-Content-Type: text/markdown
36
33
 
37
- # <center><img height="30px" src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/samsung/78/duck_1f986.png"> Patito<center>
34
+ # <center><img height="30px" src="https://em-content.zobj.net/thumbs/120/samsung/78/duck_1f986.png"> Patito<center>
38
35
 
39
36
  <p align="center">
40
37
  <em>
@@ -66,7 +63,6 @@ These schema can be used for:
66
63
  🧪 Easy generation of valid mock data frames for tests.\
67
64
  🐍 Retrieve and represent singular rows in an object-oriented manner.\
68
65
  🧠 Provide a single source of truth for the core data models in your code base. \
69
- 🦆 Integration with DuckDB for running flexible SQL queries.
70
66
 
71
67
  Patito has first-class support for [polars]("https://github.com/pola-rs/polars"), a _"blazingly fast DataFrames library written in Rust"_.
72
68
 
@@ -76,16 +72,6 @@ Patito has first-class support for [polars]("https://github.com/pola-rs/polars")
76
72
  pip install patito
77
73
  ```
78
74
 
79
- #### DuckDB Integration
80
-
81
- Patito can also integrate with [DuckDB](https://duckdb.org/).
82
- In order to enable this integration you must explicitly specify it during installation:
83
-
84
- ```sh
85
- pip install 'patito[duckdb]'
86
- ```
87
-
88
-
89
75
  ## Documentation
90
76
 
91
77
  The full documentation of Patio can be found [here](https://patito.readthedocs.io).
@@ -96,7 +82,7 @@ Patito allows you to specify the type of each column in your dataframe by creati
96
82
 
97
83
  ```py
98
84
  # models.py
99
- from typing import Literal, Optional
85
+ from typing import Literal
100
86
 
101
87
  import patito as pt
102
88
 
@@ -121,7 +107,7 @@ df = pl.DataFrame(
121
107
  )
122
108
  try:
123
109
  Product.validate(df)
124
- except pt.ValidationError as exc:
110
+ except pt.exceptions.DataFrameValidationError as exc:
125
111
  print(exc)
126
112
  # 3 validation errors for Product
127
113
  # is_for_sale
@@ -167,7 +153,7 @@ def num_products_for_sale(products: pl.DataFrame) -> int:
167
153
  return products.filter(pl.col("is_for_sale")).height
168
154
  ```
169
155
 
170
- The following test would fail with a `patito.ValidationError`:
156
+ The following test would fail with a `patito.exceptions.DataFrameValidationError`:
171
157
 
172
158
  ```py
173
159
  def test_num_products_for_sale():
@@ -0,0 +1,17 @@
1
+ patito/__init__.py,sha256=4qD13kfoa85_kyTCChm3xQcKKzIy3G8AZQp8T_bjcmo,844
2
+ patito/_docs.py,sha256=9mfttyylWpqaOZv8xfDMEwCHHaY7GQwfyI7CDg7tWe8,162
3
+ patito/_pydantic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ patito/_pydantic/column_info.py,sha256=zy3z0gCdQZhNA3_eQ9mEf3Di-gOR8Gt4vmS3v2iULkc,3536
5
+ patito/_pydantic/dtypes/__init__.py,sha256=2vTvL4N4yMN0cbv2CSoa1OFwCswx6FhuQdsYhMaz_dU,578
6
+ patito/_pydantic/dtypes/dtypes.py,sha256=nHtgyI0LsvA_hEIELUxtS4JnDwutqem7iT6nxMDLJxc,9510
7
+ patito/_pydantic/dtypes/utils.py,sha256=6g2mVVSYCs0LSqiPlc4D2Wm3X2gm8sKJnXZYcthfabY,7017
8
+ patito/_pydantic/repr.py,sha256=l9WLjwJ85nJwZCxLIwHih7UuMVVgz17W5th_UD7XZAM,4341
9
+ patito/_pydantic/schema.py,sha256=1XLByZ1jJVP7PUNTkoSPDo0D_hy8QncNLjXKV2N0XDE,3622
10
+ patito/exceptions.py,sha256=VfkkpLblu2Go4QnfWwew7g1NJ_gmynv28p-eGH84tLs,6060
11
+ patito/polars.py,sha256=iAnMFfVyJfSdHantESrIdaX6tZDWj71jyWBery325ac,35333
12
+ patito/pydantic.py,sha256=1gyPfo8-68sdy26yC8c7CQhk_9Mmr0KRsyuS4g54Ddw,48685
13
+ patito/validators.py,sha256=d7lu3MBqaaLLvBVMd5BgarLYpGYHMeJEuTSGAoYqDf0,16231
14
+ patito-0.6.2.dist-info/LICENSE,sha256=3bc4YyuF0e5nd59E3CsR8QM1Ua7pqKfC9DD1LVBVMs4,1139
15
+ patito-0.6.2.dist-info/METADATA,sha256=vUijDkEO0zT5uxED3sN3fTvgsFEIojFYFcENJ1u9_cA,13947
16
+ patito-0.6.2.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
17
+ patito-0.6.2.dist-info/RECORD,,