sanic-org / sanic

Accelerate your web app development | Build fast. Run fast.

Home Page:https://sanic.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Compatibility mode

ahopkins opened this issue · comments

Link to code

main...breaking-change-compat-mode

Proposal

Background

One of the biggest challenges to improving software may be dealing with breaking changes. Sometimes decisions and code features are made that are not forward thinking enough and preclude other enhancements to be made.

Historically, Sanic has dealt with this in several ways:

  1. Utilizing an LTS schedule. Having a yearly long-term support release means that organizations and developers can restrict their upgrades to a yearly cycle. This provides applications more stability and provides the framework more freedom to move quickly.
  2. Requiring a two-cycle deprecation. When the Sanic Core Developers decide that a breaking change is needed, generally this happens in context of the stated deprecation policy. This policy states that a breaking change can be made so long as there remains two release cycles before the old feature is removed, and during those two releases there are deprecation notices to the developers. This generally has take place in Release Notes and in the Changelog.

The Problem

This policy is great when there can be an easy transition period. That means to say when both the old and the new can be supported side-by-side. This is not always the case. Sometimes breaking changes with no notice must be made.

The question then becomes how can we ease this issue for developers and not break existing applications?

A Solution

There is a proof of concept branch with change comparison linked above

While discussing one such breaking change with @Tronic, he suggested we offer a compatibility mode. That is to say that there is some method that we allow developers to retain an old feature, but still allow them to upgrade and take advantage of newer features. This could be achievable with an early defined global variable in the target application.

__SANIC_COMPATIBILITY__ = "23.3"
from sanic import Request, Sanic, json

app = Sanic("TestApp")

@app.get("/")
async def handler(request: Request):
    return json(
        {
            "name": request.name,
            "something_new_direct": request.something_new,
        }
    )

An early defined global variable could be caught very early at import time in Sanic, and therefore used to alter import paths. If you look at the POC branch linked above, you will see something this in __version__.py:

__version__ = "23.3.0"
__compatibility__ = "22.12"

from inspect import currentframe, stack

for frame_info in stack():
    if frame_info.frame is not currentframe():
        value = frame_info.frame.f_globals.get("__SANIC_COMPATIBILITY__")
        if value:
            __compatibility__ = value

What it is doing is inspecting the call stack looking for that variable __SANIC_COMPATIBILITY__. That value then can be used to dynamically import ...

from sanic.__version__ import __compatibility__

if __compatibility__ == "22.12":
    from .v22_12.request import (
        File,
        Request,
        RequestParameters,
        parse_multipart_form,
    )
elif __compatibility__ == "23.3":
    from .v23_3.request import (
        File,
        Request,
        RequestParameters,
        parse_multipart_form,
    )
else:
    raise RuntimeError(f"Unknown compatibility value: {__compatibility__}")

... Or, it can be used to make other decisions early on in the startup process before any other Sanic objects are loaded into memory. By placing the value in sanic.__version__, there should not be any circular import issues.

Important considerations

One of the larger considerations is making sure that an sort of compatibility would work seemlessly for the developer. That means that IDEs should still function as expected, etc. As you can see in the below screenshot, this does work with VS Code.

image

VS Code is able to follow the dynamic import path to get to the "new" Request that has a something_new property and auto-suggest it. 👍

However, at least with the proposed solution, this does not work for mypy. I did not test with any other type checker. Perhaps there is a nicer implementation than the POC branch that will allow for both IDE and type checkers.

image

Requirements for a "go forward solution"

  • Configuration as early as possible so Sanic can make decisions before imports start happening
  • Allow developers to opt in and out of a set of defined features. The current POC branch contemplates "all or nothing" approach. It is conceivable that there may be some way to allow per-feature choices
  • IDEs must be able to follow any signature changes to make it easy for developers
  • Type checkers must be able to follow any signature changes to make it easy for developers

Question to be answered

The POC should not be taken as a proposed solution. Rather, it should be noted it is only meant to be illustrative of the problem and potential ways to solve it. What needs to be decided here first and foremost:

Should Sanic offer a mechanism for allowing breaking changes to occur, but developers retain a compatibility mode?

Some reasons why "yes"

  • There exists a set of changes that cannot be made without breaking APIs.
  • Accessors may be added, functional signatures changed, etc.
  • Developers may have some more freedom to access some features they might not otherwise be able to.
  • Core development can be more free to make breaking changes

Some reasons why "not"

  • Additional code complexity and maintenance
  • There already exists a strategy for "locking in" a feature set: LTS. Perhaps we need to be more up front about this and encourage developers to use it more often.
  • Breaking changes are a problem for a reason. If we really need it, perhaps a more "creative" approach should be taken: adding a new method like Sanic.serve_legacy. Using new methods and types can largely achieve the same result.

Additional context

No response

Is this a breaking change?

  • Yes

An alternative idea, rather than making the compatibility flag change functionality, perhaps it silences deprecations.

Example

Imagine we are hypothetically changing this class:

# old
class Widget:
    def do_something(self, thing_1, **kwargs):
        ...
# new
class Widget:
    def do_something(self, thing_1, thing_2, thing_3):
        ...

What we could do is this in the interim:

class Widget:
    def legacy_do_something(self, thing_1, **kwargs):
        if __compatibility__ == "<OLD VERSION>":
            deprecation("This is old and will be removed. Upgrade to future_do_something")

    def future_do_something(self, thing_1, thing_2, thing_3):
        ...

    do_something = legacy_do_something

Developers that did nothing would have the same behavior with an annoying warning. They can silence the warning by setting a value somewhere. We do allow for the stdlib filterwarnings, but that works against ALL Sanic deprecation warnings. What I am suggesting here is that some warnings be compat version specific.

A future step could then move to something like this:

class Widget:
    def legacy_do_something(self, thing_1, **kwargs):
        raise NotImplementedError("Upgrade to do_something")

    def future_do_something(self, thing_1, thing_2, thing_3):
        ...

    do_something = future_do_something