diff --git a/tildes/requirements-dev.txt b/tildes/requirements-dev.txt index 2c0ef37..8b6532c 100644 --- a/tildes/requirements-dev.txt +++ b/tildes/requirements-dev.txt @@ -43,7 +43,7 @@ lazy-object-proxy==1.11.0 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 more-itertools==10.7.0 @@ -135,7 +135,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 15af711..1db4e1b 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 @@ -37,6 +37,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 4e73db4..f7015c4 100644 --- a/tildes/requirements.txt +++ b/tildes/requirements.txt @@ -31,7 +31,7 @@ lazy-object-proxy==1.11.0 lupa==2.5 mako==1.3.10 markupsafe==3.0.2 -marshmallow==3.25.1 +marshmallow==4.0.1 matplotlib-inline==0.1.7 more-itertools==10.7.0 openapi-core==0.19.5 @@ -89,7 +89,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/tildes/json.py b/tildes/tildes/json.py index 57313f3..7ec8a0b 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, TildesContext 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: TildesContext = {} 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/resources/group.py b/tildes/tildes/resources/group.py index b95f964..5a4d8f6 100644 --- a/tildes/tildes/resources/group.py +++ b/tildes/tildes/resources/group.py @@ -10,12 +10,14 @@ from sqlalchemy_utils import Ltree from tildes.models.group import Group, GroupWikiPage from tildes.resources import get_resource +from tildes.schemas.context import TildesSchemaContext, TildesContext from tildes.schemas.group import GroupSchema from tildes.views.decorators import use_kwargs @use_kwargs( - GroupSchema(only=("path",), context={"fix_path_capitalization": True}), + GroupSchema(only=("path",)), + context=TildesSchemaContext(TildesContext(fix_path_capitalization=True)), location="matchdict", ) def group_by_path(request: Request, path: str) -> Group: diff --git a/tildes/tildes/schemas/comment.py b/tildes/tildes/schemas/comment.py index 01cfdd0..307d897 100644 --- a/tildes/tildes/schemas/comment.py +++ b/tildes/tildes/schemas/comment.py @@ -21,4 +21,4 @@ class CommentLabelSchema(Schema): """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..495d900 --- /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 TildesContext(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[TildesContext] 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..0bd674e 100644 --- a/tildes/tildes/schemas/group.py +++ b/tildes/tildes/schemas/group.py @@ -10,7 +10,9 @@ import sqlalchemy_utils from marshmallow import pre_load, Schema, validates from marshmallow.exceptions import ValidationError from marshmallow.fields import DateTime +from marshmallow.types import UnknownOption +from tildes.schemas.context import TildesSchemaContext, TildesContext from tildes.schemas.fields import Ltree, Markdown, SimpleString @@ -41,10 +43,14 @@ 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 TildesSchemaContext.get(default=TildesContext()).get( + "fix_path_capitalization" + ): return data if "path" not in data or not isinstance(data["path"], str): @@ -57,8 +63,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 +76,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/listing.py b/tildes/tildes/schemas/listing.py index 4cdc783..1c2a8e7 100644 --- a/tildes/tildes/schemas/listing.py +++ b/tildes/tildes/schemas/listing.py @@ -7,6 +7,7 @@ from typing import Any from marshmallow import pre_load, Schema, validates_schema, ValidationError from marshmallow.fields import Boolean, Integer +from marshmallow.types import UnknownOption from marshmallow.validate import Range from tildes.enums import TopicSortOption @@ -16,12 +17,14 @@ from tildes.schemas.fields import Enum, ID36, Ltree, PostType, ShortTimePeriod class PaginatedListingSchema(Schema): """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 +35,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 +65,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/topic.py b/tildes/tildes/schemas/topic.py index 41e2aae..f133fb7 100644 --- a/tildes/tildes/schemas/topic.py +++ b/tildes/tildes/schemas/topic.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse from marshmallow import pre_load, Schema, 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.fields import Enum, ID36, Markdown, SimpleString @@ -36,7 +37,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 +59,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 +103,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 +112,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 +121,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 +138,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 +167,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..b1ca227 100644 --- a/tildes/tildes/schemas/user.py +++ b/tildes/tildes/schemas/user.py @@ -9,9 +9,11 @@ from typing import Any from marshmallow import post_dump, pre_load, Schema, 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.context import TildesSchemaContext, TildesContext from tildes.schemas.fields import Markdown @@ -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 TildesSchemaContext.get(default=TildesContext()).get("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,15 @@ 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 TildesSchemaContext.get(default=TildesContext()).get( + "check_breached_passwords" + ): return if is_breached_password(value): @@ -111,13 +116,17 @@ 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 TildesSchemaContext.get(default=TildesContext()).get( + "username_trim_whitespace" + ): return data if "username" not in data: @@ -130,7 +139,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 +159,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..a16a91a 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, TildesContext 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: TildesContext = {"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..f79f2a1 100644 --- a/tildes/tildes/views/decorators.py +++ b/tildes/tildes/views/decorators.py @@ -4,9 +4,10 @@ """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.experimental.context import Context from marshmallow.fields import Field from marshmallow.schema import Schema from pyramid.httpexceptions import HTTPFound @@ -14,9 +15,14 @@ from pyramid.request import Request from pyramid.view import view_config from webargs import pyramidparser +from tildes.schemas.context import TildesSchemaContext, TildesContext + def use_kwargs( - argmap: Union[Schema, dict[str, Field]], location: str = "query", **kwargs: Any + argmap: Schema | dict[str, Field], + location: str = "query", + context: Context[Any] | None = None, + **kwargs: Any ) -> Callable: """Wrap the webargs @use_kwargs decorator with preferred default modifications. @@ -28,15 +34,21 @@ def use_kwargs( it just ignores them, instead of erroring when there's unexpected data (as there almost always is, especially because of Intercooler). """ - # convert a dict argmap to a Schema (the same way webargs would on its own) - if isinstance(argmap, dict): - argmap = Schema.from_dict(argmap)() + if context is None: + context = TildesSchemaContext(TildesContext()) + + with context: + # convert a dict argmap to a Schema (the same way webargs would on its own) + if isinstance(argmap, dict): + argmap = Schema.from_dict(argmap)() - assert isinstance(argmap, Schema) # tell mypy the type is more restricted now + assert isinstance(argmap, Schema) # tell mypy the type is more restricted now - argmap.unknown = EXCLUDE + 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..ba42336 100644 --- a/tildes/tildes/views/login.py +++ b/tildes/tildes/views/login.py @@ -19,6 +19,7 @@ from tildes.enums import LogEventType from tildes.metrics import incr_counter from tildes.models.log import Log from tildes.models.user import User +from tildes.schemas.context import TildesSchemaContext, TildesContext from tildes.schemas.user import UserSchema from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs @@ -26,7 +27,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.""" @@ -60,12 +61,11 @@ def finish_login(request: Request, user: User, redirect_url: str) -> HTTPFound: route_name="login", request_method="POST", permission=NO_PERMISSION_REQUIRED ) @use_kwargs( - UserSchema( - only=("username", "password"), context={"username_trim_whitespace": True} - ), + UserSchema(only=("username", "password")), + context=TildesSchemaContext(TildesContext(username_trim_whitespace=True)), 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..527da50 100644 --- a/tildes/tildes/views/register.py +++ b/tildes/tildes/views/register.py @@ -15,6 +15,7 @@ from tildes.metrics import incr_counter from tildes.models.group import Group, GroupSubscription from tildes.models.log import Log from tildes.models.user import User, UserInviteCode +from tildes.schemas.context import TildesSchemaContext, TildesContext from tildes.schemas.user import UserSchema from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs @@ -22,7 +23,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.""" @@ -34,9 +35,8 @@ def get_register(request: Request, code: str) -> dict: route_name="register", request_method="POST", permission=NO_PERMISSION_REQUIRED ) @use_kwargs( - UserSchema( - only=("username", "password"), context={"check_breached_passwords": True} - ), + UserSchema(only=("username", "password")), + context=TildesSchemaContext(TildesContext(check_breached_passwords=True)), location="form", ) @use_kwargs( diff --git a/tildes/tildes/views/settings.py b/tildes/tildes/views/settings.py index 0af7577..42e0cdd 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 TildesContext, 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: TildesContext = {"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],