Repository: narwhals-dev/narwhals

Issue:
[Bug]: `from_dict` doesn't preserve dtype module
### Describe the bug

`from_dict` when called from `nw.stable.v1` doesn't result in `stable.v1` dtypes

### Steps or code to reproduce the bug

```python
from __future__ import annotations

import narwhals.stable.v1 as nw
from narwhals.typing import IntoFrameT
from datetime import datetime
import polars as pl

import pandas as pd

data = {
    "Task": ["Job A", "Job B", "Job C"],
    "Start": ["2009-01-01", "2009-03-05", "2009-02-20"],
    "Finish": ["2009-02-28", "2009-04-15", "2009-05-30"],
}
df = nw.from_dict(data, native_namespace=pl).with_columns(nw.col('Finish').str.to_datetime())
print(type(df))
print(type(df.schema['Finish']))

```



### Expected results

```
<class 'narwhals.stable.v1.DataFrame'>
<class 'narwhals.stable.v1._dtypes.Datetime'>
```

### Actual results

```
<class 'narwhals.stable.v1.DataFrame'>
<class 'narwhals.Datetime'>
```

### Please run narwhals.show_version() and enter the output below.

```shell
Python dependencies:
     narwhals: 1.14.2
       pandas: 2.2.3
       polars: 1.13.1
         cudf: 
        modin: 
      pyarrow: 18.0.0
        numpy: 2.0.2
```


### Relevant log output

_No response_

Discussion:

Patch:
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 83a8c57b..563d918c 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -69,6 +69,11 @@ class ArrowDataFrame:
     def __narwhals_lazyframe__(self) -> Self:
         return self
 
