Repository: marshmallow-code/marshmallow

Issue:
Setting many=True through Meta - SchemaOpts
I would like to propose adding support for setting `many=True` within the `Meta` class of a schema in Marshmallow. This feature would allow developers to configure a schema to always handle lists of objects by default, streamlining schema instantiation (no need to set `many=True` at every instantiation), reducing code redundancy and increasing consistency while reducing errors (the schema always handles lists of objects as intended).

It finds its use cases where the instantiation is not always in place, but is first passed e.g. to a decorator that does the instantiation, loading of the schema (etc. other logic).

Currently, to achieve this behavior, we must either set `many=True` during schema instantiation or create a custom base schema that enforces this behavior. Some example workaround:

```python
class MySchema(Schema):
    field = fields.String()

    class Meta:
        many = True

    @classmethod
    def create(cls, many=True, *args, **kwargs):
        return cls(many=many, *args, **kwargs)


schema = MySchema.create()
data = schema.load([{"field": "value"}])
schema = MySchema(many=True)
data = schema.load([{"field": "value"}])
```

Example of implemented:

```python
from marshmallow import Schema


class MySchema(Schema):
    class Meta:
        many = True


schema = MySchema()
data = schema.load([{"field": "value"}])
```

Throws as expected ValidationError, because this options is not possible to be set through Meta as other options do.

With this in place it would be always, if someone wants, giving more control, instead of just at the instance level.

If this would be fine to be given a try, I'm happy to try and prepare a PR for that :smiley: 

Discussion:
lafrech: LGTM.

Not that it really matters but I think I'd test the whole payload rather than just the length. I mean I see no reason not to.

Just a nitpick. We can merge as is.

Thanks.

Patch:
diff --git a/src/marshmallow/schema.py b/src/marshmallow/schema.py
index 4f2d0c76..99210ce7 100644
--- a/src/marshmallow/schema.py
+++ b/src/marshmallow/schema.py
@@ -227,6 +227,7 @@ class SchemaOpts:
         self.dump_only = getattr(meta, "dump_only", ())
         self.unknown = validate_unknown_parameter_value(getattr(meta, "unknown", RAISE))
         self.register = getattr(meta, "register", True)
+        self.many = getattr(meta, "many", False)
 
 
 class Schema(base.SchemaABC, metaclass=SchemaMeta):
@@ -343,6 +344,7 @@ class Schema(base.SchemaABC, metaclass=SchemaMeta):
             `OrderedDict`.
         - ``exclude``: Tuple or list of fields to exclude in the serialized result.
             Nested fields can be represented with dot delimiters.
+        - ``many``: Whether the data is a collection by default.
         - ``dateformat``: Default format for `Date <fields.Date>` fields.
         - ``datetimeformat``: Default format for `DateTime <fields.DateTime>` fields.
         - ``timeformat``: Default format for `Time <fields.Time>` fields.
@@ -366,7 +368,7 @@ class Schema(base.SchemaABC, metaclass=SchemaMeta):
         *,
         only: types.StrSequenceOrSet | None = None,
         exclude: types.StrSequenceOrSet = (),
-        many: bool = False,
+        many: bool | None = None,
         context: dict | None = None,
         load_only: types.StrSequenceOrSet = (),
         dump_only: types.StrSequenceOrSet = (),
@@ -380,7 +382,7 @@ class Schema(base.SchemaABC, metaclass=SchemaMeta):
             raise StringNotCollectionError('"exclude" should be a list of strings')
         # copy declared fields from metaclass
         self.declared_fields = copy.deepcopy(self._declared_fields)
-        self.many = many
+        self.many = self.opts.many if many is None else many
         self.only = only
         self.exclude: set[typing.Any] | typing.MutableSet[typing.Any] = set(
             self.opts.exclude


Test patch:
diff --git a/tests/test_options.py b/tests/test_options.py
index 2b052a31..4200ab90 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -262,3 +262,19 @@ class TestIncludeOption:
         assert "email" in s._declared_fields.keys()
         assert "from" in s._declared_fields.keys()
         assert isinstance(s._declared_fields["from"], fields.Str)
+
+
+class TestManyOption:
+    class ManySchema(Schema):
+        foo = fields.Str()
+
+        class Meta:
+            many = True
+
+    def test_many_by_default(self):
+        test = self.ManySchema()
+        assert test.load([{"foo": "bar"}]) == [{"foo": "bar"}]
+
+    def test_explicit_single(self):
+        test = self.ManySchema(many=False)
+        assert test.load({"foo": "bar"}) == {"foo": "bar"}