diff --git a/guide/config/en/sidebar.yaml b/guide/config/en/sidebar.yaml index 000c4beafc..cbcc5a6cca 100644 --- a/guide/config/en/sidebar.yaml +++ b/guide/config/en/sidebar.yaml @@ -43,6 +43,8 @@ root: path: guide/advanced/versioning.html - label: Signals path: guide/advanced/signals.html + - label: Custom CLI Commands + path: guide/advanced/commands.html - label: Best Practices items: - label: Blueprints @@ -145,6 +147,8 @@ root: items: - label: "2024" items: + - label: Sanic 24.12 + path: release-notes/2024/v24.12.html - label: Sanic 24.6 path: release-notes/2024/v24.6.html - label: "2023" diff --git a/guide/content/en/guide/advanced/commands.md b/guide/content/en/guide/advanced/commands.md new file mode 100644 index 0000000000..390fd98adc --- /dev/null +++ b/guide/content/en/guide/advanced/commands.md @@ -0,0 +1,57 @@ +# Custom CLI Commands + +.. new:: New in v24.12 + + This feature was added in version 24.12 + +Sanic ships with a [CLI](../running/running.html#running-via-command) for running the Sanic server. Sometimes, you may have the need to enhance that CLI to run your own custom commands. Commands are invoked using the following basic pattern: + +```sh +sanic path.to:app exec [--arg=value] +``` + +.. column:: + + To enable this, you can use your `Sanic` app instance to wrap functions that can be callable from the CLI using the `@app.command` decorator. + +.. column:: + + ```python + @app.command + async def hello(name="world"): + print(f"Hello, {name}.") + ``` + +.. column:: + + Now, you can easily invoke this command using the `exec` action. + +.. column:: + + ```sh + sanic path.to:app exec hello --name=Adam + ``` + +Command handlers can be either synchronous or asynchronous. The handler can accept any number of keyword arguments, which will be passed in from the CLI. + +.. column:: + + By default, the name of the function will be the command name. You can override this by passing the `name` argument to the decorator. + +.. column:: + + ```python + @app.command(name="greet") + async def hello(name="world"): + print(f"Hello, {name}.") + ``` + + ```sh + sanic path.to:app exec greet --name=Adam + ``` + +.. warning:: + + This feature is still in **BETA** and may change in future versions. There is no type coercion or validation on the arguments passed in from the CLI, and the CLI will ignore any return values from the command handler. Future enhancements and changes are likely. + +*Added in v24.12* diff --git a/guide/content/en/organization/policies.md b/guide/content/en/organization/policies.md index 4910e6ceda..d091693b81 100644 --- a/guide/content/en/organization/policies.md +++ b/guide/content/en/organization/policies.md @@ -37,10 +37,12 @@ Sanic releases a long term support release (aka "LTS") once a year in December. | Version | Release | LTS | Supported | |---------|------------|---------------|-----------------| -| 23.12 | 2023-12-31 | until 2025-12 | ✅ | +| 24.12 | 2024-12-31 | until 2026-12 | ✅ | +| 24.6 | 2024-06-30 | | ⚪ | +| 23.12 | 2023-12-31 | until 2025-12 | ☑️ | | 23.6 | 2023-07-25 | | ⚪ | | 23.3 | 2023-03-26 | | ⚪ | -| 22.12 | 2022-12-27 | until 2024-12 | ☑️ | +| 22.12 | 2022-12-27 | | ☑️ | | 22.9 | 2022-09-29 | | ⚪ | | 22.6 | 2022-06-30 | | ⚪ | | 22.3 | 2022-03-31 | | ⚪ | diff --git a/guide/content/en/release-notes/2024/v24.12.md b/guide/content/en/release-notes/2024/v24.12.md new file mode 100644 index 0000000000..d9d74f75a7 --- /dev/null +++ b/guide/content/en/release-notes/2024/v24.12.md @@ -0,0 +1,79 @@ +--- +title: Version 24.12 +--- + +# Version 24.12 + +.. toc:: + + +## Introduction + +This is the first release of the version 24 [release cycle](../../organization/policies.md#release-schedule). The release cadence for v24 may be slightly altered from years past. Make sure to stay up to date in the Discord server for latest updates. If you run into any issues, please raise a concern on [GitHub](https://github.com/sanic-org/sanic/issues/new/choose). + +## What to know + +More details in the [Changelog](../changelog.html). Notable new or breaking features, and what to upgrade: + +### 👶 _BETA_ Custom CLI commands + +The `sanic` CLI utility now allows for custom commands to be invoked. Commands can be added using the decorator syntax below. + +```python +@app.command +async def foo(one, two: str, three: str = "..."): + logger.info(f"FOO {one=} {two=} {three=}") + + +@app.command +def bar(): + logger.info("BAR") + + +@app.command(name="qqq") +async def baz(): + logger.info("BAZ") +``` + +These are invoked using the `exec` command as follows. + +```sh +sanic server:app exec [--arg=value] +``` + +Any arguments in the function's signature will be added as arguments. For example: + +```sh +sanic server:app exec command --one=1 --two=2 --three=3 +``` + +.. warning:: + + This is in **BETA** and the functionality is subject to change in upcoming versions. + +### Add Python 3.13 support + +We have added Python 3.13 to the supported versions. + +### Remove Python 3.8 support + +Python 3.8 reached end-of-life. Sanic is now dropping support for Python 3.8, and requires Python 3.9 or newer. + +### Old response cookie accessors removed + +Prior to v23, cookies on `Response` objects were set and accessed as dictionary objects. That was deprecated in v23.3 when the new [convenience methods](../2023/v23.3.html#more-convenient-methods-for-setting-and-deleting-cookies) were added. The old patterns have been removed. + +## Thank you + +Thank you to everyone that participated in this release: :clap: + +[@ahopkins](https://github.com/ahopkins) +[@C5H12O5](https://github.com/C5H12O5) +[@ChihweiLHBird](https://github.com/ChihweiLHBird) +[@HyperKiko](https://github.com/HyperKiko) +[@imnotjames](https://github.com/imnotjames) +[@pygeek](https://github.com/pygeek) + +--- + +If you enjoy the project, please consider contributing. Of course we love code contributions, but we also love contributions in any form. Consider writing some documentation, showing off use cases, joining conversations and making your voice known, and if you are able: [financial contributions](https://opencollective.com/sanic-org/). diff --git a/guide/content/en/release-notes/changelog.md b/guide/content/en/release-notes/changelog.md index bbf3192ece..3e52b2252a 100644 --- a/guide/content/en/release-notes/changelog.md +++ b/guide/content/en/release-notes/changelog.md @@ -7,11 +7,32 @@ content_class: changelog 🔶 Current release 🔷 In support LTS release - -## Version 24.6.0 🔶 +## Version 24.12.0 🔶🔷 _Current version_ +### Features +- [#3019](https://github.com/sanic-org/sanic/pull/3019) Add custom commands to `sanic` CLI + +### Bugfixes +- [#2992](https://github.com/sanic-org/sanic/pull/2992) Fix `mixins.startup.serve` UnboundLocalError +- [#3000](https://github.com/sanic-org/sanic/pull/3000) Fix type annocation for `JSONResponse` method for return type `bytes` allowed for `dumps` callable +- [#3009](https://github.com/sanic-org/sanic/pull/3009) Fix `SanicException.quiet` attribute handling when set to `False` +- [#3014](https://github.com/sanic-org/sanic/pull/3014) Cleanup some typing +- [#3015](https://github.com/sanic-org/sanic/pull/3015) Kill the entire process group if applicable +- [#3016](https://github.com/sanic-org/sanic/pull/3016) Fix incompatible type annotation of get method in the HTTPMethodView class + +### Deprecations and Removals +- [#3020](https://github.com/sanic-org/sanic/pull/3020) Remove Python 3.8 support + +### Developer infrastructure +- [#3017](https://github.com/sanic-org/sanic/pull/3017) Cleanup setup.cfg + +### Improved Documentation +- [#3007](https://github.com/sanic-org/sanic/pull/3007) Fix typo in documentation for `sanic-ext` + +## Version 24.6.0 + ### Features - [#2838](https://github.com/sanic-org/sanic/pull/2838) Simplify request cookies `getlist` - [#2850](https://github.com/sanic-org/sanic/pull/2850) Unix sockets can now use `pathlib.Path` @@ -193,7 +214,7 @@ From that list, the items to highlight in the release notes: - [#2712](https://github.com/sanic-org/sanic/pull/2712) Improved example using ``'https'`` to create the redirect -## Version 22.12.0 🔷 +## Version 22.12.0 _Current LTS version_ diff --git a/sanic/__version__.py b/sanic/__version__.py index a7f5098aa0..a6b149417e 100644 --- a/sanic/__version__.py +++ b/sanic/__version__.py @@ -1 +1 @@ -__version__ = "24.6.0" +__version__ = "24.12.0" diff --git a/sanic/cookies/request.py b/sanic/cookies/request.py index f214cfc7cc..adbe3501bf 100644 --- a/sanic/cookies/request.py +++ b/sanic/cookies/request.py @@ -3,7 +3,6 @@ from typing import Any, Optional from sanic.cookies.response import Cookie -from sanic.log import deprecation from sanic.request.parameters import RequestParameters @@ -126,21 +125,11 @@ class CookieRequestParameters(RequestParameters): """ # noqa: E501 def __getitem__(self, key: str) -> Optional[str]: - deprecation( - f"You are accessing cookie key '{key}', which is currently in " - "compat mode returning a single cookie value. Starting in v24.9 " - "accessing a cookie value like this will return a list of values. " - "To avoid this behavior and continue accessing a single value, " - f"please upgrade from request.cookies['{key}'] to " - f"request.cookies.get('{key}'). See more details: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#request-cookies", # noqa - 24.9, - ) try: value = self._get_prefixed_cookie(key) except KeyError: value = super().__getitem__(key) - return value[0] + return value def __getattr__(self, key: str) -> str: if key.startswith("_"): diff --git a/sanic/cookies/response.py b/sanic/cookies/response.py index 18f938f68d..4fa69d7c0a 100644 --- a/sanic/cookies/response.py +++ b/sanic/cookies/response.py @@ -4,10 +4,9 @@ import string from datetime import datetime -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Union from sanic.exceptions import ServerError -from sanic.log import deprecation if TYPE_CHECKING: @@ -48,8 +47,7 @@ def _quote(str): # no cov _is_legal_key = re.compile("[%s]+" % re.escape(LEGAL_CHARS)).fullmatch -# In v24.9, we should remove this as being a subclass of dict -class CookieJar(dict): +class CookieJar: """A container to manipulate cookies. CookieJar dynamically writes headers as cookies are added and removed @@ -63,130 +61,11 @@ class CookieJar(dict): HEADER_KEY = "Set-Cookie" def __init__(self, headers: Header): - super().__init__() self.headers = headers - def __setitem__(self, key, value): - # If this cookie doesn't exist, add it to the header keys - deprecation( - "Setting cookie values using the dict pattern has been " - "deprecated. You should instead use the cookies.add_cookie " - "method. To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 0, - ) - if key not in self: - self.add_cookie(key, value, secure=False, samesite=None) - else: - self[key].value = value - - def __delitem__(self, key): - deprecation( - "Deleting cookie values using the dict pattern has been " - "deprecated. You should instead use the cookies.delete_cookie " - "method. To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 0, - ) - if key in self: - super().__delitem__(key) - self.delete_cookie(key) - def __len__(self): # no cov return len(self.cookies) - def __getitem__(self, key: str) -> Cookie: - deprecation( - "Accessing cookies from the CookieJar by dict key is deprecated. " - "You should instead use the cookies.get_cookie method. " - "To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 0, - ) - return super().__getitem__(key) - - def __iter__(self): # no cov - deprecation( - "Iterating over the CookieJar has been deprecated and will be " - "removed in v24.9. To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 24.9, - ) - return super().__iter__() - - def keys(self): # no cov - """Deprecated in v24.9""" - deprecation( - "Accessing CookieJar.keys() has been deprecated and will be " - "removed in v24.9. To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 24.9, - ) - return super().keys() - - def values(self): # no cov - """Deprecated in v24.9""" - deprecation( - "Accessing CookieJar.values() has been deprecated and will be " - "removed in v24.9. To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 24.9, - ) - return super().values() - - def items(self): # no cov - """Deprecated in v24.9""" - deprecation( - "Accessing CookieJar.items() has been deprecated and will be " - "removed in v24.9. To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 24.9, - ) - return super().items() - - def get(self, *args, **kwargs): # no cov - """Deprecated in v24.9""" - deprecation( - "Accessing cookies from the CookieJar using get is deprecated " - "and will be removed in v24.9. You should instead use the " - "cookies.get_cookie method. To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 24.9, - ) - return super().get(*args, **kwargs) - - def pop(self, key, *args, **kwargs): # no cov - """Deprecated in v24.9""" - deprecation( - "Using CookieJar.pop() has been deprecated and will be " - "removed in v24.9. To learn more, please see: " - "https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa - 24.9, - ) - self.delete(key) - return super().pop(key, *args, **kwargs) - - @property - def header_key(self): # no cov - """Deprecated in v24.9""" - deprecation( - "The CookieJar.header_key property has been deprecated and will " - "be removed in version 24.9. Use CookieJar.HEADER_KEY. ", - 24.9, - ) - return CookieJar.HEADER_KEY - - @property - def cookie_headers(self) -> dict[str, str]: # no cov - """Deprecated in v24.9""" - deprecation( - "The CookieJar.coookie_headers property has been deprecated " - "and will be removed in version 24.9. If you need to check if a " - "particular cookie key has been set, use CookieJar.has_cookie.", - 24.9, - ) - return {key: self.header_key for key in self} - @property def cookies(self) -> list[Cookie]: """A list of cookies in the CookieJar. @@ -200,10 +79,10 @@ def get_cookie( self, key: str, path: str = "/", - domain: Optional[str] = None, + domain: str | None = None, host_prefix: bool = False, secure_prefix: bool = False, - ) -> Optional[Cookie]: + ) -> Cookie | None: """Fetch a cookie from the CookieJar. Args: @@ -233,7 +112,7 @@ def has_cookie( self, key: str, path: str = "/", - domain: Optional[str] = None, + domain: str | None = None, host_prefix: bool = False, secure_prefix: bool = False, ) -> bool: @@ -268,14 +147,14 @@ def add_cookie( value: str, *, path: str = "/", - domain: Optional[str] = None, + domain: str | None = None, secure: bool = True, - max_age: Optional[int] = None, - expires: Optional[datetime] = None, + max_age: int | None = None, + expires: datetime | None = None, httponly: bool = False, - samesite: Optional[SameSite] = "Lax", + samesite: SameSite | None = "Lax", partitioned: bool = False, - comment: Optional[str] = None, + comment: str | None = None, host_prefix: bool = False, secure_prefix: bool = False, ) -> Cookie: @@ -349,9 +228,6 @@ def add_cookie( ) self.headers.add(self.HEADER_KEY, cookie) - # This should be removed in v24.9 - super().__setitem__(key, cookie) - return cookie def delete_cookie( @@ -359,7 +235,7 @@ def delete_cookie( key: str, *, path: str = "/", - domain: Optional[str] = None, + domain: str | None = None, secure: bool = True, host_prefix: bool = False, secure_prefix: bool = False, @@ -410,11 +286,6 @@ def delete_cookie( self.headers.add(self.HEADER_KEY, cookie) elif existing_cookie is None: existing_cookie = cookie - # This should be removed in v24.9 - try: - super().__delitem__(key) - except KeyError: - ... if existing_cookie is not None: # Use all the same values as the cookie to be deleted @@ -446,11 +317,7 @@ def delete_cookie( ) -# In v24.9, we should remove this as being a subclass of dict -# Instead, it should be an object with __slots__ -# All of the current property accessors should be removed in favor -# of actual slotted properties. -class Cookie(dict): +class Cookie: """A representation of a HTTP cookie, providing an interface to manipulate cookie attributes intended for a response. This class is a simplified representation of a cookie, similar to the Morsel SimpleCookie in Python's standard library. @@ -490,6 +357,20 @@ class Cookie(dict): HOST_PREFIX = "__Host-" SECURE_PREFIX = "__Secure-" + __slots__ = ( + "key", + "value", + "_path", + "_comment", + "_domain", + "_secure", + "_httponly", + "_partitioned", + "_expires", + "_max_age", + "_samesite", + ) + _keys = { "path": "Path", "comment": "Comment", @@ -497,7 +378,7 @@ class Cookie(dict): "max-age": "Max-Age", "expires": "expires", "samesite": "SameSite", - "version": "Version", + # "version": "Version", "secure": "Secure", "httponly": "HttpOnly", "partitioned": "Partitioned", @@ -510,14 +391,14 @@ def __init__( value: str, *, path: str = "/", - domain: Optional[str] = None, + domain: str | None = None, secure: bool = True, - max_age: Optional[int] = None, - expires: Optional[datetime] = None, + max_age: int | None = None, + expires: datetime | None = None, httponly: bool = False, - samesite: Optional[SameSite] = "Lax", + samesite: SameSite | None = "Lax", partitioned: bool = False, - comment: Optional[str] = None, + comment: str | None = None, host_prefix: bool = False, secure_prefix: bool = False, ): @@ -552,92 +433,32 @@ def __init__( self.key = self.make_key(key, host_prefix, secure_prefix) self.value = value - super().__init__() - - # This is a temporary solution while this object is a dict. We update - # all of the values in bulk, except for the values that have - # key-specific validation in _set_value - self.update( - { - "path": path, - "comment": comment, - "domain": domain, - "secure": secure, - "httponly": httponly, - "partitioned": partitioned, - "expires": None, - "max-age": None, - "samesite": None, - } - ) + + self._path = path + self._comment = comment + self._domain = domain + self._secure = secure + self._httponly = httponly + self._partitioned = partitioned + self._expires = None + self._max_age = None + self._samesite = None + if expires is not None: - self._set_value("expires", expires) + self.expires = expires if max_age is not None: - self._set_value("max-age", max_age) + self.max_age = max_age if samesite is not None: - self._set_value("samesite", samesite) - - def __setitem__(self, key, value): - deprecation( - "Setting values on a Cookie object as a dict has been deprecated. " - "This feature will be removed in v24.9. You should instead set " - f"values on cookies as object properties: cookie.{key}=... ", - 24.9, - ) - self._set_value(key, value) - - # This is a temporary method for backwards compat and should be removed - # in v24.9 when this is no longer a dict - def _set_value(self, key: str, value: Any) -> None: - if key not in self._keys: - raise KeyError("Unknown cookie property: {}={}".format(key, value)) - - if value is not None: - if key.lower() == "max-age" and not str(value).isdigit(): - raise ValueError("Cookie max-age must be an integer") - elif key.lower() == "expires" and not isinstance(value, datetime): - raise TypeError("Cookie 'expires' property must be a datetime") - elif key.lower() == "samesite": - if value.lower() not in SAMESITE_VALUES: - raise TypeError( - "Cookie 'samesite' property must " - f"be one of: {','.join(SAMESITE_VALUES)}" - ) - value = value.title() - - super().__setitem__(key, value) - - def encode(self, encoding: str) -> bytes: - """Encode the cookie content in a specific type of encoding instructed by the developer. - - Leverages the `str.encode` method provided by Python. - - This method can be used to encode and embed ``utf-8`` content into - the cookies. - - .. warning:: - Direct encoding of a Cookie object has been deprecated and will be removed in v24.9. - - Args: - encoding (str): The encoding type to be used. - - Returns: - bytes: The encoded cookie content. - """ # noqa: E501 - deprecation( - "Direct encoding of a Cookie object has been deprecated and will " - "be removed in v24.9.", - 24.9, - ) - return str(self).encode(encoding) + self.samesite = samesite def __str__(self): """Format as a Set-Cookie header value.""" output = ["{}={}".format(self.key, _quote(self.value))] - key_index = list(self._keys) - for key, value in sorted( - self.items(), key=lambda x: key_index.index(x[0]) + ordered_keys = list(self._keys.keys()) + for key in sorted( + self._keys.keys(), key=lambda k: ordered_keys.index(k) ): + value = getattr(self, key.replace("-", "_")) if value is not None and value is not False: if key == "max-age": try: @@ -662,83 +483,92 @@ def __str__(self): @property def path(self) -> str: # no cov """The path of the cookie. Defaults to `"/"`.""" - return self["path"] + return self._path @path.setter def path(self, value: str) -> None: # no cov - self._set_value("path", value) + self._path = value @property - def expires(self) -> Optional[datetime]: # no cov + def expires(self) -> datetime | None: # no cov """The expiration date of the cookie. Defaults to `None`.""" - return self.get("expires") + return self._expires @expires.setter def expires(self, value: datetime) -> None: # no cov - self._set_value("expires", value) + if not isinstance(value, datetime): + raise TypeError("Cookie 'expires' property must be a datetime") + self._expires = value @property - def comment(self) -> Optional[str]: # no cov + def comment(self) -> str | None: # no cov """A comment for the cookie. Defaults to `None`.""" - return self.get("comment") + return self._comment @comment.setter def comment(self, value: str) -> None: # no cov - self._set_value("comment", value) + self._comment = value @property - def domain(self) -> Optional[str]: # no cov + def domain(self) -> str | None: # no cov """The domain of the cookie. Defaults to `None`.""" - return self.get("domain") + return self._domain @domain.setter def domain(self, value: str) -> None: # no cov - self._set_value("domain", value) + self._domain = value @property - def max_age(self) -> Optional[int]: # no cov + def max_age(self) -> int | None: # no cov """The maximum age of the cookie in seconds. Defaults to `None`.""" - return self.get("max-age") + return self._max_age @max_age.setter def max_age(self, value: int) -> None: # no cov - self._set_value("max-age", value) + if not str(value).isdigit(): + raise ValueError("Cookie max-age must be an integer") + self._max_age = value @property def secure(self) -> bool: # no cov """Whether the cookie is secure. Defaults to `True`.""" - return self.get("secure", False) + return self._secure @secure.setter def secure(self, value: bool) -> None: # no cov - self._set_value("secure", value) + self._secure = value @property def httponly(self) -> bool: # no cov """Whether the cookie is HTTP only. Defaults to `False`.""" - return self.get("httponly", False) + return self._httponly @httponly.setter def httponly(self, value: bool) -> None: # no cov - self._set_value("httponly", value) + self._httponly = value @property - def samesite(self) -> Optional[SameSite]: # no cov + def samesite(self) -> SameSite | None: # no cov """The SameSite attribute for the cookie. Defaults to `"Lax"`.""" - return self.get("samesite") + return self._samesite @samesite.setter def samesite(self, value: SameSite) -> None: # no cov - self._set_value("samesite", value) + if value.lower() not in SAMESITE_VALUES: + raise TypeError( + "Cookie 'samesite' property must " + f"be one of: {','.join(SAMESITE_VALUES)}" + ) + self._samesite = value.title() @property def partitioned(self) -> bool: # no cov """Whether the cookie is partitioned. Defaults to `False`.""" - return self.get("partitioned", False) + return self._partitioned @partitioned.setter def partitioned(self, value: bool) -> None: # no cov - self._set_value("partitioned", value) + self._partitioned = value @classmethod def make_key( diff --git a/tests/test_asgi.py b/tests/test_asgi.py index b3899294f5..b0b0b0faca 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -452,11 +452,8 @@ async def test_cookie_customization(app): @app.get("/cookie") def get_cookie(request): response = text("There's a cookie up in this response") - response.cookies["test"] = "Cookie1" - response.cookies["test"]["httponly"] = True - - response.cookies["c2"] = "Cookie2" - response.cookies["c2"]["httponly"] = False + response.add_cookie("test", "Cookie1", httponly=True) + response.add_cookie("c2", "Cookie2", httponly=False) return response diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 63c8de8183..cad00ed229 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -37,14 +37,14 @@ def test_cookies(app): def handler(request): cookie_value = request.cookies["test"] response = text(f"Cookies are: {cookie_value}") - response.cookies["right_back"] = "at you" + response.add_cookie("right_back", "at you") return response request, response = app.test_client.get("/", cookies={"test": "working!"}) response_cookies = SimpleCookie() response_cookies.load(response.headers.get("Set-Cookie", {})) - assert response.text == "Cookies are: working!" + assert response.text == "Cookies are: ['working!']" assert response_cookies["right_back"].value == "at you" @@ -54,7 +54,7 @@ async def test_cookies_asgi(app): def handler(request): cookie_value = request.cookies["test"] response = text(f"Cookies are: {cookie_value}") - response.cookies["right_back"] = "at you" + response.add_cookie("right_back", "at you") return response request, response = await app.asgi_client.get( @@ -63,22 +63,23 @@ def handler(request): response_cookies = SimpleCookie() response_cookies.load(response.headers.get("set-cookie", {})) - assert response.body == b"Cookies are: working!" + assert response.body == b"Cookies are: ['working!']" assert response_cookies["right_back"].value == "at you" -@pytest.mark.parametrize("httponly,expected", [(False, False), (True, True)]) +@pytest.mark.parametrize( + "httponly,expected", [(False, "False"), (True, "True")] +) def test_false_cookies_encoded(app, httponly, expected): @app.route("/") def handler(request): response = text("hello cookies") - response.cookies["hello"] = "world" - response.cookies["hello"]["httponly"] = httponly - return text(response.cookies["hello"].encode("utf8").decode()) + response.add_cookie("hello", "world", httponly=httponly) + return text(str(response.cookies.get_cookie("hello").httponly)) request, response = app.test_client.get("/") - assert ("HttpOnly" in response.text) == expected + assert response.text == expected @pytest.mark.parametrize("httponly,expected", [(False, False), (True, True)]) @@ -86,8 +87,7 @@ def test_false_cookies(app, httponly, expected): @app.route("/") def handler(request): response = text("hello cookies") - response.cookies["right_back"] = "at you" - response.cookies["right_back"]["httponly"] = httponly + response.add_cookie("right_back", "at you", httponly=httponly) return response request, response = app.test_client.get("/") @@ -107,17 +107,18 @@ async def handler(request): headers = {"cookie": "test=working!"} request, response = app.test_client.get("/", headers=headers) - assert response.text == "Cookies are: working!" + assert response.text == "Cookies are: ['working!']" def test_cookie_options(app): @app.route("/") def handler(request): response = text("OK") - response.cookies["test"] = "at you" - response.cookies["test"]["httponly"] = True - response.cookies["test"]["expires"] = datetime.now() + timedelta( - seconds=10 + response.add_cookie( + "test", + "at you", + httponly=True, + expires=datetime.now() + timedelta(seconds=10), ) return response @@ -136,9 +137,9 @@ def test_cookie_deletion(app): def handler(request): nonlocal cookie_jar response = text("OK") - del response.cookies["one"] - response.cookies["two"] = "testing" - del response.cookies["two"] + response.delete_cookie("one") + response.add_cookie("two", "testing") + response.delete_cookie("two") cookie_jar = response.cookies return response @@ -161,21 +162,14 @@ def test_cookie_illegal_key_format(): assert e.message == "Cookie key contains illegal characters" -def test_cookie_set_unknown_property(): - c = Cookie("test_cookie", "value") - with pytest.raises(expected_exception=KeyError) as e: - c["invalid"] = "value" - assert e.message == "Unknown cookie property" - - def test_cookie_set_same_key(app): cookies = {"test": "wait"} @app.get("/") def handler(request): response = text("pass") - response.cookies["test"] = "modified" - response.cookies["test"] = "pass" + response.add_cookie("test", "modified") + response.add_cookie("test", "pass") return response request, response = app.test_client.get("/", cookies=cookies) @@ -190,8 +184,7 @@ def test_cookie_max_age(app, max_age): @app.get("/") def handler(request): response = text("pass") - response.cookies["test"] = "pass" - response.cookies["test"]["max-age"] = max_age + response.add_cookie("test", "pass", max_age=max_age) return response request, response = app.test_client.get( @@ -232,8 +225,7 @@ def test_cookie_bad_max_age(app, max_age): @app.get("/") def handler(request): response = text("pass") - response.cookies["test"] = "pass" - response.cookies["test"]["max-age"] = max_age + response.add_cookie("test", "pass", max_age=max_age) return response request, response = app.test_client.get( @@ -250,8 +242,7 @@ def test_cookie_expires(app: Sanic, expires: timedelta): @app.get("/") def handler(request): response = text("pass") - response.cookies["test"] = "pass" - response.cookies["test"]["expires"] = expires_time + response.add_cookie("test", "pass", expires=expires_time) return response request, response = app.test_client.get( @@ -280,7 +271,7 @@ def test_request_with_duplicate_cookie_key(value): headers = Header({"Cookie": value}) request = Request(b"/", headers, "1.1", "GET", Mock(), Mock()) - assert request.cookies["foo"] == "one" + assert request.cookies["foo"] == ["one", "two"] assert request.cookies.get("foo") == "one" assert request.cookies.getlist("foo") == ["one", "two"] assert request.cookies.get("bar") is None @@ -338,32 +329,34 @@ def test_cookie_jar_add_cookie_encode(): jar.add_cookie("foo", "four", host_prefix=True) jar.add_cookie("foo", "five", host_prefix=True, partitioned=True) - encoded = [cookie.encode("ascii") for cookie in jar.cookies] + encoded = [str(cookie) for cookie in jar.cookies] assert encoded == [ - b"foo=one; Path=/; SameSite=Lax; Secure", - b"foo=two; Path=/something; Domain=example.com; Max-Age=999; SameSite=Strict; Secure; HttpOnly", # noqa - b"__Secure-foo=three; Path=/; SameSite=Lax; Secure", - b"__Host-foo=four; Path=/; SameSite=Lax; Secure", - b"__Host-foo=five; Path=/; SameSite=Lax; Secure; Partitioned", + "foo=one; Path=/; SameSite=Lax; Secure", + "foo=two; Path=/something; Domain=example.com; Max-Age=999; SameSite=Strict; Secure; HttpOnly", # noqa + "__Secure-foo=three; Path=/; SameSite=Lax; Secure", + "__Host-foo=four; Path=/; SameSite=Lax; Secure", + "__Host-foo=five; Path=/; SameSite=Lax; Secure; Partitioned", ] def test_cookie_jar_old_school_cookie_encode(): headers = Header() jar = CookieJar(headers) - jar["foo"] = "one" - jar["bar"] = "two" - jar["bar"]["domain"] = "example.com" - jar["bar"]["path"] = "/something" - jar["bar"]["secure"] = True - jar["bar"]["max-age"] = 999 - jar["bar"]["httponly"] = True - jar["bar"]["samesite"] = "strict" - - encoded = [cookie.encode("ascii") for cookie in jar.cookies] - assert encoded == [ - b"foo=one; Path=/", - b"bar=two; Path=/something; Domain=example.com; Max-Age=999; SameSite=Strict; Secure; HttpOnly", # noqa + jar.add_cookie("foo", "one") + jar.add_cookie( + "bar", + "two", + domain="example.com", + path="/something", + secure=True, + max_age=999, + httponly=True, + samesite="strict", + ) + + assert [str(cookie) for cookie in jar.cookies] == [ + "foo=one; Path=/; SameSite=Lax; Secure", + "bar=two; Path=/something; Domain=example.com; Max-Age=999; SameSite=Strict; Secure; HttpOnly", # noqa ] @@ -373,10 +366,10 @@ def test_cookie_jar_delete_cookie_encode(): jar.delete_cookie("foo") jar.delete_cookie("foo", domain="example.com") - encoded = [cookie.encode("ascii") for cookie in jar.cookies] + encoded = [str(cookie) for cookie in jar.cookies] assert encoded == [ - b'foo=""; Path=/; Max-Age=0; Secure', - b'foo=""; Path=/; Domain=example.com; Max-Age=0; Secure', + 'foo=""; Path=/; Max-Age=0; Secure', + 'foo=""; Path=/; Domain=example.com; Max-Age=0; Secure', ] @@ -385,9 +378,9 @@ def test_cookie_jar_delete_nonsecure_cookie(): jar = CookieJar(headers) jar.delete_cookie("foo", domain="example.com", secure=False) - encoded = [cookie.encode("ascii") for cookie in jar.cookies] + encoded = [str(cookie) for cookie in jar.cookies] assert encoded == [ - b'foo=""; Path=/; Domain=example.com; Max-Age=0', + 'foo=""; Path=/; Domain=example.com; Max-Age=0', ] @@ -399,11 +392,11 @@ def test_cookie_jar_delete_existing_cookie(): ) jar.delete_cookie("foo", domain="example.com", secure=True) - encoded = [cookie.encode("ascii") for cookie in jar.cookies] + encoded = [str(cookie) for cookie in jar.cookies] # deletion cookie contains samesite=Strict as was in original cookie assert encoded == [ - b'foo=""; Path=/; Domain=example.com; Max-Age=0; ' - b"SameSite=Strict; Secure", + 'foo=""; Path=/; Domain=example.com; ' + "Max-Age=0; SameSite=Strict; Secure" ] @@ -415,10 +408,10 @@ def test_cookie_jar_delete_existing_nonsecure_cookie(): ) jar.delete_cookie("foo", domain="example.com", secure=False) - encoded = [cookie.encode("ascii") for cookie in jar.cookies] + encoded = [str(cookie) for cookie in jar.cookies] # deletion cookie contains samesite=Strict as was in original cookie assert encoded == [ - b'foo=""; Path=/; Domain=example.com; Max-Age=0; SameSite=Strict', + 'foo=""; Path=/; Domain=example.com; Max-Age=0; SameSite=Strict', ] @@ -445,11 +438,11 @@ def test_cookie_jar_delete_existing_nonsecure_cookie_bad_prefix(): def test_cookie_jar_old_school_delete_encode(): headers = Header() jar = CookieJar(headers) - del jar["foo"] + jar.delete_cookie("foo") - encoded = [cookie.encode("ascii") for cookie in jar.cookies] + encoded = [str(cookie) for cookie in jar.cookies] assert encoded == [ - b'foo=""; Path=/; Max-Age=0; Secure', + 'foo=""; Path=/; Max-Age=0; Secure', ] @@ -547,9 +540,9 @@ async def handler(request: Request): assert response.json == { "getitem": { - "one": "1", - "two": "2", - "three": "3", + "one": ["1"], + "two": ["2"], + "three": ["3"], }, "get": { "one": "1", diff --git a/tests/test_naming.py b/tests/test_naming.py index 1f0b4c4c0e..70329edb6a 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -1,4 +1,3 @@ - from sanic import Blueprint, Sanic, text diff --git a/tests/test_requests.py b/tests/test_requests.py index 70bfc59fd6..895b3131f5 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1837,7 +1837,7 @@ def handler(request): request, response = app.test_client.get("/", cookies=cookies) assert len(request.cookies) == len(cookies) - assert request.cookies["test"] == cookies["test"] + assert request.cookies["test"] == [cookies["test"]] @pytest.mark.asyncio @@ -1851,7 +1851,7 @@ def handler(request): request, response = await app.asgi_client.get("/", cookies=cookies) assert len(request.cookies) == len(cookies) - assert request.cookies["test"] == cookies["test"] + assert request.cookies["test"] == [cookies["test"]] def test_request_cookies_without_cookies(app): diff --git a/tests/test_response.py b/tests/test_response.py index 7705189c6b..0a383c19f1 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -298,8 +298,8 @@ def test_stream_response_with_cookies(app): async def test(request: Request): headers = Header() cookies = CookieJar(headers) - cookies["test"] = "modified" - cookies["test"] = "pass" + cookies.add_cookie("test", "modified") + cookies.add_cookie("test", "pass") response = await request.respond( content_type="text/csv", headers=headers )