+    def _change_dtypes(self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            self._native_frame, backend_version=self._backend_version, dtypes=dtypes
+        )
+
     def _from_native_frame(self, df: pa.Table) -> Self:
         return self.__class__(
             df, backend_version=self._backend_version, dtypes=self._dtypes
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index f286b300..88a91a39 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -45,6 +45,14 @@ class ArrowSeries:
         self._backend_version = backend_version
         self._dtypes = dtypes
 
+    def _change_dtypes(self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            self._native_series,
+            name=self._name,
+            backend_version=self._backend_version,
+            dtypes=dtypes,
+        )
+
     def _from_native_series(self, series: pa.ChunkedArray | pa.Array) -> Self:
         import pyarrow as pa  # ignore-banned-import()
 
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 96a6ce84..89a6f302 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -59,6 +59,11 @@ class DaskLazyFrame:
     def __narwhals_lazyframe__(self) -> Self:
         return self
 
+    def _change_dtypes(self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            self._native_frame, backend_version=self._backend_version, dtypes=dtypes
+        )
+
     def _from_native_frame(self, df: Any) -> Self:
         return self.__class__(
             df, backend_version=self._backend_version, dtypes=self._dtypes
diff --git a/narwhals/_duckdb/dataframe.py b/narwhals/_duckdb/dataframe.py
index 69eaf467..17dcb785 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -139,5 +139,8 @@ class DuckDBInterchangeFrame:
     def to_arrow(self: Self) -> pa.Table:
         return self._native_frame.arrow()
 
+    def _change_dtypes(self: Self, dtypes: DTypes) -> Self:
+        return self.__class__(self._native_frame, dtypes=dtypes)
+
     def _from_native_frame(self: Self, df: Any) -> Self:
         return self.__class__(df, dtypes=self._dtypes)
diff --git a/narwhals/_ibis/dataframe.py b/narwhals/_ibis/dataframe.py
index 571eb1b6..b04f5b13 100644
--- a/narwhals/_ibis/dataframe.py
+++ b/narwhals/_ibis/dataframe.py
@@ -119,5 +119,8 @@ class IbisInterchangeFrame:
         )
         raise NotImplementedError(msg)
 
+    def _change_dtypes(self: Self, dtypes: DTypes) -> Self:
+        return self.__class__(self._native_frame, dtypes=dtypes)
+
     def _from_native_frame(self: Self, df: Any) -> Self:
         return self.__class__(df, dtypes=self._dtypes)
diff --git a/narwhals/_interchange/dataframe.py b/narwhals/_interchange/dataframe.py
index dc2af3ba..1ae01311 100644
--- a/narwhals/_interchange/dataframe.py
+++ b/narwhals/_interchange/dataframe.py
@@ -73,11 +73,24 @@ def map_interchange_dtype_to_narwhals_dtype(
     raise AssertionError(msg)
 
 
+class WrapInterchangeFrame:
+    def __init__(self, interchange_frame: InterchangeFrame) -> None:
+        self._interchange_frame = interchange_frame
+
+    def __dataframe__(self) -> InterchangeFrame:
+        return self._interchange_frame
+
+
 class InterchangeFrame:
     def __init__(self, df: Any, dtypes: DTypes) -> None:
         self._interchange_frame = df.__dataframe__()
         self._dtypes = dtypes
 
+    def _change_dtypes(self: Self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            WrapInterchangeFrame(self._interchange_frame), dtypes=dtypes
+        )
+
     def __narwhals_dataframe__(self) -> Any:
         return self
 
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 96bfe936..2e975b46 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -100,6 +100,14 @@ class PandasLikeDataFrame:
             msg = f"Expected unique column names, got:{msg}"
             raise ValueError(msg)
 
+    def _change_dtypes(self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            self._native_frame,
+            implementation=self._implementation,
+            backend_version=self._backend_version,
+            dtypes=dtypes,
+        )
+
     def _from_native_frame(self, df: Any) -> Self:
         return self.__class__(
             df,
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index 6ae76c7b..426e828b 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -127,6 +127,14 @@ class PandasLikeSeries:
             return self._native_series.iloc[idx]
         return self._from_native_series(self._native_series.iloc[idx])
 
+    def _change_dtypes(self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            self._native_series,
+            implementation=self._implementation,
+            backend_version=self._backend_version,
+            dtypes=dtypes,
+        )
+
     def _from_native_series(self, series: Any) -> Self:
         return self.__class__(
             series,
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index f46bd800..231fd9a4 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -51,6 +51,11 @@ class PolarsDataFrame:
         msg = f"Expected polars, got: {type(self._implementation)}"  # pragma: no cover
         raise AssertionError(msg)
 
+    def _change_dtypes(self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            self._native_frame, backend_version=self._backend_version, dtypes=dtypes
+        )
+
     def _from_native_frame(self, df: Any) -> Self:
         return self.__class__(
             df, backend_version=self._backend_version, dtypes=self._dtypes
@@ -321,6 +326,11 @@ class PolarsLazyFrame:
             df, backend_version=self._backend_version, dtypes=self._dtypes
         )
 
+    def _change_dtypes(self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            self._native_frame, backend_version=self._backend_version, dtypes=dtypes
+        )
+
     def __getattr__(self, attr: str) -> Any:
         def func(*args: Any, **kwargs: Any) -> Any:
             import polars as pl  # ignore-banned-import
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 0865ca15..a0df340d 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -46,6 +46,11 @@ class PolarsSeries:
         msg = f"Expected polars, got: {type(self._implementation)}"  # pragma: no cover
         raise AssertionError(msg)
 
+    def _change_dtypes(self, dtypes: DTypes) -> Self:
+        return self.__class__(
+            self._native_series, backend_version=self._backend_version, dtypes=dtypes
+        )
+
     def _from_native_series(self, series: Any) -> Self:
         return self.__class__(
             series, backend_version=self._backend_version, dtypes=self._dtypes
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index d83cacb6..cfea156d 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -1160,19 +1160,21 @@ def _stableify(obj: Any) -> Any: ...
 def _stableify(
     obj: NwDataFrame[IntoFrameT] | NwLazyFrame[IntoFrameT] | NwSeries | NwExpr | Any,
 ) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series | Expr | Any:
+    from narwhals.stable.v1 import dtypes
+
     if isinstance(obj, NwDataFrame):
         return DataFrame(
-            obj._compliant_frame,
+            obj._compliant_frame._change_dtypes(dtypes),
             level=obj._level,
         )
     if isinstance(obj, NwLazyFrame):
         return LazyFrame(
-            obj._compliant_frame,
+            obj._compliant_frame._change_dtypes(dtypes),
             level=obj._level,
         )
     if isinstance(obj, NwSeries):
         return Series(
-            obj._compliant_series,
+            obj._compliant_series._change_dtypes(dtypes),
             level=obj._level,
         )
     if isinstance(obj, NwExpr):


Test patch:
diff --git a/tests/from_dict_test.py b/tests/from_dict_test.py
index 833cd3ac..24167e7b 100644
--- a/tests/from_dict_test.py
+++ b/tests/from_dict_test.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+from datetime import datetime
+
 import pytest
 
 import narwhals as nw
@@ -61,12 +63,16 @@ def test_from_dict_one_native_one_narwhals(
 def test_from_dict_v1(constructor: Constructor, request: pytest.FixtureRequest) -> None:
     if "dask" in str(constructor):
         request.applymarker(pytest.mark.xfail)
-    df = nw.from_native(constructor({"a": [1, 2, 3], "b": [4, 5, 6]}))
-    native_namespace = nw.get_native_namespace(df)
-    result = nw.from_dict({"c": [1, 2], "d": [5, 6]}, native_namespace=native_namespace)
-    expected = {"c": [1, 2], "d": [5, 6]}
+    df = nw_v1.from_native(constructor({"a": [1, 2, 3], "b": [4, 5, 6]}))
+    native_namespace = nw_v1.get_native_namespace(df)
+    result = nw_v1.from_dict(
+        {"c": [1, 2], "d": [datetime(2020, 1, 1), datetime(2020, 1, 2)]},
+        native_namespace=native_namespace,
+    )
+    expected = {"c": [1, 2], "d": [datetime(2020, 1, 1), datetime(2020, 1, 2)]}
     assert_equal_data(result, expected)
-    assert isinstance(result, nw.DataFrame)
+    assert isinstance(result, nw_v1.DataFrame)
+    assert isinstance(result.schema["d"], nw_v1.dtypes.Datetime)
 
 
 def test_from_dict_empty() -> None:
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index 05c92250..511e4dea 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from contextlib import nullcontext as does_not_raise
+from typing import TYPE_CHECKING
 from typing import Any
 
 import numpy as np
@@ -13,6 +14,9 @@ import narwhals as unstable_nw
 import narwhals.stable.v1 as nw
 from tests.utils import maybe_get_modin_df
 
+if TYPE_CHECKING:
+    from narwhals.typing import DTypes
+
 data = {"a": [1, 2, 3]}
 
 df_pd = pd.DataFrame(data)
@@ -28,16 +32,25 @@ series_pa = pa.chunked_array([data["a"]])
 
 
 class MockDataFrame:
+    def _change_dtypes(self, _dtypes: DTypes) -> MockDataFrame:
+        return self
+
     def __narwhals_dataframe__(self) -> Any:
         return self
 
 
 class MockLazyFrame:
+    def _change_dtypes(self, _dtypes: DTypes) -> MockLazyFrame:
+        return self
+
     def __narwhals_lazyframe__(self) -> Any:
         return self
 
 
 class MockSeries:
+    def _change_dtypes(self, _dtypes: DTypes) -> MockSeries:
+        return self
+
     def __narwhals_series__(self) -> Any:
         return self