Repository: narwhals-dev/narwhals
Pull request #527 (2024-07-15 15:24:30)

chore: ruff rules
## What type of PR is this? (check all applicable)

- [x] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [x] 🐳 Other

## Related issues 

- Related issue # 
- Closes #

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added 
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below.

Low hanging changes

MarcoGorelli: one annoyance of this `msg = ...; raise Error(msg)` is that then we need extra pragma no covers...maybe we need a utility `issue_assertion_error`, which we then add to the coverage ignores in pyproject.toml and just use that

Patch:
diff --git a/.github/workflows/check_docs_build.yml b/.github/workflows/check_docs_build.yml
new file mode 100644
index 00000000..55635f61
--- /dev/null
+++ b/.github/workflows/check_docs_build.yml
@@ -0,0 +1,40 @@
+name: ci
+
+on:
+  pull_request:
+  push:
+    branches: [main]
+
+jobs:
+  mkdocs:
+    strategy:
+      matrix:
+        python-version: ["3.11"]
+        os: [ubuntu-latest]
+
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Cache multiple paths
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/.cache/pip
+            $RUNNER_TOOL_CACHE/Python/*
+            ~\AppData\Local\pip\Cache
+          key: ${{ runner.os }}-build-${{ matrix.python-version }}
+      - name: install-reqs
+        run: python -m pip install --upgrade tox virtualenv setuptools pip -r requirements-dev.txt
+      - name: install-docs-reqs
+        run: python -m pip install --upgrade -r docs/requirements-docs.txt
+      - name: local-install
+        run: python -m pip install -e .
+      - name: check-no-errors
+        run: python -m mkdocs build > output.txt 2>&1
+      - name: assert-no-errors
+        run: python utils/check_for_no_build_errors.py
+      - name: strict-docs-build
+        run: mkdocs build --strict
diff --git a/docs/levels.md b/docs/levels.md
index d632baa6..bfa580f1 100644
--- a/docs/levels.md
+++ b/docs/levels.md
@@ -28,6 +28,8 @@ level of support. If a library implements the
 a call such as
 
 ```python exec="1" source="above"
+from typing import Any
+
 import narwhals as nw
 from narwhals.schema import Schema
 
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 10696d6d..94dccd0c 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -343,7 +343,8 @@ class ArrowDataFrame:
         )
 
     def clone(self) -> Self:
-        raise NotImplementedError("clone is not yet supported on PyArrow tables")
+        msg = "clone is not yet supported on PyArrow tables"
+        raise NotImplementedError(msg)
 
     def is_empty(self: Self) -> bool:
         return self.shape[0] == 0
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 9635b835..bf3cc7de 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -125,6 +125,9 @@ class ArrowExpr:
     def __rpow__(self, other: ArrowExpr | Any) -> Self:
         return reuse_series_implementation(self, "__rpow__", other)
 
+    def __invert__(self) -> Self:
+        return reuse_series_implementation(self, "__invert__")
+
     def filter(self, *predicates: Any) -> Self:
         from narwhals._arrow.namespace import ArrowNamespace
 
@@ -183,6 +186,9 @@ class ArrowExpr:
     def null_count(self) -> Self:
         return reuse_series_implementation(self, "null_count", returns_scalar=True)
 
+    def is_null(self) -> Self:
+        return reuse_series_implementation(self, "is_null")
+
     def head(self, n: int) -> Self:
         return reuse_series_implementation(self, "head", n)
 
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 5d1ae9a9..b0f2b6ae 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -139,6 +139,10 @@ class ArrowSeries:
         other = validate_column_comparand(other)
         return self._from_native_series(pc.power(other, ser))
 
+    def __invert__(self) -> Self:
+        pc = get_pyarrow_compute()
+        return self._from_native_series(pc.invert(self._native_series))
+
     def filter(self, other: Any) -> Self:
         other = validate_column_comparand(other)
         return self._from_native_series(self._native_series.filter(other))
@@ -229,6 +233,10 @@ class ArrowSeries:
     def is_empty(self) -> bool:
         return len(self) == 0
 
+    def is_null(self) -> Self:
+        ser = self._native_series
+        return self._from_native_series(ser.is_null())
+
     def cast(self, dtype: DType) -> Self:
         pc = get_pyarrow_compute()
         ser = self._native_series
diff --git a/narwhals/_interchange/dataframe.py b/narwhals/_interchange/dataframe.py
index 16c8c54d..c7fc5ea3 100644
--- a/narwhals/_interchange/dataframe.py
+++ b/narwhals/_interchange/dataframe.py
@@ -34,7 +34,8 @@ def map_interchange_dtype_to_narwhals_dtype(
             return dtypes.Int16()
         if interchange_dtype[1] == 8:
             return dtypes.Int8()
-        raise AssertionError("Invalid bit width for INT")
+        msg = "Invalid bit width for INT"  # pragma: no cover
+        raise AssertionError(msg)
     if interchange_dtype[0] == DtypeKind.UINT:
         if interchange_dtype[1] == 64:
             return dtypes.UInt64()
@@ -44,13 +45,15 @@ def map_interchange_dtype_to_narwhals_dtype(
             return dtypes.UInt16()
         if interchange_dtype[1] == 8:
             return dtypes.UInt8()
-        raise AssertionError("Invalid bit width for UINT")
+        msg = "Invalid bit width for UINT"  # pragma: no cover
+        raise AssertionError(msg)
     if interchange_dtype[0] == DtypeKind.FLOAT:
         if interchange_dtype[1] == 64:
             return dtypes.Float64()
         if interchange_dtype[1] == 32:
             return dtypes.Float32()
-        raise AssertionError("Invalid bit width for FLOAT")
+        msg = "Invalid bit width for FLOAT"  # pragma: no cover
+        raise AssertionError(msg)
     if interchange_dtype[0] == DtypeKind.BOOL:
         return dtypes.Boolean()
     if interchange_dtype[0] == DtypeKind.STRING:
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 02f7644d..a6af772a 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -951,9 +951,8 @@ class Expr:
         if isinstance(other, Iterable) and not isinstance(other, (str, bytes)):
             return self.__class__(lambda plx: self._call(plx).is_in(other))
         else:
-            raise NotImplementedError(
-                "Narwhals `is_in` doesn't accept expressions as an argument, as opposed to Polars. You should provide an iterable instead."
-            )
+            msg = "Narwhals `is_in` doesn't accept expressions as an argument, as opposed to Polars. You should provide an iterable instead."
+            raise NotImplementedError(msg)
 
     def filter(self, *predicates: Any) -> Self:
         """
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 9184a8cf..24d299be 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -28,10 +28,9 @@ def concat(
     *,
     how: Literal["horizontal", "vertical"] = "vertical",
 ) -> FrameT:
-    if how not in ("horizontal", "vertical"):
-        raise NotImplementedError(
-            "Only horizontal and vertical concatenations are supported"
-        )
+    if how not in ("horizontal", "vertical"):  # pragma: no cover
+        msg = "Only horizontal and vertical concatenations are supported"
+        raise NotImplementedError(msg)
     if not items:
         msg = "No items to concatenate"
         raise ValueError(msg)
diff --git a/narwhals/utils.py b/narwhals/utils.py
index ee19d4b6..1f6a9074 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -92,7 +92,8 @@ def validate_same_library(items: Iterable[Any]) -> None:
         len({item._compliant_frame._implementation for item in items}) == 1
     ):
         return
-    raise NotImplementedError("Cross-library comparisons aren't supported")
+    msg = "Cross-library comparisons aren't supported"
+    raise NotImplementedError(msg)
 
 
 def validate_laziness(items: Iterable[Any]) -> None:
@@ -103,9 +104,8 @@ def validate_laziness(items: Iterable[Any]) -> None:
         all(isinstance(item, LazyFrame) for item in items)
     ):
         return
-    raise NotImplementedError(
-        "The items to concatenate should either all be eager, or all lazy"
-    )
+    msg = "The items to concatenate should either all be eager, or all lazy"
+    raise NotImplementedError(msg)
 
 
 def maybe_align_index(lhs: T, rhs: Series | BaseFrame[Any]) -> T:
diff --git a/pyproject.toml b/pyproject.toml
index 8976a515..434e74fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,34 +43,32 @@ lint.select = [
   "ALL",
 ]
 lint.ignore = [
-  'A001',
-  'A003',
-  'ANN101',
-  'ANN401',
-  'C901',
-  'COM812',
-  'D',
-  'DTZ001',
-  'E501',
-  'EM101',
-  'FIX',
-  'ISC001',
-  'PD901',  # This is a auxiliary library so dataframe variables have no concrete business meaning
-  'PLR0911',
-  'PLR0912',
-  'PLR0913',
-  'PLR2004',
-  'PTH',
-  'RET505',
-  'SLF001',
-  'TD003',
-  'TRY003',  # TODO(Unassigned): enable
-  'TRY004'
+  "A001",
+  "ANN101",
+  "ANN401",
+  "C901",
+  "COM812",
+  "D",
+  "DTZ001",
+  "E501",
+  "FIX",
+  "ISC001",
+  "PD901",  # This is a auxiliary library so dataframe variables have no concrete business meaning
+  "PLR0911",
+  "PLR0912",
+  "PLR0913",
+  "PLR2004",
+  "RET505",
+  "SLF001",
+  "TD003",
 ]
 
 [tool.ruff.lint.per-file-ignores]
 "tests/*" = ["S101"]
-"utils/*" = ["S311"]
+"utils/*" = ["S311", "PTH123"]
+
+[tool.ruff.lint.pydocstyle]
+convention = "google"
 
 [tool.ruff.lint.isort]
 force-single-line = true
diff --git a/utils/check_backend_completeness.py b/utils/check_backend_completeness.py
index ab2eec28..f412fc5e 100644
--- a/utils/check_backend_completeness.py
+++ b/utils/check_backend_completeness.py
@@ -25,7 +25,6 @@ MISSING = [
     "Series.is_first_distinct",
     "Series.is_in",
     "Series.is_last_distinct",
-    "Series.is_null",
     "Series.is_sorted",
     "Series.is_unique",
     "Series.len",
diff --git a/utils/check_for_no_build_errors.py b/utils/check_for_no_build_errors.py
new file mode 100644
index 00000000..995411e9
--- /dev/null
+++ b/utils/check_for_no_build_errors.py
@@ -0,0 +1,15 @@
+"""
+Check that `output.txt` doesn't contain the string
+"exited with errors". If it does, exit with status 1.
+
+This is just used in CI.
+"""
+
+import sys
+
+with open("output.txt") as fd:
+    content = fd.read()
+
+if "exited with errors" in content:
+    sys.exit(1)
+sys.exit(0)


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
new file mode 100644
index 00000000..9a587f02
--- /dev/null
+++ b/.github/workflows/downstream_tests.yml
@@ -0,0 +1,89 @@
+name: ci
+
+on:
+  pull_request:
+  push:
+    branches: [main]
+
+jobs:
+  altair:
+    strategy:
+      matrix:
+        python-version: ["3.11"]
+        os: [ubuntu-latest]
+
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Cache multiple paths
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/.cache/pip
+            $RUNNER_TOOL_CACHE/Python/*
+            ~\AppData\Local\pip\Cache
+          key: ${{ runner.os }}-build-${{ matrix.python-version }}
+      - name: clone-altair
+        run: git clone https://github.com/vega/altair.git
+      - name: install-basics
+        run: python -m pip install --upgrade tox virtualenv setuptools pip
+      - name: install-altair-dev
+        run: |
+            cd altair
+            pip install -e ".[dev, all]"
+      - name: install-narwhals-dev
+        run: |
+            pip uninstall narwhals -y
+            pip install -e .
+      - name: show-deps
+        run: pip freeze
+      - name: Run pytest
+        run: |
+            cd altair
+            pytest tests
+      - name: Run mypy
+        run: |
+            cd altair
+            mypy altair tests
+
+  scikit-lego:
+    strategy:
+      matrix:
+        python-version: ["3.11"]
+        os: [ubuntu-latest]
+
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Cache multiple paths
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/.cache/pip
+            $RUNNER_TOOL_CACHE/Python/*
+            ~\AppData\Local\pip\Cache
+          key: ${{ runner.os }}-build-${{ matrix.python-version }}
+      - name: clone-scikit-lego
+        run: git clone https://github.com/koaning/scikit-lego.git
+      - name: install-basics
+        run: python -m pip install --upgrade tox virtualenv setuptools pip
+      - name: install-scikit-lego-dev
+        run: |
+            cd scikit-lego
+            pip install -e ".[dev, test]"
+      - name: install-narwhals-dev
+        run: |
+            pip uninstall narwhals -y
+            pip install -e .
+      - name: show-deps
+        run: pip freeze
+      - name: Run pytest
+        run: |
+            cd scikit-lego
+            pytest -n auto --disable-warnings --cov=sklego -m "not cvxpy and not formulaic and not umap"
diff --git a/tests/frame/join_test.py b/tests/frame/join_test.py
index f8cafbdf..9ba11991 100644
--- a/tests/frame/join_test.py
+++ b/tests/frame/join_test.py
@@ -154,7 +154,7 @@ def test_left_join_multiple_column(request: Any, constructor: Any) -> None:
     compare_dicts(result, expected)
 
 
-@pytest.mark.filterwarnings("ignore: the defaultcoalesce behavior")
+@pytest.mark.filterwarnings("ignore: the default coalesce behavior")
 def test_left_join_overlapping_column(request: Any, constructor: Any) -> None:
     if "pyarrow_table" in str(constructor):
         request.applymarker(pytest.mark.xfail)
diff --git a/tests/frame/test_common.py b/tests/frame/test_common.py
index 01eb3495..03efae10 100644
--- a/tests/frame/test_common.py
+++ b/tests/frame/test_common.py
@@ -152,10 +152,7 @@ def test_expr_min_max(request: Any, constructor: Any) -> None:
     compare_dicts(result_max, expected_max)
 
 
-def test_expr_na(request: Any, constructor_with_lazy: Any) -> None:
-    if "pyarrow_table" in str(constructor_with_lazy):
-        request.applymarker(pytest.mark.xfail)
-
+def test_expr_na(constructor_with_lazy: Any) -> None:
     df = nw.from_native(constructor_with_lazy(data_na)).lazy()
     result_nna = df.filter((~nw.col("a").is_null()) & (~df.collect()["z"].is_null()))
     expected = {"a": [2], "b": [6], "z": [9]}
diff --git a/tests/frame/write_parquet_test.py b/tests/frame/write_parquet_test.py
index 88e35b3f..f0735692 100644
--- a/tests/frame/write_parquet_test.py
+++ b/tests/frame/write_parquet_test.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import os
 from typing import Any
 
 import pandas as pd
@@ -16,6 +15,6 @@ data = {"a": [1, 2, 3]}
     parse_version(pd.__version__) < parse_version("2.0.0"), reason="too old for pyarrow"
 )
 def test_write_parquet(constructor: Any, tmpdir: pytest.TempdirFactory) -> None:
-    path = str(tmpdir / "foo.parquet")  # type: ignore[operator]
-    nw.from_native(constructor(data), eager_only=True).write_parquet(path)
-    assert os.path.exists(path)
+    path = tmpdir / "foo.parquet"  # type: ignore[operator]
+    nw.from_native(constructor(data), eager_only=True).write_parquet(str(path))
+    assert path.exists()