From 11971438fe15f1110136c59df68a8be30fb1d526 Mon Sep 17 00:00:00 2001 From: Steven Loria Date: Sat, 11 Jan 2025 09:28:00 -0500 Subject: [PATCH] Documentation improvements (#639) * Move docstrings of top-level functions show they appear in autodocs * Improve API docs * Add documentation for Related * Update changelog * typo --- CHANGELOG.rst | 8 +++ README.rst | 2 +- docs/api_reference.rst | 27 ++++--- docs/conf.py | 2 +- docs/recipes.rst | 69 ++++++++++++++++++ src/marshmallow_sqlalchemy/convert.py | 71 +++++++++---------- src/marshmallow_sqlalchemy/fields.py | 4 +- .../load_instance_mixin.py | 13 +++- tests/test_sqlalchemy_schema.py | 1 + 9 files changed, 139 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6adcd7d..7f0037b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,14 @@ Features: * Typing: Add type annotations to `fields `. +Other changes: + +* Docs: Add more documentation for `marshmallow_sqlalchemy.fields.Related` (:issue:`162`). + Thanks :user:`GabrielC101` for the suggestion. +* Docs: Document methods of `SQLAlchemySchema ` + and `SQLAlchemyAutoSchema ` (:issue:`619`). +* Docs: Various documentation improvements (:pr:`635`, :pr:`636`, :pr:`639`). + 1.2.0 (2025-01-09) ++++++++++++++++++ diff --git a/README.rst b/README.rst index 0f51cae..549954a 100644 --- a/README.rst +++ b/README.rst @@ -129,7 +129,7 @@ Get it now .. code-block:: shell-session - pip install -U marshmallow-sqlalchemy + $ pip install -U marshmallow-sqlalchemy Requires Python >= 3.9, marshmallow >= 3.18.0, and SQLAlchemy >= 1.4.40. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index e2ca26c..1430d36 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -1,26 +1,23 @@ .. _api: -Core -==== +Top-level API +============= -.. automodule:: marshmallow_sqlalchemy - :members: - -.. autodata:: marshmallow_sqlalchemy.fields_for_model - :annotation: =func(...) +.. Explicitly list which methods to document because :inherited-members: documents +.. all of Schema's methods, which we don't want +.. autoclass:: marshmallow_sqlalchemy.SQLAlchemySchema + :members: load,get_instance,make_instance,validate,session,transient -.. autodata:: marshmallow_sqlalchemy.property2field - :annotation: =func(...) +.. autoclass:: marshmallow_sqlalchemy.SQLAlchemyAutoSchema + :members: load,get_instance,make_instance,validate,session,transient -.. autodata:: marshmallow_sqlalchemy.column2field - :annotation: =func(...) - -.. autodata:: marshmallow_sqlalchemy.field_for - :annotation: =func(...) +.. automodule:: marshmallow_sqlalchemy + :members: + :exclude-members: SQLAlchemySchema,SQLAlchemyAutoSchema Fields ====== .. automodule:: marshmallow_sqlalchemy.fields :members: - :private-members: + :exclude-members: get_value,default_error_messages,get_primary_keys diff --git a/docs/conf.py b/docs/conf.py index 886bd0e..3a666f4 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,5 +55,5 @@ # https://sphinx-copybutton.readthedocs.io/en/latest/use.html#strip-and-configure-input-prompts-for-code-cells copybutton_prompt_text = "$ " -autodoc_typehints = "both" +autodoc_typehints = "description" autodoc_member_order = "bysource" diff --git a/docs/recipes.rst b/docs/recipes.rst index 0f70c78..79ea4ca 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -89,6 +89,75 @@ This allows you to define class Meta options without having to subclass ``BaseSc class Meta: model = User +Using `Related ` to serialize relationships +================================================================================== + +The `Related ` field can be used to serialize a +SQLAlchemy `relationship ` as a nested dictionary. + +.. code-block:: python + :emphasize-lines: 34 + + import sqlalchemy as sa + from sqlalchemy.orm import DeclarativeBase, relationship + + from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field + from marshmallow_sqlalchemy.fields import Related + + + class Base(DeclarativeBase): + pass + + + class User(Base): + __tablename__ = "user" + id = sa.Column(sa.Integer, primary_key=True) + full_name = sa.Column(sa.String(255)) + + + class BlogPost(Base): + __tablename__ = "blog_post" + id = sa.Column(sa.Integer, primary_key=True) + title = sa.Column(sa.String(255), nullable=False) + + author_id = sa.Column(sa.Integer, sa.ForeignKey(User.id), nullable=False) + author = relationship(User) + + + class BlogPostSchema(SQLAlchemyAutoSchema): + class Meta: + model = BlogPost + + id = auto_field() + # Blog's author will be serialized as a dictionary with + # `id` and `name` pulled from the related User. + author = Related(["id", "full_name"]) + +Serialization will look like this: + +.. code-block:: python + + from pprint import pprint + + from sqlalchemy.orm import scoped_session, sessionmaker + + engine = sa.create_engine("sqlite:///:memory:") + session = scoped_session(sessionmaker(bind=engine)) + + Base.metadata.create_all(engine) + + user = User(full_name="Freddie Mercury") + post = BlogPost(title="Bohemian Rhapsody Revisited", author=user) + session.add_all([user, post]) + session.commit() + + blog_post_schema = BlogPostSchema() + data = blog_post_schema.dump(post) + pprint(data, indent=2) + # { 'author': {'full_name': 'Freddie Mercury', 'id': 1}, + # 'id': 1, + # 'title': 'Bohemian Rhapsody Revisited'} + Introspecting generated fields ============================== diff --git a/src/marshmallow_sqlalchemy/convert.py b/src/marshmallow_sqlalchemy/convert.py index db24de7..2307789 100644 --- a/src/marshmallow_sqlalchemy/convert.py +++ b/src/marshmallow_sqlalchemy/convert.py @@ -144,6 +144,14 @@ def fields_for_model( base_fields: dict | None = None, dict_cls: type[dict] = dict, ) -> dict[str, fields.Field]: + """Generate a dict of field_name: `marshmallow.fields.Field` pairs for the given model. + Note: SynonymProperties are ignored. Use an explicit field if you want to include a synonym. + + :param model: The SQLAlchemy model + :param bool include_fk: Whether to include foreign key fields in the output. + :param bool include_relationships: Whether to include relationships fields in the output. + :return: dict of field_name: Field instance pairs + """ result = dict_cls() base_fields = base_fields or {} @@ -226,6 +234,14 @@ def property2field( field_class: type[fields.Field] | None = None, **kwargs, ) -> fields.Field | type[fields.Field]: + """Convert a SQLAlchemy `Property` to a field instance or class. + + :param Property prop: SQLAlchemy Property. + :param bool instance: If `True`, return `Field` instance, computing relevant kwargs + from the given property. If `False`, return the `Field` class. + :param kwargs: Additional keyword arguments to pass to the field constructor. + :return: A `marshmallow.fields.Field` class or instance. + """ # handle synonyms # Attribute renamed "_proxied_object" in 1.4 for attr in ("_proxied_property", "_proxied_object"): @@ -262,6 +278,13 @@ def column2field( def column2field( self, column, *, instance: bool = True, **kwargs ) -> fields.Field | type[fields.Field]: + """Convert a SQLAlchemy `Column ` to a field instance or class. + + :param sqlalchemy.schema.Column column: SQLAlchemy Column. + :param bool instance: If `True`, return `Field` instance, computing relevant kwargs + from the given property. If `False`, return the `Field` class. + :return: A `marshmallow.fields.Field` class or instance. + """ field_class = self._get_field_class_for_column(column) if not instance: return field_class @@ -301,6 +324,17 @@ def field_for( field_class: type[fields.Field] | None = None, **kwargs, ) -> fields.Field | type[fields.Field]: + """Convert a property for a mapped SQLAlchemy class to a marshmallow `Field`. + Example: :: + + date_created = field_for(Author, "date_created", dump_only=True) + author = field_for(Book, "author") + + :param type model: A SQLAlchemy mapped class. + :param str property_name: The name of the property to convert. + :param kwargs: Extra keyword arguments to pass to `property2field` + :return: A `marshmallow.fields.Field` class or instance. + """ target_model = model prop_name = property_name attr = getattr(model, property_name) @@ -450,43 +484,6 @@ def get_base_kwargs(self): default_converter = ModelConverter() fields_for_model = default_converter.fields_for_model -"""Generate a dict of field_name: `marshmallow.fields.Field` pairs for the given model. -Note: SynonymProperties are ignored. Use an explicit field if you want to include a synonym. - -:param model: The SQLAlchemy model -:param bool include_fk: Whether to include foreign key fields in the output. -:param bool include_relationships: Whether to include relationships fields in the output. -:return: dict of field_name: Field instance pairs -""" - property2field = default_converter.property2field -"""Convert a SQLAlchemy `Property` to a field instance or class. - -:param Property prop: SQLAlchemy Property. -:param bool instance: If `True`, return `Field` instance, computing relevant kwargs - from the given property. If `False`, return the `Field` class. -:param kwargs: Additional keyword arguments to pass to the field constructor. -:return: A `marshmallow.fields.Field` class or instance. -""" - column2field = default_converter.column2field -"""Convert a SQLAlchemy `Column ` to a field instance or class. - -:param sqlalchemy.schema.Column column: SQLAlchemy Column. -:param bool instance: If `True`, return `Field` instance, computing relevant kwargs - from the given property. If `False`, return the `Field` class. -:return: A `marshmallow.fields.Field` class or instance. -""" - field_for = default_converter.field_for -"""Convert a property for a mapped SQLAlchemy class to a marshmallow `Field`. -Example: :: - - date_created = field_for(Author, 'date_created', dump_only=True) - author = field_for(Book, 'author') - -:param type model: A SQLAlchemy mapped class. -:param str property_name: The name of the property to convert. -:param kwargs: Extra keyword arguments to pass to `property2field` -:return: A `marshmallow.fields.Field` class or instance. -""" diff --git a/src/marshmallow_sqlalchemy/fields.py b/src/marshmallow_sqlalchemy/fields.py index e46352b..bc10b7a 100644 --- a/src/marshmallow_sqlalchemy/fields.py +++ b/src/marshmallow_sqlalchemy/fields.py @@ -24,8 +24,8 @@ def get_value(self, obj, attr, accessor=None): class Related(fields.Field): """Related data represented by a SQLAlchemy `relationship`. Must be attached - to a :class:`Schema` class whose options includes a SQLAlchemy `model`, such - as SQLAlchemySchema `. + to a `Schema ` class whose options includes a SQLAlchemy `model`, such + as `SQLAlchemySchema `. :param columns: Optional column names on related model. If not provided, the primary key(s) of the related model will be used. diff --git a/src/marshmallow_sqlalchemy/load_instance_mixin.py b/src/marshmallow_sqlalchemy/load_instance_mixin.py index 47d3942..e003e1e 100644 --- a/src/marshmallow_sqlalchemy/load_instance_mixin.py +++ b/src/marshmallow_sqlalchemy/load_instance_mixin.py @@ -8,6 +8,7 @@ from __future__ import annotations +from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast import marshmallow as ma @@ -45,6 +46,7 @@ class Schema(Generic[_ModelType]): @property def session(self) -> Session | None: + """The SQLAlchemy session used to load models.""" return self._session or self.opts.sqla_session @session.setter @@ -53,6 +55,7 @@ def session(self, session: Session) -> None: @property def transient(self) -> bool: + """Whether model instances are loaded in a transient state.""" if self._transient is not None: return self._transient return self.opts.transient @@ -110,7 +113,7 @@ def make_instance(self, data, **kwargs) -> _ModelType: def load( self, - data, + data: Mapping[str, Any] | Iterable[Mapping[str, Any]], *, session: Session | None = None, instance: _ModelType | None = None, @@ -119,6 +122,7 @@ def load( ) -> Any: """Deserialize data to internal representation. + :param data: The data to deserialize. :param session: Optional SQLAlchemy session. :param instance: Optional existing instance to modify. :param transient: Optional switch to allow transient instantiation. @@ -134,8 +138,13 @@ def load( self.instance = None def validate( - self, data, *, session: Session | None = None, **kwargs + self, + data: Mapping[str, Any] | Iterable[Mapping[str, Any]], + *, + session: Session | None = None, + **kwargs, ) -> dict[str, list[str]]: + """Same as `marshmallow.Schema.validate` but allows passing a ``session``.""" self._session = session or self._session if not (self.transient or self.session): raise ValueError("Validation requires a session") diff --git a/tests/test_sqlalchemy_schema.py b/tests/test_sqlalchemy_schema.py index f67978c..6899166 100644 --- a/tests/test_sqlalchemy_schema.py +++ b/tests/test_sqlalchemy_schema.py @@ -473,6 +473,7 @@ class Meta: dump_data = TeacherSchema().dump(teacher) assert "school_id" not in dump_data["current_school"] assert dump_data["current_school"]["id"] == teacher.current_school.id + assert dump_data["current_school"]["name"] == teacher.current_school.name new_teacher = TeacherSchema().load(dump_data, transient=True) assert new_teacher.current_school.id == teacher.current_school.id assert TeacherSchema().load(dump_data) is teacher