Skip to content

Commit 4cd8fdd

Browse files
authored
Merge pull request #69 from vertti/feat/new-string-checks
Add new value checks for 2.1.0
2 parents 0b78719 + 42bca0c commit 4cd8fdd

5 files changed

Lines changed: 130 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## 2.1.0
6+
7+
### New Features
8+
9+
- Added new value checks: `notin`, `str_startswith`, `str_endswith`, `str_contains`, `str_length`
10+
511
## 2.0.2
612

713
### Documentation

daffy/checks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,15 @@ def apply_check(series: Any, check_name: str, check_value: Any, max_samples: int
3131
"eq": lambda: series != check_value,
3232
"ne": lambda: series == check_value,
3333
"isin": lambda: nw.to_native(~nws.is_in(check_value)),
34+
"notin": lambda: nw.to_native(nws.is_in(check_value)),
3435
"notnull": lambda: nw.to_native(nws.is_null()),
3536
"str_regex": lambda: nw.to_native(~nws.str.contains(f"^(?:{check_value})")),
37+
"str_startswith": lambda: nw.to_native(~nws.str.starts_with(check_value)),
38+
"str_endswith": lambda: nw.to_native(~nws.str.ends_with(check_value)),
39+
"str_contains": lambda: nw.to_native(~nws.str.contains(check_value, literal=True)),
40+
"str_length": lambda: nw.to_native(
41+
~((nws.str.len_chars() >= check_value[0]) & (nws.str.len_chars() <= check_value[1]))
42+
),
3643
}
3744

3845
if check_name not in check_masks:

docs/advanced.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,13 @@ def process_data(df):
206206
| `ne` | value | Not equal to | `{"ne": "deleted"}` |
207207
| `between` | (lo, hi) | Value in range (inclusive) | `{"between": (0, 100)}` |
208208
| `isin` | list | Value in set | `{"isin": ["a", "b", "c"]}` |
209+
| `notin` | list | Value not in set | `{"notin": ["x", "y"]}` |
209210
| `notnull` | True | No null values | `{"notnull": True}` |
210211
| `str_regex` | pattern | String matches regex | `{"str_regex": r"^\d+$"}` |
212+
| `str_startswith` | string | String starts with prefix | `{"str_startswith": "pre_"}` |
213+
| `str_endswith` | string | String ends with suffix | `{"str_endswith": ".csv"}` |
214+
| `str_contains` | string | String contains substring | `{"str_contains": "@"}` |
215+
| `str_length` | (min, max) | String length in range | `{"str_length": (1, 100)}` |
211216

