Skip to content

Commit

Permalink
Documentation improvements (#639)
Browse files Browse the repository at this point in the history
* Move docstrings of top-level functions show they appear in autodocs

* Improve API docs

* Add documentation for Related

* Update changelog

* typo
  • Loading branch information
sloria authored Jan 11, 2025
1 parent 3f3fb97 commit 1197143
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 58 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Features:

* Typing: Add type annotations to `fields <marshmallow_sqlalchemy.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 <marshmallow_sqalalchemy.SQLAlchemySchema>`
and `SQLAlchemyAutoSchema <marshmallow_sqalchemy.SQLAlchemyAutoSchema>` (:issue:`619`).
* Docs: Various documentation improvements (:pr:`635`, :pr:`636`, :pr:`639`).

1.2.0 (2025-01-09)
++++++++++++++++++

Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 12 additions & 15 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
69 changes: 69 additions & 0 deletions docs/recipes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,75 @@ This allows you to define class Meta options without having to subclass ``BaseSc
class Meta:
model = User
Using `Related <marshmallow_sqlalchemy.fields.Related>` to serialize relationships
==================================================================================

The `Related <marshmallow_sqlalchemy.fields.Related>` field can be used to serialize a
SQLAlchemy `relationship <sqlalchemy.orm.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
==============================

Expand Down
71 changes: 34 additions & 37 deletions src/marshmallow_sqlalchemy/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -262,6 +278,13 @@ def column2field(
def column2field(
self, column, *, instance: bool = True, **kwargs
) -> fields.Field | type[fields.Field]:
"""Convert a SQLAlchemy `Column <sqlalchemy.schema.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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <sqlalchemy.schema.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.
"""
4 changes: 2 additions & 2 deletions src/marshmallow_sqlalchemy/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <marshmallow_sqlalchemy.SQLAlchemySchema>`.
to a `Schema <marshmallow.Schema>` class whose options includes a SQLAlchemy `model`, such
as `SQLAlchemySchema <marshmallow_sqlalchemy.SQLAlchemySchema>`.
:param columns: Optional column names on related model. If not provided,
the primary key(s) of the related model will be used.
Expand Down
13 changes: 11 additions & 2 deletions src/marshmallow_sqlalchemy/load_instance_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions tests/test_sqlalchemy_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 1197143

Please sign in to comment.