Repository: scrapy/scrapy

Issue:
AssertionError   scrapy.settings in getdictorlist
<!--

Thanks for taking an interest in Scrapy!

If you have a question that starts with "How to...", please see the Scrapy Community page: https://scrapy.org/community/.
The GitHub issue tracker's purpose is to deal with bug reports and feature requests for the project itself.

Keep in mind that by filing an issue, you are expected to comply with Scrapy's Code of Conduct, including treating everyone with respect: https://github.com/scrapy/scrapy/blob/master/CODE_OF_CONDUCT.md

The following is a suggested template to structure your issue, you can find more guidelines at https://doc.scrapy.org/en/latest/contributing.html#reporting-bugs

-->

### Description

AssertionError after upgrade to scrapy==2.10.0

### Steps to Reproduce

1. in settings.py:
    ```python
    FEED_EXPORT_FIELDS = tuple( ### if change to list - Ok
      're_num idgood num code title price artikul valuta url_id url_rsp '
      'is_auto_valuta code_nohash url_item'.split())
    ```

**Actual behavior:** [What actually happens]
```
Traceback (most recent call last):
  File "scrapy", line 8, in <module>
    sys.exit(execute())
  File "scrapy/cmdline.py", line 161, in execute
    _run_print_help(parser, _run_command, cmd, args, opts)
  File "scrapy/cmdline.py", line 114, in _run_print_help
    func(*a, **kw)
  File "scrapy/cmdline.py", line 169, in _run_command
    cmd.run(args, opts)
  File "scrapy/commands/crawl.py", line 23, in run
    crawl_defer = self.crawler_process.crawl(spname, **opts.spargs)
  File "scrapy/crawler.py", line 244, in crawl
    crawler = self.create_crawler(crawler_or_spidercls)
  File "scrapy/crawler.py", line 280, in create_crawler
    return self._create_crawler(crawler_or_spidercls)
  File "scrapy/crawler.py", line 363, in _create_crawler
    return Crawler(spidercls, self.settings, init_reactor=init_reactor)
  File "scrapy/crawler.py", line 119, in __init__
    self.extensions: ExtensionManager = ExtensionManager.from_crawler(self)
  File "scrapy/middleware.py", line 67, in from_crawler
    return cls.from_settings(crawler.settings, crawler)
  File "scrapy/middleware.py", line 44, in from_settings
    mw = create_instance(mwcls, settings, crawler)
  File "scrapy/utils/misc.py", line 188, in create_instance
    instance = objcls.from_crawler(crawler, *args, **kwargs)
  File "scrapy/extensions/feedexport.py", line 398, in from_crawler
    exporter = cls(crawler)
  File "scrapy/extensions/feedexport.py", line 436, in __init__
    self.feeds[uri] = feed_complete_default_values_from_settings(
  File "scrapy/utils/conf.py", line 136, in feed_complete_default_values_from_settings
    out.setdefault("fields", settings.getdictorlist("FEED_EXPORT_FIELDS") or None)
  File "scrapy/settings/__init__.py", line 274, in getdictorlist
    assert isinstance(value, (dict, list))
AssertionError
```
**Reproduces how often:** [What percentage of the time does it reproduce?]

### Versions

```
$ scrapy version --verbose
Scrapy       : 2.10.0
lxml         : 4.9.3.0
libxml2      : 2.10.3
cssselect    : 1.2.0
parsel       : 1.8.1
w3lib        : 2.1.2
Twisted      : 22.10.0
Python       : 3.11.4 (main, Jun  8 2023, 09:53:06) [GCC 11.3.0]
pyOpenSSL    : 23.2.0 (OpenSSL 3.1.2 1 Aug 2023)
cryptography : 41.0.3
Platform     : Linux-6.2.0-26-generic-x86_64-with-glibc2.35
```

### Additional context

Any additional information, configuration, data or output from commands that might be necessary to reproduce or understand the issue. Please try not to include screenshots of code or the command line, paste the contents as text instead. You can use [GitHub Flavored Markdown](https://help.github.com/en/articles/creating-and-highlighting-code-blocks) to make the text look better.



Discussion:
This doesn't seem to be a bug but intentional behavior, why would you need to pass a tuple object?
@wRAR I tested locally and indeed `tuple` object on <2.10 works to filtering out fields, should we consider this a bug and put tuple object in assertion list?
Initially `getlist()` was used to handle `FEED_EXPORT_FIELDS`, later `getdictorlist()` was added and used there (this isn't what was changed in 2.10.0, I'm explaining the behavior difference between the two functions). But `getlist()` uses `list()` while `getdictorlist()` doesn't. So both functions shouldn't return tuple but convert it to a list. It makes sense to check if `value` is not a dict and convert it to a list in that case.

Resolving patch:
diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py
--- a/scrapy/settings/__init__.py
+++ b/scrapy/settings/__init__.py
@@ -238,7 +238,7 @@ def getdict(
     def getdictorlist(
         self,
         name: _SettingsKeyT,
-        default: Union[Dict[Any, Any], List[Any], None] = None,
+        default: Union[Dict[Any, Any], List[Any], Tuple[Any], None] = None,
     ) -> Union[Dict[Any, Any], List[Any]]:
         """Get a setting value as either a :class:`dict` or a :class:`list`.
 
@@ -271,6 +271,8 @@ def getdictorlist(
                 return value_loaded
             except ValueError:
                 return value.split(",")
+        if isinstance(value, tuple):
+            return list(value)
         assert isinstance(value, (dict, list))
         return copy.deepcopy(value)
 


Test patch:
diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py
--- a/tests/test_feedexport.py
+++ b/tests/test_feedexport.py
@@ -1321,6 +1321,17 @@ def test_export_dicts(self):
         yield self.assertExportedCsv(items, ["foo", "egg"], rows_csv)
         yield self.assertExportedJsonLines(items, rows_jl)
 
+    @defer.inlineCallbacks
+    def test_export_tuple(self):
+        items = [
+            {"foo": "bar1", "egg": "spam1"},
+            {"foo": "bar2", "egg": "spam2", "baz": "quux"},
+        ]
+
+        settings = {"FEED_EXPORT_FIELDS": ("foo", "baz")}
+        rows = [{"foo": "bar1", "baz": ""}, {"foo": "bar2", "baz": "quux"}]
+        yield self.assertExported(items, ["foo", "baz"], rows, settings=settings)
+
     @defer.inlineCallbacks
     def test_export_feed_export_fields(self):
         # FEED_EXPORT_FIELDS option allows to order export fields