From d2149ab6203c266500f3bdf5af9b3e391ed0d647 Mon Sep 17 00:00:00 2001 From: suhavani Date: Fri, 15 May 2026 17:58:18 +0530 Subject: [PATCH 1/7] feat: add is_empty convenience property to ArFrame --- arnio/frame.py | 36 +++++++++++++-------------- arnio/schema.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_frame.py | 22 ++++++++++++++++- tests/test_schema.py | 44 +++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 19 deletions(-) diff --git a/arnio/frame.py b/arnio/frame.py index 45a868092..9e2638481 100644 --- a/arnio/frame.py +++ b/arnio/frame.py @@ -320,6 +320,24 @@ def dtypes(self) -> dict[str, str]: Mapping of column names to their data types. """ return self._frame.dtypes() + + @property + def is_empty(self) -> bool: + """Check if frame has zero rows. + + Returns + ------- + bool + True if frame contains no rows, False otherwise. + + Examples + -------- + >>> frame = ar.read_csv("data.csv") + >>> if frame.is_empty: + ... print("No data to process") + False + """ + return len(self) == 0 @property def schema_summary(self) -> list[ColumnSummary]: @@ -357,24 +375,6 @@ def schema_summary(self) -> list[ColumnSummary]: ) return result - @property - def is_empty(self) -> bool: - """Check if frame has zero rows. - - Returns - ------- - bool - True if frame contains no rows, False otherwise. - - Examples - -------- - >>> frame = ar.read_csv("data.csv") - >>> if frame.is_empty: - ... print("No data to process") - False - """ - return len(self) == 0 - # --- Methods --- def memory_usage(self) -> int: diff --git a/arnio/schema.py b/arnio/schema.py index 2bce7382f..b7d047273 100644 --- a/arnio/schema.py +++ b/arnio/schema.py @@ -3080,3 +3080,62 @@ def Custom( severity=severity, required_if=required_if, ) + + +def Choice( + allowed: set[Any] | list[Any] | tuple[Any, ...], + *, + nullable: bool = True, + unique: bool = False, + severity: str = "error", + required_if: tuple[str, Any] | None = None, +) -> Field: + """Create a categorical schema field restricted to an explicit set of allowed values. + + Parameters + ---------- + allowed : set, list, or tuple + The permitted values for this column. Must be non-empty. + nullable : bool, default True + Whether null values are allowed. + unique : bool, default False + Whether all non-null values must be unique. + severity : str, default "error" + Severity level for validation issues. + required_if : tuple[str, Any] or None, default None + Conditional requirement as a column/value pair. + + Raises + ------ + ValueError + If the allowed set is empty. + TypeError + If allowed is a bare string or not a valid sequence. + + Examples + -------- + >>> schema = ar.Schema({ + ... "status": ar.Choice(["active", "inactive", "pending"]), + ... }) + """ + if isinstance(allowed, (str, bytes)): + raise TypeError( + "allowed must be a sequence of values, not a bare string" + ) + + if not isinstance(allowed, (set, list, tuple)): + raise TypeError( + f"allowed must be a list, tuple, or set, got {type(allowed).__name__}" + ) + + if len(allowed) == 0: + raise ValueError("allowed must contain at least one value") + + return Field( + dtype="string", + nullable=nullable, + allowed=set(allowed), + unique=unique, + required_if=required_if, + severity=severity, + ) \ No newline at end of file diff --git a/tests/test_frame.py b/tests/test_frame.py index 2cefb1cff..f83f42791 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -1,3 +1,4 @@ + """ Tests for ArFrame.preview() """ @@ -394,7 +395,6 @@ def fail_to_pandas(_): assert list(df.columns) == ["salary", "name"] - def test_select_columns_null_nan_handling(): df = pd.DataFrame( { @@ -669,6 +669,7 @@ def test_is_empty_true(self, tmp_path): csv_path = tmp_path / "empty.csv" csv_path.write_text("name,age\n") # Header only, no data rows + frame = ar.read_csv(str(csv_path)) assert frame.is_empty is True assert len(frame) == 0 @@ -1713,3 +1714,22 @@ def test_from_records_dict_explicit_valid_columns_subset(): assert frame.shape == (2, 1) assert frame.columns == ["a"] assert frame["a"] == [1, 3] + +# ── is_empty ────────────────────────────────────────────────────────────────── + + +def test_is_empty_returns_false_when_frame_has_rows(): + frame = ar.from_dict({"name": ["Alice", "Bob"], "age": [25, 30]}) + assert frame.is_empty is False + + +def test_is_empty_returns_true_for_empty_frame(): + import pandas as pd + df = pd.DataFrame(columns=["name", "age"]) + frame = ar.from_pandas(df) + assert frame.is_empty is True + + +def test_is_empty_single_row_is_not_empty(): + frame = ar.from_dict({"name": ["Alice"], "age": [25]}) + assert frame.is_empty is False \ No newline at end of file diff --git a/tests/test_schema.py b/tests/test_schema.py index 81d94a552..81cd99ad8 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -4260,3 +4260,47 @@ def test_from_json_round_trip_is_accepted(): recovered = ar.Schema.from_json(original.to_json()) assert recovered.fields["email"].nullable is False assert recovered.unique == ["email"] + + +# --------------------------------------------------------------------------- +# Choice field helper +# --------------------------------------------------------------------------- + + +def test_choice_valid_values(): + import pandas as pd + + df = pd.DataFrame({"status": ["active", "inactive", "pending"]}) + frame = ar.from_pandas(df) + schema = ar.Schema({"status": ar.Choice(["active", "inactive", "pending"])}) + result = ar.validate(frame, schema) + assert result.passed + assert result.issue_count == 0 + + +def test_choice_invalid_values(): + import pandas as pd + + df = pd.DataFrame({"status": ["active", "unknown", "pending"]}) + frame = ar.from_pandas(df) + schema = ar.Schema({"status": ar.Choice(["active", "inactive", "pending"])}) + result = ar.validate(frame, schema) + assert not result.passed + assert result.issue_count == 1 + assert result.issues[0].rule == "allowed" + assert result.issues[0].row_index == 2 + + +def test_choice_empty_allowed_raises(): + with pytest.raises(ValueError, match="allowed must contain at least one value"): + ar.Choice([]) + + +def test_choice_bare_string_raises(): + with pytest.raises(TypeError, match="allowed must be a sequence"): + ar.Choice("active") + + +def test_choice_accepts_set_and_tuple(): + assert ar.Choice({"active", "inactive"}) is not None + assert ar.Choice(("active", "inactive")) is not None \ No newline at end of file From eb9dfb266616052b629923d6543016ad72bad660 Mon Sep 17 00:00:00 2001 From: suhavani Date: Sat, 16 May 2026 00:16:06 +0530 Subject: [PATCH 2/7] ci: trigger checks From 3adc8fe35243069b981dfa4d71a7ce30b6e589c3 Mon Sep 17 00:00:00 2001 From: suhavani Date: Mon, 18 May 2026 08:07:02 +0530 Subject: [PATCH 3/7] feat: add Choice field validator for categorical columns --- arnio/__init__.py | 2 ++ arnio/schema.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_schema.py | 44 -------------------------------------------- 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/arnio/__init__.py b/arnio/__init__.py index b5a7ff794..39fc0f014 100644 --- a/arnio/__init__.py +++ b/arnio/__init__.py @@ -85,6 +85,7 @@ from .schema import ( URL, Bool, + Choice, CountryCode, CurrencyCode, Custom, @@ -203,6 +204,7 @@ "TimeZone", "Bool", "Email", + "Choice", "URL", "PhoneNumber", "DateTime", diff --git a/arnio/schema.py b/arnio/schema.py index b7d047273..1cf98cf6c 100644 --- a/arnio/schema.py +++ b/arnio/schema.py @@ -2077,6 +2077,7 @@ def URL( ) +<<<<<<< HEAD def PhoneNumber( *, nullable: bool = True, @@ -2123,6 +2124,41 @@ def CountryCode( Returns: Field: Configured uppercase ISO alpha-2 country-code schema field. """ +======= +def Choice( + allowed: set[Any] | list[Any] | tuple[Any, ...], + *, + nullable: bool = True, +) -> Field: + """Create a categorical schema field restricted to explicit allowed values. + + Parameters + ---------- + allowed : set, list, or tuple + The set of permitted values for this column. + nullable : bool, optional + Whether null values are permitted. Defaults to True. + + Returns + ------- + Field + A Field configured to validate against the allowed values. + + Examples + -------- + >>> schema = ar.Schema({"status": ar.Choice(["active", "inactive", "pending"])}) + >>> result = ar.validate(frame, schema) + """ + if not allowed: + raise ValueError("allowed values cannot be empty") + return Field( + nullable=nullable, + allowed=set(allowed), + ) + +def CountryCode(*, nullable: bool = True, unique: bool = False) -> Field: + """Create an uppercase ISO alpha-2 country-code schema field.""" +>>>>>>> 1391c52 (feat: add Choice field validator for categorical columns) return Field( dtype="string", nullable=nullable, diff --git a/tests/test_schema.py b/tests/test_schema.py index 81cd99ad8..81d94a552 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -4260,47 +4260,3 @@ def test_from_json_round_trip_is_accepted(): recovered = ar.Schema.from_json(original.to_json()) assert recovered.fields["email"].nullable is False assert recovered.unique == ["email"] - - -# --------------------------------------------------------------------------- -# Choice field helper -# --------------------------------------------------------------------------- - - -def test_choice_valid_values(): - import pandas as pd - - df = pd.DataFrame({"status": ["active", "inactive", "pending"]}) - frame = ar.from_pandas(df) - schema = ar.Schema({"status": ar.Choice(["active", "inactive", "pending"])}) - result = ar.validate(frame, schema) - assert result.passed - assert result.issue_count == 0 - - -def test_choice_invalid_values(): - import pandas as pd - - df = pd.DataFrame({"status": ["active", "unknown", "pending"]}) - frame = ar.from_pandas(df) - schema = ar.Schema({"status": ar.Choice(["active", "inactive", "pending"])}) - result = ar.validate(frame, schema) - assert not result.passed - assert result.issue_count == 1 - assert result.issues[0].rule == "allowed" - assert result.issues[0].row_index == 2 - - -def test_choice_empty_allowed_raises(): - with pytest.raises(ValueError, match="allowed must contain at least one value"): - ar.Choice([]) - - -def test_choice_bare_string_raises(): - with pytest.raises(TypeError, match="allowed must be a sequence"): - ar.Choice("active") - - -def test_choice_accepts_set_and_tuple(): - assert ar.Choice({"active", "inactive"}) is not None - assert ar.Choice(("active", "inactive")) is not None \ No newline at end of file From b439c71f2cc0a86ce0b35f42d2fb9e826bd189dd Mon Sep 17 00:00:00 2001 From: suhavani Date: Mon, 18 May 2026 08:16:44 +0530 Subject: [PATCH 4/7] fix: ensure two blank lines after Choice function --- arnio/schema.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/arnio/schema.py b/arnio/schema.py index 1cf98cf6c..b7d047273 100644 --- a/arnio/schema.py +++ b/arnio/schema.py @@ -2077,7 +2077,6 @@ def URL( ) -<<<<<<< HEAD def PhoneNumber( *, nullable: bool = True, @@ -2124,41 +2123,6 @@ def CountryCode( Returns: Field: Configured uppercase ISO alpha-2 country-code schema field. """ -======= -def Choice( - allowed: set[Any] | list[Any] | tuple[Any, ...], - *, - nullable: bool = True, -) -> Field: - """Create a categorical schema field restricted to explicit allowed values. - - Parameters - ---------- - allowed : set, list, or tuple - The set of permitted values for this column. - nullable : bool, optional - Whether null values are permitted. Defaults to True. - - Returns - ------- - Field - A Field configured to validate against the allowed values. - - Examples - -------- - >>> schema = ar.Schema({"status": ar.Choice(["active", "inactive", "pending"])}) - >>> result = ar.validate(frame, schema) - """ - if not allowed: - raise ValueError("allowed values cannot be empty") - return Field( - nullable=nullable, - allowed=set(allowed), - ) - -def CountryCode(*, nullable: bool = True, unique: bool = False) -> Field: - """Create an uppercase ISO alpha-2 country-code schema field.""" ->>>>>>> 1391c52 (feat: add Choice field validator for categorical columns) return Field( dtype="string", nullable=nullable, From 6f425d8582cfc23d96d7eba196ccc07466d64aa1 Mon Sep 17 00:00:00 2001 From: suhavani Date: Tue, 14 Jul 2026 09:59:41 +0530 Subject: [PATCH 5/7] test: add Choice field validator tests, README, and API docs Adds test coverage for ar.Choice() covering valid/invalid values, empty allowed set, bare string rejection, nullable/unique/severity interactions, and row index reporting. Adds README usage example and website API reference entry for Choice(), fixing the website public-exports doc check that started failing once Choice became a documented public export. Fixes #194 --- README.md | 15 +++++++++ tests/test_schema.py | 74 ++++++++++++++++++++++++++++++++++++++++++++ website/api.html | 6 ++++ 3 files changed, 95 insertions(+) diff --git a/README.md b/README.md index 514fd3f88..c7a6070c3 100644 --- a/README.md +++ b/README.md @@ -1363,6 +1363,21 @@ Accepted examples include: - `+91 9876543210` - `5551234567` +### Choice validation + +`Choice()` restricts a column to an explicit set of allowed values — useful for status flags, categories, or any column with a known, finite set of valid entries. + +```python +schema = ar.Schema({ + "status": ar.Choice(["active", "inactive", "pending"], nullable=False), +}) + +result = schema.validate(frame) +print(result.passed) +``` + +Any value not in the `allowed` set raises an `allowed` validation issue. Pass `unique=True` to also enforce that non-null values are unique, or `severity="warning"` to flag invalid values without failing validation. + ### Warning-only validation ```python diff --git a/tests/test_schema.py b/tests/test_schema.py index 6a4e38a14..b204431dd 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -5161,3 +5161,77 @@ def test_restored_schema_validates_correctly(self, factory, semantic): frame = _frame({"col": [invalid_values[semantic]]}) result = validate(frame, restored) assert not result.passed + + +class TestChoice: + def test_choice_valid_values_pass(self): + schema = ar.Schema({"status": ar.Choice(["active", "inactive", "pending"])}) + frame = ar.from_dict({"status": ["active", "pending", "inactive"]}) + result = schema.validate(frame) + assert result.passed + + def test_choice_invalid_value_fails(self): + schema = ar.Schema({"status": ar.Choice(["active", "inactive"])}) + frame = ar.from_dict({"status": ["active", "unknown"]}) + result = schema.validate(frame) + assert not result.passed + assert any(issue.rule == "allowed" for issue in result.issues) + + def test_choice_reports_correct_row_index(self): + schema = ar.Schema({"status": ar.Choice(["active", "inactive"])}) + frame = ar.from_dict({"status": ["active", "bogus", "inactive"]}) + result = schema.validate(frame) + bad_issue = next(i for i in result.issues if i.rule == "allowed") + assert bad_issue.row_index == 2 # 1-indexed, second row + + def test_choice_empty_allowed_raises_value_error(self): + with pytest.raises(ValueError, match="allowed must contain at least one value"): + ar.Choice([]) + + def test_choice_bare_string_raises_type_error(self): + with pytest.raises(TypeError, match="not a bare string"): + ar.Choice("active") + + def test_choice_invalid_container_type_raises_type_error(self): + with pytest.raises(TypeError): + ar.Choice(123) + + def test_choice_accepts_set_list_tuple(self): + for allowed in (["a", "b"], ("a", "b"), {"a", "b"}): + field = ar.Choice(allowed) + assert field.allowed == {"a", "b"} + + def test_choice_nullable_default_allows_nulls(self): + schema = ar.Schema({"status": ar.Choice(["active", "inactive"])}) + frame = ar.from_dict({"status": ["active", None]}) + result = schema.validate(frame) + assert result.passed + + def test_choice_non_nullable_rejects_nulls(self): + schema = ar.Schema({"status": ar.Choice(["active", "inactive"], nullable=False)}) + frame = ar.from_dict({"status": ["active", None]}) + result = schema.validate(frame) + assert not result.passed + assert any(issue.rule == "nullable" for issue in result.issues) + + def test_choice_unique_flag_detects_duplicates(self): + schema = ar.Schema({"status": ar.Choice(["active", "inactive"], unique=True)}) + frame = ar.from_dict({"status": ["active", "active"]}) + result = schema.validate(frame) + assert not result.passed + assert any(issue.rule == "unique" for issue in result.issues) + + def test_choice_severity_warning_does_not_fail_validation(self): + schema = ar.Schema( + {"status": ar.Choice(["active", "inactive"], severity="warning")} + ) + frame = ar.from_dict({"status": ["active", "unknown"]}) + result = schema.validate(frame) + assert result.passed # only warnings, no errors + assert any(issue.severity == "warning" for issue in result.issues) + + def test_choice_all_values_valid_zero_issues(self): + schema = ar.Schema({"status": ar.Choice(["x", "y", "z"])}) + frame = ar.from_dict({"status": ["x", "y", "z", "x"]}) + result = schema.validate(frame) + assert result.issue_count == 0 \ No newline at end of file diff --git a/website/api.html b/website/api.html index d85667260..8087c6eaa 100644 --- a/website/api.html +++ b/website/api.html @@ -221,6 +221,12 @@

Schema

nullable=True, pattern=None, allowed=None, case_sensitive=True, min_length=None, max_length=None, unique=False, severity="error", required_if=None pattern is a regex applied to every non-null value. allowed accepts a set, list, or tuple — not a bare string. + + Choice(...) + String + nullable=True, unique=False, severity="error", required_if=None + Restricts a column to an explicit non-empty set of allowed values, passed positionally as a list, tuple, or set. Raises ValueError if empty, TypeError for a bare string. + Bool(...) Boolean From ad2efdb3d1f48cdc6e7be5aa3079b77c85cba9d6 Mon Sep 17 00:00:00 2001 From: suhavani Date: Tue, 14 Jul 2026 10:09:40 +0530 Subject: [PATCH 6/7] style: apply black formatting to test_schema.py --- tests/test_schema.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index b204431dd..2b046b5bb 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -5208,7 +5208,9 @@ def test_choice_nullable_default_allows_nulls(self): assert result.passed def test_choice_non_nullable_rejects_nulls(self): - schema = ar.Schema({"status": ar.Choice(["active", "inactive"], nullable=False)}) + schema = ar.Schema( + {"status": ar.Choice(["active", "inactive"], nullable=False)} + ) frame = ar.from_dict({"status": ["active", None]}) result = schema.validate(frame) assert not result.passed @@ -5234,4 +5236,4 @@ def test_choice_all_values_valid_zero_issues(self): schema = ar.Schema({"status": ar.Choice(["x", "y", "z"])}) frame = ar.from_dict({"status": ["x", "y", "z", "x"]}) result = schema.validate(frame) - assert result.issue_count == 0 \ No newline at end of file + assert result.issue_count == 0 From 281779fc650ab91c450f1d5aabf215d8b9a1a1f0 Mon Sep 17 00:00:00 2001 From: suhavani Date: Tue, 14 Jul 2026 10:16:23 +0530 Subject: [PATCH 7/7] style: apply black formatting to frame.py, schema.py, test_frame.py --- arnio/frame.py | 2 +- arnio/schema.py | 6 ++---- tests/test_frame.py | 9 +++++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/arnio/frame.py b/arnio/frame.py index af75dd409..a94e744b3 100644 --- a/arnio/frame.py +++ b/arnio/frame.py @@ -333,7 +333,7 @@ def dtypes(self) -> dict[str, str]: Mapping of column names to their data types. """ return self._frame.dtypes() - + @property def is_empty(self) -> bool: """Check if frame has zero rows. diff --git a/arnio/schema.py b/arnio/schema.py index c0eb20578..a5a967486 100644 --- a/arnio/schema.py +++ b/arnio/schema.py @@ -3662,9 +3662,7 @@ def Choice( ... }) """ if isinstance(allowed, (str, bytes)): - raise TypeError( - "allowed must be a sequence of values, not a bare string" - ) + raise TypeError("allowed must be a sequence of values, not a bare string") if not isinstance(allowed, (set, list, tuple)): raise TypeError( @@ -3681,4 +3679,4 @@ def Choice( unique=unique, required_if=required_if, severity=severity, - ) \ No newline at end of file + ) diff --git a/tests/test_frame.py b/tests/test_frame.py index 5b7733577..ca1ffdd5a 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -1,4 +1,3 @@ - """ Tests for ArFrame.preview() """ @@ -395,6 +394,7 @@ def fail_to_pandas(_): assert list(df.columns) == ["salary", "name"] + def test_select_columns_null_nan_handling(): df = pd.DataFrame( { @@ -669,7 +669,6 @@ def test_is_empty_true(self, tmp_path): csv_path = tmp_path / "empty.csv" csv_path.write_text("name,age\n") # Header only, no data rows - frame = ar.read_csv(str(csv_path)) assert frame.is_empty is True assert len(frame) == 0 @@ -1866,7 +1865,8 @@ def test_astype_rejects_multielement_numpy_array(): # 3. Multi-element NumPy array check with pytest.raises(TypeError, match="dtype must be a string"): frame.astype(np.array([1, 2])) - + + # ── is_empty ────────────────────────────────────────────────────────────────── @@ -1877,6 +1877,7 @@ def test_is_empty_returns_false_when_frame_has_rows(): def test_is_empty_returns_true_for_empty_frame(): import pandas as pd + df = pd.DataFrame(columns=["name", "age"]) frame = ar.from_pandas(df) assert frame.is_empty is True @@ -1884,4 +1885,4 @@ def test_is_empty_returns_true_for_empty_frame(): def test_is_empty_single_row_is_not_empty(): frame = ar.from_dict({"name": ["Alice"], "age": [25]}) - assert frame.is_empty is False \ No newline at end of file + assert frame.is_empty is False