diff --git a/tildes/requirements-dev.txt b/tildes/requirements-dev.txt index 1634bef..5c94104 100644 --- a/tildes/requirements-dev.txt +++ b/tildes/requirements-dev.txt @@ -37,7 +37,7 @@ jinja2==3.1.6 lupa==2.5 mako==1.3.10 markupsafe==3.0.2 -marshmallow==3.25.1 +marshmallow==4.0.1 matplotlib-inline==0.1.7 mccabe==0.7.0 mypy==1.17.1 @@ -119,7 +119,7 @@ urllib3==2.5.0 venusian==3.1.1 waitress==3.0.2 wcwidth==0.2.13 -webargs==8.0.0 +webargs==8.7.0 webassets==2.0 webencodings==0.5.1 webob==1.8.9 diff --git a/tildes/requirements.in b/tildes/requirements.in index a7ee0cf..3520044 100644 --- a/tildes/requirements.in +++ b/tildes/requirements.in @@ -10,7 +10,7 @@ html5lib invoke ipython lupa -marshmallow==3.25.1 # TODO: Upgrade Marshmallow https://marshmallow.readthedocs.io/en/latest/upgrading.html +marshmallow Pillow pip-tools prometheus-client @@ -36,6 +36,6 @@ SQLAlchemy-Utils stripe==2.6.0 # TODO: Figure out if we can update this titlecase unicodedata2 -webargs==8.0.0 # TODO: Updating webargs causes an issue with parsing URLs, figure out if we can fix this +webargs wrapt zope.sqlalchemy==1.5 # TODO: Figure out if we can update this diff --git a/tildes/requirements.txt b/tildes/requirements.txt index 477544c..11fb8c0 100644 --- a/tildes/requirements.txt +++ b/tildes/requirements.txt @@ -25,7 +25,7 @@ jinja2==3.1.6 lupa==2.5 mako==1.3.10 markupsafe==3.0.2 -marshmallow==3.25.1 +marshmallow==4.0.1 matplotlib-inline==0.1.7 packaging==25.0 parso==0.8.4 @@ -73,7 +73,7 @@ unicodedata2==16.0.0 urllib3==2.5.0 venusian==3.1.1 wcwidth==0.2.13 -webargs==8.0.0 +webargs==8.7.0 webassets==2.0 webencodings==0.5.1 webob==1.8.9 diff --git a/tildes/tests/test_markdown_field.py b/tildes/tests/test_markdown_field.py index 753f84c..16b6c09 100644 --- a/tildes/tests/test_markdown_field.py +++ b/tildes/tests/test_markdown_field.py @@ -1,13 +1,14 @@ # Copyright (c) 2018 Tildes contributors # SPDX-License-Identifier: AGPL-3.0-or-later -from marshmallow import Schema, ValidationError +from marshmallow import ValidationError from pytest import raises +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import Markdown -class MarkdownFieldTestSchema(Schema): +class MarkdownFieldTestSchema(BaseTildesSchema): """Simple schema class with a standard Markdown field.""" markdown = Markdown() diff --git a/tildes/tests/test_simplestring_field.py b/tildes/tests/test_simplestring_field.py index 651392e..0a29fe6 100644 --- a/tildes/tests/test_simplestring_field.py +++ b/tildes/tests/test_simplestring_field.py @@ -1,13 +1,14 @@ # Copyright (c) 2018 Tildes contributors # SPDX-License-Identifier: AGPL-3.0-or-later -from marshmallow import Schema, ValidationError +from marshmallow import ValidationError from pytest import raises +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import SimpleString -class SimpleStringTestSchema(Schema): +class SimpleStringTestSchema(BaseTildesSchema): """Simple schema class with a standard SimpleString field.""" subject = SimpleString() diff --git a/tildes/tildes/json.py b/tildes/tildes/json.py index 57313f3..7e0101b 100644 --- a/tildes/tildes/json.py +++ b/tildes/tildes/json.py @@ -11,6 +11,7 @@ from tildes.models import DatabaseModel from tildes.models.group import Group from tildes.models.topic import Topic from tildes.models.user import User +from tildes.schemas.context import TildesSchemaContext, TildesSchemaContextDict def serialize_model(model_item: DatabaseModel, request: Request) -> dict: @@ -25,11 +26,12 @@ def serialize_model(model_item: DatabaseModel, request: Request) -> dict: def serialize_topic(topic: Topic, request: Request) -> dict: """Return serializable data for a Topic.""" - context = {} + context: TildesSchemaContextDict = {} if not request.has_permission("view_author", topic): context["hide_username"] = True - return topic.schema_class(context=context).dump(topic) + with TildesSchemaContext(context): + return topic.schema_class().dump(topic) def includeme(config: Configurator) -> None: diff --git a/tildes/tildes/schemas/base.py b/tildes/tildes/schemas/base.py new file mode 100644 index 0000000..1ab6b3c --- /dev/null +++ b/tildes/tildes/schemas/base.py @@ -0,0 +1,37 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Base Marshmallow schema.""" + + +from typing import Any, Optional +from marshmallow import Schema +from tildes.schemas.context import TildesSchemaContext, TildesSchemaContextDict + + +class BaseTildesSchema(Schema): + """Base Marshmallow schema for Tildes schemas. + + Adds common code like the context dict. + """ + + context: TildesSchemaContextDict + + def __init__( + self, context: Optional[TildesSchemaContextDict] = None, **kwargs: Any + ): + """Pass an optional context, and forward Schema arguments to superclass.""" + super().__init__(**kwargs) + self.context = context if context else {} + + def get_context_value(self, key: str) -> Any: + """Get a value from the context dict. + + Any active TildesSchemaContext, e.g. set using a "with" statement, + takes precedence. If there is no active TildesSchemaContext, then + it takes the value from the dict passed in __init__ instead. + """ + result = TildesSchemaContext.get(default=self.context).get(key) + if result: + return result + return self.context.get(key) diff --git a/tildes/tildes/schemas/comment.py b/tildes/tildes/schemas/comment.py index 01cfdd0..550b506 100644 --- a/tildes/tildes/schemas/comment.py +++ b/tildes/tildes/schemas/comment.py @@ -3,13 +3,12 @@ """Validation/dumping schema for comments.""" -from marshmallow import Schema - from tildes.enums import CommentLabelOption +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import Enum, ID36, Markdown, SimpleString -class CommentSchema(Schema): +class CommentSchema(BaseTildesSchema): """Marshmallow schema for comments.""" comment_id36 = ID36() @@ -17,8 +16,8 @@ class CommentSchema(Schema): parent_comment_id36 = ID36() -class CommentLabelSchema(Schema): +class CommentLabelSchema(BaseTildesSchema): """Marshmallow schema for comment labels.""" name = Enum(CommentLabelOption) - reason = SimpleString(max_length=1000, missing=None) + reason = SimpleString(max_length=1000, load_default=None) diff --git a/tildes/tildes/schemas/context.py b/tildes/tildes/schemas/context.py new file mode 100644 index 0000000..1064486 --- /dev/null +++ b/tildes/tildes/schemas/context.py @@ -0,0 +1,30 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Context variables that can be used with Marshmallow schemas.""" +import typing + +from marshmallow.experimental.context import Context + + +class TildesSchemaContextDict(typing.TypedDict, total=False): + """Context for Tildes Marshmallow schemas. + + For convenience, we use one unified class instead of one per schema, + so it can be passed down through different schemas in a subgraph. + For example, if a Topic contains a reference to a User, + one instance of TildesContext can configure both the Topic and User. + """ + + # Applies to UserSchema + hide_username: bool + # Applies to UserSchema + check_breached_passwords: bool + # Applies to UserSchema + username_trim_whitespace: bool + + # Applies to GroupSchema + fix_path_capitalization: bool + + +TildesSchemaContext = Context[TildesSchemaContextDict] diff --git a/tildes/tildes/schemas/fields.py b/tildes/tildes/schemas/fields.py index 48ff185..6510106 100644 --- a/tildes/tildes/schemas/fields.py +++ b/tildes/tildes/schemas/fields.py @@ -22,7 +22,7 @@ from tildes.lib.string import simplify_string DataType = Optional[Mapping[str, Any]] -class Enum(Field): +class Enum(Field[enum.Enum]): """Field for a native Python Enum (or subclasses).""" def __init__( @@ -34,9 +34,12 @@ class Enum(Field): self._enum_class = enum_class def _serialize( - self, value: enum.Enum, attr: str | None, obj: object, **kwargs: Any - ) -> str: + self, value: enum.Enum | None, attr: str | None, obj: object, **kwargs: Any + ) -> str | None: """Serialize the enum value - lowercase version of its name.""" + if value is None: + return None + return value.name.lower() def _deserialize( @@ -64,7 +67,7 @@ class ID36(String): super().__init__(validate=Regexp(ID36_REGEX), **kwargs) -class ShortTimePeriod(Field): +class ShortTimePeriod(Field[Optional[SimpleHoursPeriod]]): """Field for short time period strings like "4h" and "2d". Also supports the string "all" which will be converted to None. @@ -100,7 +103,7 @@ class ShortTimePeriod(Field): return value.as_short_form() -class Markdown(Field): +class Markdown(Field[str]): """Field for markdown strings (comments, text topic, messages, etc.).""" DEFAULT_MAX_LENGTH = 50000 @@ -132,13 +135,13 @@ class Markdown(Field): return value def _serialize( - self, value: str, attr: str | None, obj: object, **kwargs: Any - ) -> str: + self, value: str | None, attr: str | None, obj: object, **kwargs: Any + ) -> str | None: """Serialize the value (no-op in this case).""" return value -class SimpleString(Field): +class SimpleString(Field[str]): """Field for "simple" strings, suitable for uses like subject, title, etc. These strings should generally not contain any special formatting (such as @@ -169,13 +172,13 @@ class SimpleString(Field): return simplify_string(value) def _serialize( - self, value: str, attr: str | None, obj: object, **kwargs: Any - ) -> str: + self, value: str | None, attr: str | None, obj: object, **kwargs: Any + ) -> str | None: """Serialize the value (no-op in this case).""" return value -class Ltree(Field): +class Ltree(Field[sqlalchemy_utils.Ltree]): """Field for postgresql ltree type.""" # note that this regex only checks whether all of the chars are individually valid, diff --git a/tildes/tildes/schemas/group.py b/tildes/tildes/schemas/group.py index 1659cb8..53575b8 100644 --- a/tildes/tildes/schemas/group.py +++ b/tildes/tildes/schemas/group.py @@ -7,10 +7,12 @@ import re from typing import Any import sqlalchemy_utils -from marshmallow import pre_load, Schema, validates +from marshmallow import pre_load, validates from marshmallow.exceptions import ValidationError from marshmallow.fields import DateTime +from marshmallow.types import UnknownOption +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import Ltree, Markdown, SimpleString @@ -30,7 +32,7 @@ GROUP_PATH_ELEMENT_VALID_REGEX = re.compile( SHORT_DESCRIPTION_MAX_LENGTH = 200 -class GroupSchema(Schema): +class GroupSchema(BaseTildesSchema): """Marshmallow schema for groups.""" path = Ltree(required=True) @@ -41,10 +43,12 @@ class GroupSchema(Schema): sidebar_markdown = Markdown(allow_none=True) @pre_load - def prepare_path(self, data: dict, many: bool, partial: Any) -> dict: + def prepare_path( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Prepare the path value before it's validated.""" # pylint: disable=unused-argument - if not self.context.get("fix_path_capitalization"): + if not self.get_context_value("fix_path_capitalization"): return data if "path" not in data or not isinstance(data["path"], str): @@ -57,8 +61,9 @@ class GroupSchema(Schema): return new_data @validates("path") - def validate_path(self, value: sqlalchemy_utils.Ltree) -> None: + def validate_path(self, value: sqlalchemy_utils.Ltree, data_key: str) -> None: """Validate the path field, raising an error if an issue exists.""" + # pylint: disable=unused-argument # check each element for length and against validity regex path_elements = value.path.split(".") for element in path_elements: @@ -69,7 +74,9 @@ class GroupSchema(Schema): raise ValidationError("Path element %s is invalid" % element) @pre_load - def prepare_sidebar_markdown(self, data: dict, many: bool, partial: Any) -> dict: + def prepare_sidebar_markdown( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Prepare the sidebar_markdown value before it's validated.""" # pylint: disable=unused-argument if "sidebar_markdown" not in data: diff --git a/tildes/tildes/schemas/group_wiki_page.py b/tildes/tildes/schemas/group_wiki_page.py index ea4229f..31a849d 100644 --- a/tildes/tildes/schemas/group_wiki_page.py +++ b/tildes/tildes/schemas/group_wiki_page.py @@ -3,15 +3,14 @@ """Validation/dumping schema for group wiki pages.""" -from marshmallow import Schema - +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import Markdown, SimpleString PAGE_NAME_MAX_LENGTH = 40 -class GroupWikiPageSchema(Schema): +class GroupWikiPageSchema(BaseTildesSchema): """Marshmallow schema for group wiki pages.""" page_name = SimpleString(max_length=PAGE_NAME_MAX_LENGTH) diff --git a/tildes/tildes/schemas/listing.py b/tildes/tildes/schemas/listing.py index 4cdc783..aeb1305 100644 --- a/tildes/tildes/schemas/listing.py +++ b/tildes/tildes/schemas/listing.py @@ -5,23 +5,27 @@ from typing import Any -from marshmallow import pre_load, Schema, validates_schema, ValidationError +from marshmallow import pre_load, validates_schema, ValidationError from marshmallow.fields import Boolean, Integer +from marshmallow.types import UnknownOption from marshmallow.validate import Range from tildes.enums import TopicSortOption +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import Enum, ID36, Ltree, PostType, ShortTimePeriod -class PaginatedListingSchema(Schema): +class PaginatedListingSchema(BaseTildesSchema): """Marshmallow schema to validate arguments for a paginated listing page.""" - after = ID36(missing=None) - before = ID36(missing=None) - per_page = Integer(validate=Range(min=1, max=100), missing=50) + after = ID36(load_default=None) + before = ID36(load_default=None) + per_page = Integer(validate=Range(min=1, max=100), load_default=50) @validates_schema - def either_after_or_before(self, data: dict, many: bool, partial: Any) -> None: + def either_after_or_before( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> None: """Fail validation if both after and before were specified.""" # pylint: disable=unused-argument if data.get("after") and data.get("before"): @@ -32,15 +36,15 @@ class TopicListingSchema(PaginatedListingSchema): """Marshmallow schema to validate arguments for a topic listing page.""" period = ShortTimePeriod(allow_none=True) - order = Enum(TopicSortOption, missing=None) - tag = Ltree(missing=None) - unfiltered = Boolean(missing=False) - all_subgroups = Boolean(missing=False) - rank_start = Integer(data_key="n", validate=Range(min=1), missing=None) + order = Enum(TopicSortOption, load_default=None) + tag = Ltree(load_default=None) + unfiltered = Boolean(load_default=False) + all_subgroups = Boolean(load_default=False) + rank_start = Integer(data_key="n", validate=Range(min=1), load_default=None) @pre_load def reset_rank_start_on_first_page( - self, data: dict, many: bool, partial: Any + self, data: dict, many: bool, partial: Any, unknown: UnknownOption ) -> dict: """Reset rank_start to 1 if this is a first page (no before/after).""" # pylint: disable=unused-argument @@ -62,11 +66,11 @@ class MixedListingSchema(PaginatedListingSchema): of just one or the other. """ - anchor_type = PostType(missing=None) + anchor_type = PostType(load_default=None) @pre_load def set_anchor_type_from_before_or_after( - self, data: dict, many: bool, partial: Any + self, data: dict, many: bool, partial: Any, unknown: UnknownOption ) -> dict: """Set the anchor_type if before or after has a special value indicating type. diff --git a/tildes/tildes/schemas/message.py b/tildes/tildes/schemas/message.py index 2f16075..c82ccaa 100644 --- a/tildes/tildes/schemas/message.py +++ b/tildes/tildes/schemas/message.py @@ -3,16 +3,16 @@ """Validation/dumping schemas for messages.""" -from marshmallow import Schema from marshmallow.fields import DateTime, String +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import ID36, Markdown, SimpleString SUBJECT_MAX_LENGTH = 200 -class MessageConversationSchema(Schema): +class MessageConversationSchema(BaseTildesSchema): """Marshmallow schema for message conversations.""" conversation_id36 = ID36() @@ -22,7 +22,7 @@ class MessageConversationSchema(Schema): created_time = DateTime(dump_only=True) -class MessageReplySchema(Schema): +class MessageReplySchema(BaseTildesSchema): """Marshmallow schema for message replies.""" reply_id36 = ID36() diff --git a/tildes/tildes/schemas/topic.py b/tildes/tildes/schemas/topic.py index 41e2aae..1cd52ab 100644 --- a/tildes/tildes/schemas/topic.py +++ b/tildes/tildes/schemas/topic.py @@ -7,10 +7,12 @@ import re from typing import Any from urllib.parse import urlparse -from marshmallow import pre_load, Schema, validates, validates_schema, ValidationError +from marshmallow import pre_load, validates, validates_schema, ValidationError from marshmallow.fields import DateTime, List, Nested, String, URL +from marshmallow.types import UnknownOption from tildes.lib.url_transform import apply_url_transformations +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import Enum, ID36, Markdown, SimpleString from tildes.schemas.group import GroupSchema from tildes.schemas.user import UserSchema @@ -20,7 +22,7 @@ TITLE_MAX_LENGTH = 200 TAG_SYNONYMS = {"spoiler": ["spoilers"]} -class TopicSchema(Schema): +class TopicSchema(BaseTildesSchema): """Marshmallow schema for topics.""" topic_id36 = ID36() @@ -36,7 +38,9 @@ class TopicSchema(Schema): group = Nested(GroupSchema, dump_only=True) @pre_load - def prepare_title(self, data: dict, many: bool, partial: Any) -> dict: + def prepare_title( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Prepare the title before it's validated.""" # pylint: disable=unused-argument if "title" not in data: @@ -56,7 +60,9 @@ class TopicSchema(Schema): return new_data @pre_load - def prepare_tags(self, data: dict, many: bool, partial: Any) -> dict: + def prepare_tags( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Prepare the tags before they're validated.""" # pylint: disable=unused-argument if "tags" not in data: @@ -98,7 +104,7 @@ class TopicSchema(Schema): return new_data @validates("tags") - def validate_tags(self, value: list[str]) -> None: + def validate_tags(self, value: list[str], data_key: str) -> None: """Validate the tags field, raising an error if an issue exists. Note that tags are validated by ensuring that each tag would be a valid group @@ -107,6 +113,7 @@ class TopicSchema(Schema): between groups and tags. For example, a popular tag in a group could be converted into a sub-group easily. """ + # pylint: disable=unused-argument group_schema = GroupSchema(partial=True) for tag in value: try: @@ -115,7 +122,9 @@ class TopicSchema(Schema): raise ValidationError("Tag %s is invalid" % tag) from exc @pre_load - def prepare_markdown(self, data: dict, many: bool, partial: Any) -> dict: + def prepare_markdown( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Prepare the markdown value before it's validated.""" # pylint: disable=unused-argument if "markdown" not in data: @@ -130,7 +139,9 @@ class TopicSchema(Schema): return new_data @pre_load - def prepare_link(self, data: dict, many: bool, partial: Any) -> dict: + def prepare_link( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Prepare the link value before it's validated.""" # pylint: disable=unused-argument if "link" not in data: @@ -157,7 +168,9 @@ class TopicSchema(Schema): return new_data @validates_schema - def link_or_markdown(self, data: dict, many: bool, partial: Any) -> None: + def link_or_markdown( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> None: """Fail validation unless at least one of link or markdown were set.""" # pylint: disable=unused-argument if "link" not in data and "markdown" not in data: diff --git a/tildes/tildes/schemas/user.py b/tildes/tildes/schemas/user.py index 44bf55f..f73b7a8 100644 --- a/tildes/tildes/schemas/user.py +++ b/tildes/tildes/schemas/user.py @@ -6,12 +6,14 @@ import re from typing import Any -from marshmallow import post_dump, pre_load, Schema, validates, validates_schema +from marshmallow import post_dump, pre_load, validates, validates_schema from marshmallow.exceptions import ValidationError from marshmallow.fields import DateTime, Email, String +from marshmallow.types import UnknownOption from marshmallow.validate import Length, Regexp from tildes.lib.password import is_breached_password +from tildes.schemas.base import BaseTildesSchema from tildes.schemas.fields import Markdown @@ -41,7 +43,7 @@ EMAIL_ADDRESS_NOTE_MAX_LENGTH = 100 BIO_MAX_LENGTH = 2000 -class UserSchema(Schema): +class UserSchema(BaseTildesSchema): """Marshmallow schema for users.""" username = String( @@ -63,7 +65,7 @@ class UserSchema(Schema): def anonymize_username(self, data: dict, many: bool) -> dict: """Hide the username if the dumping context specifies to do so.""" # pylint: disable=unused-argument - if not self.context.get("hide_username"): + if not self.get_context_value("hide_username"): return data if "username" not in data: @@ -77,7 +79,7 @@ class UserSchema(Schema): @validates_schema def username_pass_not_substrings( - self, data: dict, many: bool, partial: Any + self, data: dict, many: bool, partial: Any, unknown: UnknownOption ) -> None: """Ensure the username isn't in the password and vice versa.""" # pylint: disable=unused-argument @@ -96,12 +98,13 @@ class UserSchema(Schema): raise ValidationError("Username cannot contain password") @validates("password") - def password_not_breached(self, value: str) -> None: + def password_not_breached(self, value: str, data_key: str) -> None: """Validate that the password is not in the breached-passwords list. Requires check_breached_passwords be True in the schema's context. """ - if not self.context.get("check_breached_passwords"): + # pylint: disable=unused-argument + if not self.get_context_value("check_breached_passwords"): return if is_breached_password(value): @@ -111,13 +114,15 @@ class UserSchema(Schema): ) @pre_load - def username_trim_whitespace(self, data: dict, many: bool, partial: Any) -> dict: + def username_trim_whitespace( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Trim leading/trailing whitespace around the username. Requires username_trim_whitespace be True in the schema's context. """ # pylint: disable=unused-argument - if not self.context.get("username_trim_whitespace"): + if not self.get_context_value("username_trim_whitespace"): return data if "username" not in data: @@ -130,7 +135,9 @@ class UserSchema(Schema): return new_data @pre_load - def prepare_email_address(self, data: dict, many: bool, partial: Any) -> dict: + def prepare_email_address( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Prepare the email address value before it's validated.""" # pylint: disable=unused-argument if "email_address" not in data: @@ -148,7 +155,9 @@ class UserSchema(Schema): return new_data @pre_load - def prepare_bio_markdown(self, data: dict, many: bool, partial: Any) -> dict: + def prepare_bio_markdown( + self, data: dict, many: bool, partial: Any, unknown: UnknownOption + ) -> dict: """Prepare the bio_markdown value before it's validated.""" # pylint: disable=unused-argument if "bio_markdown" not in data: diff --git a/tildes/tildes/views/api/web/comment.py b/tildes/tildes/views/api/web/comment.py index f5b1e74..63e2f17 100644 --- a/tildes/tildes/views/api/web/comment.py +++ b/tildes/tildes/views/api/web/comment.py @@ -335,7 +335,7 @@ def delete_label_comment(request: Request, name: CommentLabelOption) -> Response @ic_view_config( route_name="comment_mark_read", request_method="PUT", permission="mark_read" ) -@use_kwargs({"mark_all_previous": Boolean(missing=False)}, location="query") +@use_kwargs({"mark_all_previous": Boolean(load_default=False)}, location="query") def put_mark_comments_read(request: Request, mark_all_previous: bool) -> Response: """Mark comment(s) read, clearing notifications. diff --git a/tildes/tildes/views/api/web/group.py b/tildes/tildes/views/api/web/group.py index ccb90ab..b717f5d 100644 --- a/tildes/tildes/views/api/web/group.py +++ b/tildes/tildes/views/api/web/group.py @@ -86,7 +86,7 @@ def delete_subscribe_group(request: Request) -> dict: @use_kwargs( { "order": Enum(TopicSortOption), - "period": ShortTimePeriod(allow_none=True, missing=None), + "period": ShortTimePeriod(allow_none=True, load_default=None), }, location="form", ) diff --git a/tildes/tildes/views/api/web/topic.py b/tildes/tildes/views/api/web/topic.py index 2d28557..40cb98f 100644 --- a/tildes/tildes/views/api/web/topic.py +++ b/tildes/tildes/views/api/web/topic.py @@ -158,7 +158,8 @@ def get_topic_tags(request: Request) -> dict: permission="tag", ) @use_kwargs( - {"tags": String(missing=""), "conflict_check": String(missing="")}, location="form" + {"tags": String(load_default=""), "conflict_check": String(load_default="")}, + location="form", ) def put_tag_topic(request: Request, tags: str, conflict_check: str) -> dict: """Apply tags to a topic with Intercooler.""" diff --git a/tildes/tildes/views/api/web/user.py b/tildes/tildes/views/api/web/user.py index a9b70f3..c5aa2ee 100644 --- a/tildes/tildes/views/api/web/user.py +++ b/tildes/tildes/views/api/web/user.py @@ -23,6 +23,7 @@ from tildes.lib.datetime import SimpleHoursPeriod from tildes.lib.string import separate_string from tildes.models.log import Log from tildes.models.user import User, UserInviteCode +from tildes.schemas.context import TildesSchemaContext, TildesSchemaContextDict from tildes.schemas.fields import Enum, ShortTimePeriod from tildes.schemas.topic import TopicSchema from tildes.schemas.user import UserSchema @@ -54,12 +55,13 @@ def patch_change_password( user = request.context # enable checking the new password against the breached-passwords list - user.schema.context["check_breached_passwords"] = True + context: TildesSchemaContextDict = {"check_breached_passwords": True} if new_password != new_password_confirm: raise HTTPUnprocessableEntity("New password and confirmation do not match.") - user.change_password(old_password, new_password) + with TildesSchemaContext(context): + user.change_password(old_password, new_password) return Response("Your password has been updated") @@ -357,7 +359,7 @@ def get_invite_code(request: Request) -> dict: @use_kwargs( { "order": Enum(TopicSortOption), - "period": ShortTimePeriod(allow_none=True, missing=None), + "period": ShortTimePeriod(allow_none=True, load_default=None), }, location="form", ) diff --git a/tildes/tildes/views/bookmarks.py b/tildes/tildes/views/bookmarks.py index 4c68b01..3b046d6 100644 --- a/tildes/tildes/views/bookmarks.py +++ b/tildes/tildes/views/bookmarks.py @@ -15,7 +15,7 @@ from tildes.views.decorators import use_kwargs @view_config(route_name="bookmarks", renderer="bookmarks.jinja2") @use_kwargs(PaginatedListingSchema()) -@use_kwargs({"post_type": PostType(data_key="type", missing="topic")}) +@use_kwargs({"post_type": PostType(data_key="type", load_default="topic")}) def get_bookmarks( request: Request, after: Optional[str], diff --git a/tildes/tildes/views/decorators.py b/tildes/tildes/views/decorators.py index 677d3b8..b3637fa 100644 --- a/tildes/tildes/views/decorators.py +++ b/tildes/tildes/views/decorators.py @@ -4,7 +4,7 @@ """Contains decorators for view functions.""" from collections.abc import Callable -from typing import Any, Union +from typing import Any from marshmallow import EXCLUDE from marshmallow.fields import Field @@ -16,7 +16,7 @@ from webargs import pyramidparser def use_kwargs( - argmap: Union[Schema, dict[str, Field]], location: str = "query", **kwargs: Any + argmap: Schema | dict[str, Field], location: str = "query", **kwargs: Any ) -> Callable: """Wrap the webargs @use_kwargs decorator with preferred default modifications. @@ -36,7 +36,7 @@ def use_kwargs( argmap.unknown = EXCLUDE - return pyramidparser.use_kwargs(argmap, location=location, **kwargs) + return pyramidparser.use_kwargs(argmap, location=location, unknown=None, **kwargs) def ic_view_config(**kwargs: Any) -> Callable: diff --git a/tildes/tildes/views/login.py b/tildes/tildes/views/login.py index b6f519d..ef68091 100644 --- a/tildes/tildes/views/login.py +++ b/tildes/tildes/views/login.py @@ -26,7 +26,7 @@ from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs @view_config( route_name="login", renderer="login.jinja2", permission=NO_PERMISSION_REQUIRED ) -@use_kwargs({"from_url": String(missing="")}) +@use_kwargs({"from_url": String(load_default="")}) @not_logged_in def get_login(request: Request, from_url: str) -> dict: """Display the login form.""" @@ -65,7 +65,7 @@ def finish_login(request: Request, user: User, redirect_url: str) -> HTTPFound: ), location="form", ) -@use_kwargs({"from_url": String(missing="")}, location="form") +@use_kwargs({"from_url": String(load_default="")}, location="form") @not_logged_in @rate_limit_view("login") def post_login( @@ -148,7 +148,8 @@ def post_login( @not_logged_in @rate_limit_view("login_two_factor") @use_kwargs( - {"code": String(missing=""), "from_url": String(missing="")}, location="form" + {"code": String(load_default=""), "from_url": String(load_default="")}, + location="form", ) def post_login_two_factor(request: Request, code: str, from_url: str) -> NoReturn: """Process a log in request with 2FA.""" diff --git a/tildes/tildes/views/message.py b/tildes/tildes/views/message.py index 05f1452..95ecafe 100644 --- a/tildes/tildes/views/message.py +++ b/tildes/tildes/views/message.py @@ -18,7 +18,7 @@ from tildes.views.decorators import use_kwargs @view_config( route_name="new_message", renderer="new_message.jinja2", permission="message" ) -@use_kwargs({"subject": String(missing=""), "message": String(missing="")}) +@use_kwargs({"subject": String(load_default=""), "message": String(load_default="")}) def get_new_message_form(request: Request, subject: str, message: str) -> dict: """Form for entering a new private message to send.""" return { diff --git a/tildes/tildes/views/register.py b/tildes/tildes/views/register.py index bab4a45..4a44d88 100644 --- a/tildes/tildes/views/register.py +++ b/tildes/tildes/views/register.py @@ -22,7 +22,7 @@ from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs @view_config( route_name="register", renderer="register.jinja2", permission=NO_PERMISSION_REQUIRED ) -@use_kwargs({"code": String(missing="")}) +@use_kwargs({"code": String(load_default="")}) @not_logged_in def get_register(request: Request, code: str) -> dict: """Display the registration form.""" diff --git a/tildes/tildes/views/settings.py b/tildes/tildes/views/settings.py index 0af7577..280e446 100644 --- a/tildes/tildes/views/settings.py +++ b/tildes/tildes/views/settings.py @@ -22,6 +22,7 @@ from tildes.models.comment import Comment, CommentLabel, CommentTree from tildes.models.group import Group from tildes.models.topic import Topic from tildes.models.user import User +from tildes.schemas.context import TildesSchemaContextDict, TildesSchemaContext from tildes.schemas.user import ( BIO_MAX_LENGTH, EMAIL_ADDRESS_NOTE_MAX_LENGTH, @@ -151,12 +152,13 @@ def post_settings_password_change( ) -> Response: """Change the logged-in user's password.""" # enable checking the new password against the breached-passwords list - request.user.schema.context["check_breached_passwords"] = True + context: TildesSchemaContextDict = {"check_breached_passwords": True} if new_password != new_password_confirm: raise HTTPUnprocessableEntity("New password and confirmation do not match.") - request.user.change_password(old_password, new_password) + with TildesSchemaContext(context): + request.user.change_password(old_password, new_password) return Response("Your password has been updated") diff --git a/tildes/tildes/views/topic.py b/tildes/tildes/views/topic.py index 376cd8e..336a15d 100644 --- a/tildes/tildes/views/topic.py +++ b/tildes/tildes/views/topic.py @@ -48,7 +48,7 @@ DefaultSettings = namedtuple("DefaultSettings", ["order", "period"]) @view_config(route_name="group_topics", request_method="POST", permission="topic.post") @use_kwargs(TopicSchema(only=("title", "markdown", "link")), location="form") @use_kwargs( - {"tags": String(missing=""), "confirm_repost": Boolean(missing=False)}, + {"tags": String(load_default=""), "confirm_repost": Boolean(load_default=False)}, location="form", ) def post_group_topics( @@ -345,7 +345,7 @@ def get_group_topics( # noqa @view_config(route_name="search", renderer="search.jinja2") @view_config(route_name="group_search", renderer="search.jinja2") @use_kwargs(TopicListingSchema(only=("after", "before", "order", "per_page", "period"))) -@use_kwargs({"search": String(data_key="q", missing="")}) +@use_kwargs({"search": String(data_key="q", load_default="")}) def get_search( request: Request, order: Optional[TopicSortOption], @@ -414,7 +414,7 @@ def get_search( @view_config( route_name="new_topic", renderer="new_topic.jinja2", permission="topic.post" ) -@use_kwargs({"title": String(missing=""), "link": String(missing="")}) +@use_kwargs({"title": String(load_default=""), "link": String(load_default="")}) def get_new_topic_form(request: Request, title: str, link: str) -> dict: """Form for entering a new topic to post.""" group = request.context @@ -424,7 +424,7 @@ def get_new_topic_form(request: Request, title: str, link: str) -> dict: @view_config(route_name="topic", renderer="topic.jinja2") @view_config(route_name="topic_no_title", renderer="topic.jinja2") -@use_kwargs({"comment_order": Enum(CommentTreeSortOption, missing=None)}) +@use_kwargs({"comment_order": Enum(CommentTreeSortOption, load_default=None)}) def get_topic(request: Request, comment_order: CommentTreeSortOption) -> dict: """View a single topic.""" topic = request.context diff --git a/tildes/tildes/views/user.py b/tildes/tildes/views/user.py index 71d4695..d9b38e7 100644 --- a/tildes/tildes/views/user.py +++ b/tildes/tildes/views/user.py @@ -25,8 +25,8 @@ from tildes.views.decorators import use_kwargs @use_kwargs(MixedListingSchema()) @use_kwargs( { - "post_type": PostType(data_key="type", missing=None), - "order_name": String(data_key="order", missing="new"), + "post_type": PostType(data_key="type", load_default=None), + "order_name": String(data_key="order", load_default="new"), } ) def get_user( @@ -94,8 +94,8 @@ def get_user( @use_kwargs( { "post_type": PostType(data_key="type", required=True), - "order_name": String(data_key="order", missing="new"), - "search": String(data_key="q", missing=""), + "order_name": String(data_key="order", load_default="new"), + "search": String(data_key="q", load_default=""), } ) def get_user_search( diff --git a/tildes/tildes/views/votes.py b/tildes/tildes/views/votes.py index ba5f798..ffdb619 100644 --- a/tildes/tildes/views/votes.py +++ b/tildes/tildes/views/votes.py @@ -15,7 +15,7 @@ from tildes.views.decorators import use_kwargs @view_config(route_name="votes", renderer="votes.jinja2") @use_kwargs(PaginatedListingSchema()) -@use_kwargs({"post_type": PostType(data_key="type", missing="topic")}) +@use_kwargs({"post_type": PostType(data_key="type", load_default="topic")}) def get_voted_posts( request: Request, after: Optional[str],