212217
### Multiple Checks
213218

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "daffy"
3-
version = "2.0.2"
3+
version = "2.1.0"
44
description = "Function decorators for DataFrame validation - columns, data types, and row-level validation with Pydantic. Supports Pandas, Polars, Modin, and PyArrow."
55
authors = [
66
{ name="Janne Sinivirta", email="janne.sinivirta@gmail.com" },

tests/test_checks.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,30 @@ def test_isin_with_numbers(self) -> None:
132132
assert samples == [99]
133133

134134

135+
class TestNotinCheck:
136+
def test_notin_passes(self) -> None:
137+
series = pd.Series(["a", "b", "c"])
138+
fail_count, samples = apply_check(series, "notin", ["x", "y", "z"])
139+
assert fail_count == 0
140+
141+
def test_notin_fails(self) -> None:
142+
series = pd.Series(["a", "b", "x"])
143+
fail_count, samples = apply_check(series, "notin", ["x", "y", "z"])
144+
assert fail_count == 1
145+
assert samples == ["x"]
146+
147+
def test_notin_with_numbers(self) -> None:
148+
series = pd.Series([1, 2, 3])
149+
fail_count, samples = apply_check(series, "notin", [3, 4, 5])
150+
assert fail_count == 1
151+
assert samples == [3]
152+
153+
def test_notin_all_forbidden(self) -> None:
154+
series = pd.Series(["x", "y", "z"])
155+
fail_count, samples = apply_check(series, "notin", ["x", "y", "z"])
156+
assert fail_count == 3
157+
158+
135159
class TestNotnullCheck:
136160
def test_notnull_passes(self) -> None:
137161
series = pd.Series([1, 2, 3])
@@ -164,6 +188,93 @@ def test_str_regex_email_pattern(self) -> None:
164188
assert samples == ["invalid"]
165189

166190

191+
class TestStrStartswithCheck:
192+
def test_str_startswith_passes(self) -> None:
193+
series = pd.Series(["hello", "hi", "hey"])
194+
fail_count, samples = apply_check(series, "str_startswith", "h")
195+
assert fail_count == 0
196+
197+
def test_str_startswith_fails(self) -> None:
198+
series = pd.Series(["hello", "world"])
199+
fail_count, samples = apply_check(series, "str_startswith", "h")
200+
assert fail_count == 1
201+
assert samples == ["world"]
202+
203+
def test_str_startswith_prefix(self) -> None:
204+
series = pd.Series(["pre_name", "pre_value", "other"])
205+
fail_count, samples = apply_check(series, "str_startswith", "pre_")
206+
assert fail_count == 1
207+
assert samples == ["other"]
208+
209+
210+
class TestStrEndswithCheck:
211+
def test_str_endswith_passes(self) -> None:
212+
series = pd.Series(["test.py", "main.py"])
213+
fail_count, samples = apply_check(series, "str_endswith", ".py")
214+
assert fail_count == 0
215+
216+
def test_str_endswith_fails(self) -> None:
217+
series = pd.Series(["test.py", "readme.md"])
218+
fail_count, samples = apply_check(series, "str_endswith", ".py")
219+
assert fail_count == 1
220+
assert samples == ["readme.md"]
221+
222+
223+
class TestStrContainsCheck:
224+
def test_str_contains_passes(self) -> None:
225+
series = pd.Series(["hello world", "world peace"])
226+
fail_count, samples = apply_check(series, "str_contains", "world")
227+
assert fail_count == 0
228+
229+
def test_str_contains_fails(self) -> None:
230+
series = pd.Series(["hello", "goodbye"])
231+
fail_count, samples = apply_check(series, "str_contains", "world")
232+
assert fail_count == 2
233+
234+
def test_str_contains_literal_not_regex(self) -> None:
235+
# Ensure . is treated as literal, not regex wildcard
236+
series = pd.Series(["a.b", "axb"])
237+
fail_count, samples = apply_check(series, "str_contains", ".")
238+
assert fail_count == 1
239+
assert samples == ["axb"]
240+
241+
def test_str_contains_at_symbol(self) -> None:
242+
series = pd.Series(["user@example.com", "no-at-here"])
243+
fail_count, samples = apply_check(series, "str_contains", "@")
244+
assert fail_count == 1
245+
assert samples == ["no-at-here"]
246+
247+
248+
class TestStrLengthCheck:
249+
def test_str_length_passes(self) -> None:
250+
series = pd.Series(["ab", "abc", "abcd"])
251+
fail_count, samples = apply_check(series, "str_length", (2, 4))
252+
assert fail_count == 0
253+
254+
def test_str_length_fails_too_short(self) -> None:
255+
series = pd.Series(["a", "abc"])
256+
fail_count, samples = apply_check(series, "str_length", (2, 4))
257+
assert fail_count == 1
258+
assert samples == ["a"]
259+
260+
def test_str_length_fails_too_long(self) -> None:
261+
series = pd.Series(["abc", "abcdef"])
262+
fail_count, samples = apply_check(series, "str_length", (2, 4))
263+
assert fail_count == 1
264+
assert samples == ["abcdef"]
265+
266+
def test_str_length_inclusive(self) -> None:
267+
series = pd.Series(["ab", "abcd"])
268+
fail_count, samples = apply_check(series, "str_length", (2, 4))
269+
assert fail_count == 0
270+
271+
def test_str_length_exact(self) -> None:
272+
# Can use same min/max for exact length
273+
series = pd.Series(["abc", "ab", "abcd"])
274+
fail_count, samples = apply_check(series, "str_length", (3, 3))
275+
assert fail_count == 2
276+
277+
167278
class TestMaxSamples:
168279
def test_max_samples_limits_returned_values(self) -> None:
169280
series = pd.Series([0, -1, -2, -3, -4, -5, -6, -7, -8, -9])

0 commit comments

Comments
 (0)