Repository: scrapy/scrapy (python)

Issue:
`FileFeedStorage` ignores the drive letter on Windows
```
>>> from scrapy.extensions.feedexport import FileFeedStorage
>>> s = FileFeedStorage("C:/foo")
>>> s.path
'\\foo'
>>> s = FileFeedStorage("D:/foo")
>>> s.path
'\\foo'
```

This, I believe, causes the files to be written to the drive from the spider work directory instead of the specified one. Thanks to GitHub CI recently changing the workdir to something on D: this is now visible in tests.

The cause of this seems to be `w3lib.url.file_uri_to_path()`, but I don't think calling it on a normal path instead of an URI is correct. OTOH this code was added in 2010 and so far it looks like it should have been working in the same way back then.

Pull request description:
Fix FileFeedStorage handling of Windows paths without file:// scheme
## Description

This PR addresses an issue in the `FileFeedStorage` class where passing a Windows-style path without the `file://` URI scheme causes incorrect path resolution. Specifically, using a path like `C:/foo` was incorrectly processed by `w3lib.url.file_uri_to_path()`, which expects a proper URI. This led to malformed paths such as `\\foo`, causing files to be written relative to the spider's working directory instead of the intended absolute path.

The problematic code was:
```python
self.path = file_uri_to_path(uri)
```
which always called file_uri_to_path() regardless of whether uri was a valid URI or just a local path.

## Solution

The fix changes the initialization logic to only convert the path using file_uri_to_path() if the input string starts with the file:// scheme. Otherwise, the path is preserved as-is.

## Testing

- Added a new test test_preserves_windows_path_without_file_scheme that verifies Windows-style paths are preserved correctly when passed without the file:// scheme.
- Existing tests that use tmp_path cover writing and storing files, including relative and URI paths.
- The new test is sufficient to cover the issue because the root problem was path parsing, not file writing.

Fixes #6894 

Patch:
diff --git a/.gitignore b/.gitignore
index 0a3f0ac1c..e02c2241d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ docs/build
 *egg-info
 .tox
 venv
+.venv
 build
 dist
 .idea
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 0172887d6..3976d34c2 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -251,10 +251,10 @@ Coding style
 Please follow these coding conventions when writing code for inclusion in
 Scrapy:
 
-* We use `black <https://black.readthedocs.io/en/stable/>`_ for code formatting.
+* We use `Ruff <https://docs.astral.sh/ruff/>`_ for code formatting.
   There is a hook in the pre-commit config
   that will automatically format your code before every commit. You can also
-  run black manually with ``tox -e pre-commit``.
+  run Ruff manually with ``tox -e pre-commit``.
 
 * Don't put your name in the code you contribute; git provides enough
   metadata to identify author of the code.
diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py
index d9e9ea775..f7bf50a5c 100644
--- a/scrapy/extensions/feedexport.py
+++ b/scrapy/extensions/feedexport.py
@@ -185,7 +185,7 @@ class StdoutFeedStorage:
 @implementer(IFeedStorage)
 class FileFeedStorage:
     def __init__(self, uri: str, *, feed_options: dict[str, Any] | None = None):
-        self.path: str = file_uri_to_path(uri)
+        self.path: str = file_uri_to_path(uri) if uri.startswith("file://") else uri
         feed_options = feed_options or {}
         self.write_mode: OpenBinaryMode = (
             "wb" if feed_options.get("overwrite", False) else "ab"


Test patch:
diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py
index f8f3eb22a..7073d5a35 100644
--- a/tests/test_feedexport.py
+++ b/tests/test_feedexport.py
@@ -155,6 +155,11 @@ class TestFileFeedStorage:
         finally:
             path.unlink()
 
+    def test_preserves_windows_path_without_file_scheme(self):
+        path = r"C:\Users\user\Desktop\test.txt"
+        storage = FileFeedStorage(path)
+        assert storage.path == path
+
 
 class TestFTPFeedStorage(unittest.TestCase):
     def get_test_spider(self, settings=None):