Skip to content

add mypy plugin support for Django's storage framework #2680

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions django-stubs/contrib/staticfiles/finders.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ from collections.abc import Iterable, Iterator, Sequence
from typing import Any, Literal, overload

from django.core.checks.messages import CheckMessage
from django.core.files.storage import FileSystemStorage, Storage
from django.core.files.storage import FileSystemStorage, Storage, _DefaultStorage

searched_locations: Any

Expand All @@ -16,7 +16,7 @@ class BaseFinder:

class FileSystemFinder(BaseFinder):
locations: list[tuple[str, str]]
storages: dict[str, Any]
storages: dict[str, FileSystemStorage]
def __init__(self, app_names: Sequence[str] | None = None, *args: Any, **kwargs: Any) -> None: ...
def find_location(self, root: str, path: str, prefix: str | None = None) -> str | None: ...
@overload
Expand Down Expand Up @@ -48,7 +48,7 @@ class BaseStorageFinder(BaseFinder):
def list(self, ignore_patterns: Iterable[str] | None) -> Iterable[Any]: ...

class DefaultStorageFinder(BaseStorageFinder):
storage: Storage
storage: _DefaultStorage
def __init__(self, *args: Any, **kwargs: Any) -> None: ...

@overload
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any

from django.contrib.staticfiles.storage import _ConfiguredStorage
from django.core.files.storage import Storage
from django.core.management.base import BaseCommand
from django.utils.functional import cached_property
Expand All @@ -9,7 +10,7 @@ class Command(BaseCommand):
symlinked_files: Any
unmodified_files: Any
post_processed_files: Any
storage: Any
storage: _ConfiguredStorage
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
@cached_property
def local(self) -> bool: ...
Expand Down
6 changes: 5 additions & 1 deletion django-stubs/contrib/staticfiles/storage.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@ class ManifestFilesMixin(HashedFilesMixin):
class ManifestStaticFilesStorage(ManifestFilesMixin, StaticFilesStorage): ... # type: ignore[misc]
class ConfiguredStorage(LazyObject): ...

staticfiles_storage: Storage
# This is our "placeholder" type the mypy plugin refines to configured
# 'STORAGES["staticfiles"]["BACKEND"]' wherever it is used as a type.
_ConfiguredStorage: TypeAlias = ConfiguredStorage

staticfiles_storage: _ConfiguredStorage
9 changes: 7 additions & 2 deletions django-stubs/core/files/storage/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import TypeAlias

from django.utils.functional import LazyObject

from .base import Storage
Expand All @@ -18,6 +20,9 @@ __all__ = (

class DefaultStorage(LazyObject): ...

# This is our "placeholder" type the mypy plugin refines to configured
# 'STORAGES["default"]["BACKEND"]' wherever it is used as a type.
_DefaultStorage: TypeAlias = DefaultStorage

storages: StorageHandler
# default_storage is actually an instance of DefaultStorage, but it proxies through to a Storage
default_storage: Storage
default_storage: _DefaultStorage
14 changes: 10 additions & 4 deletions django-stubs/core/files/storage/handler.pyi
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
from typing import Any
from typing import Any, TypedDict, type_check_only

from django.core.exceptions import ImproperlyConfigured
from django.utils.functional import cached_property
from typing_extensions import NotRequired

from .base import Storage

@type_check_only
class _StorageConfig(TypedDict):
BACKEND: str
OPTIONS: NotRequired[dict[str, Any]]

class InvalidStorageError(ImproperlyConfigured): ...

class StorageHandler:
def __init__(self, backends: dict[str, Storage] | None = None) -> None: ...
def __init__(self, backends: dict[str, _StorageConfig] | None = None) -> None: ...
@cached_property
def backends(self) -> dict[str, Storage]: ...
def backends(self) -> dict[str, _StorageConfig]: ...
def __getitem__(self, alias: str) -> Storage: ...
def create_storage(self, params: dict[str, Any]) -> Storage: ...
def create_storage(self, params: _StorageConfig) -> Storage: ...
1 change: 1 addition & 0 deletions mypy_django_plugin/lib/fullnames.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
MANYTOMANY_FIELD_FULLNAME = "django.db.models.fields.related.ManyToManyField"
DUMMY_SETTINGS_BASE_CLASS = "django.conf._DjangoConfLazyObject"
AUTH_USER_MODEL_FULLNAME = "django.conf.settings.AUTH_USER_MODEL"
STORAGE_HANDLER_CLASS_FULLNAME = "django.core.files.storage.handler.StorageHandler"

QUERYSET_CLASS_FULLNAME = "django.db.models.query.QuerySet"
BASE_MANAGER_CLASS_FULLNAME = "django.db.models.manager.BaseManager"
Expand Down
48 changes: 44 additions & 4 deletions mypy_django_plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
orm_lookups,
querysets,
settings,
storage,
)
from mypy_django_plugin.transformers.auth import get_user_model
from mypy_django_plugin.transformers.functional import resolve_str_promise_attribute
Expand Down Expand Up @@ -98,8 +99,29 @@ def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]:
if file.fullname == "django.conf" and self.django_context.django_settings_module:
return [self._new_dependency(self.django_context.django_settings_module, PRI_MED)]

# for settings.STORAGES["staticfiles"]
if (
file.fullname == "django.contrib.staticfiles.storage"
and isinstance(storage_config := self.django_context.settings.STORAGES.get("staticfiles"), dict)
and isinstance(storage_backend := storage_config.get("BACKEND"), str)
and "." in storage_backend
):
return [self._new_dependency(storage_backend.rsplit(".", 1)[0])]

# for settings.STORAGES
elif file.fullname == "django.core.files.storage":
return [
self._new_dependency(storage_backend.rsplit(".", 1)[0])
for storage_config in self.django_context.settings.STORAGES.values()
if (
isinstance(storage_config, dict)
and isinstance(storage_backend := storage_config.get("BACKEND"), str)
and "." in storage_backend
)
]

# for values / values_list
if file.fullname == "django.db.models":
elif file.fullname == "django.db.models":
return [self._new_dependency("typing"), self._new_dependency("django_stubs_ext")]

# for `get_user_model()`
Expand Down Expand Up @@ -200,6 +222,9 @@ def get_method_hook(self, fullname: str) -> Callable[[MethodContext], MypyType]
}
return hooks.get(class_fullname)

elif method_name == "__getitem__" and class_fullname == fullnames.STORAGE_HANDLER_CLASS_FULLNAME:
return partial(storage.extract_proper_type_for_getitem, django_context=self.django_context)

if method_name in self.manager_and_queryset_method_hooks:
info = self._get_typeinfo_or_none(class_fullname)
if info and helpers.has_any_of_bases(
Expand Down Expand Up @@ -298,6 +323,10 @@ def get_type_analyze_hook(self, fullname: str) -> Callable[[AnalyzeTypeContext],
return partial(handle_annotated_type, fullname=fullname)
elif fullname == "django.contrib.auth.models._User":
return partial(get_user_model, django_context=self.django_context)
elif fullname == "django.contrib.staticfiles.storage._ConfiguredStorage":
return partial(storage.get_storage, alias="staticfiles", django_context=self.django_context)
elif fullname == "django.core.files.storage._DefaultStorage":
return partial(storage.get_storage, alias="default", django_context=self.django_context)
return None

def get_dynamic_class_hook(self, fullname: str) -> Callable[[DynamicClassDefContext], None] | None:
Expand All @@ -311,9 +340,20 @@ def get_dynamic_class_hook(self, fullname: str) -> Callable[[DynamicClassDefCont

def report_config_data(self, ctx: ReportConfigContext) -> dict[str, Any]:
# Cache would be cleared if any settings do change.
extra_data = {}
# In all places we use '_User' alias as a type we want to clear cache if
# AUTH_USER_MODEL setting changes
extra_data: dict[str, Any] = {}
# In all places we use '_DefaultStorage' or '_ConfiguredStorage' aliases as a type we want to clear the cache
# if STORAGES setting changes
if ctx.id.startswith("django.contrib.staticfiles") or ctx.id.startswith("django.core.files.storage"):
extra_data["STORAGES"] = [
storage_backend
for storage_config in self.django_context.settings.STORAGES.values()
if (
isinstance(storage_config, dict)
and isinstance(storage_backend := storage_config.get("BACKEND"), str)
and "." in storage_backend
)
]
# In all places we use '_User' alias as a type we want to clear the cache if AUTH_USER_MODEL setting changes
if ctx.id.startswith("django.contrib.auth") or ctx.id in {"django.http.request", "django.test.client"}:
extra_data["AUTH_USER_MODEL"] = self.django_context.settings.AUTH_USER_MODEL
return self.plugin_config.to_json(extra_data)
Expand Down
74 changes: 74 additions & 0 deletions mypy_django_plugin/transformers/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from mypy.checker import TypeChecker
from mypy.plugin import AnalyzeTypeContext, MethodContext
from mypy.semanal import SemanticAnalyzer
from mypy.typeanal import TypeAnalyser
from mypy.types import Instance, PlaceholderType, UninhabitedType, get_proper_type
from mypy.types import Type as MypyType
from mypy.typevars import fill_typevars

from mypy_django_plugin.django.context import DjangoContext
from mypy_django_plugin.lib import helpers


def get_storage_backend(alias: str, django_context: DjangoContext) -> str | None:
"Defensively look for a settings.STORAGES by its alias."

try:
fullname = django_context.settings.STORAGES[alias]["BACKEND"]
if not isinstance(fullname, str) or "." not in fullname:
return None

return fullname
except (KeyError, TypeError):
return None


def get_storage(ctx: AnalyzeTypeContext, alias: str, django_context: DjangoContext) -> MypyType:
"""
Get a storage type by its alias, but do not fail if it cannot be found since this is resolving an internal type-var,
and errors would be reported in the type stubs.
"""

assert isinstance(ctx.api, TypeAnalyser)
assert isinstance(ctx.api.api, SemanticAnalyzer)

if fullname := get_storage_backend(alias, django_context):
if type_info := helpers.lookup_fully_qualified_typeinfo(ctx.api.api, fullname):
return fill_typevars(type_info)

if not ctx.api.api.final_iteration:
ctx.api.api.defer()
return PlaceholderType(fullname=fullname, args=[], line=ctx.context.line)

return ctx.type


def extract_proper_type_for_getitem(ctx: MethodContext, django_context: DjangoContext) -> MypyType:
"""
Provide type information for `StorageHandler.__getitem__` when providing a literal value.
"""

assert isinstance(ctx.api, TypeChecker)

if ctx.arg_types:
alias_type = get_proper_type(ctx.arg_types[0][0])

if (
isinstance(alias_type, Instance)
and (alias_literal := alias_type.last_known_value)
and isinstance(alias := alias_literal.value, str)
):
if alias not in django_context.settings.STORAGES:
ctx.api.fail(f'Could not find config for "{alias}" in settings.STORAGES.', ctx.context)

elif fullname := get_storage_backend(alias, django_context):
type_info = helpers.lookup_fully_qualified_typeinfo(ctx.api, fullname)
assert type_info
return fill_typevars(type_info)

else:
ctx.api.fail(f'"{alias}" in settings.STORAGES is improperly configured.', ctx.context)

return UninhabitedType()

return ctx.default_return_type
8 changes: 8 additions & 0 deletions tests/assert_type/contrib/staticfiles/test_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.contrib.staticfiles.storage import ConfiguredStorage, StaticFilesStorage, staticfiles_storage
from typing_extensions import assert_type

# The plugin can figure out what these are (but pyright can't):
assert_type(staticfiles_storage, StaticFilesStorage) # pyright: ignore[reportAssertTypeFailure]

# what pyright thinks these are:
assert_type(staticfiles_storage, ConfiguredStorage) # mypy: ignore[assert-type]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, basically, right now pyright will infer ConfiguredStorage instead of Any and existing code can break, due to the fact that ConfiguredStorage is a LazyObject which does not really have right attributes.

How can we fix that?

Copy link
Contributor Author

@terencehonles terencehonles May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wasn't Any before, but it can be if that's better... I just looked and mypy can't have its own type ignores, so I'm not sure how to ever make this work since mypy will always know more about the object than pyright does (even if it reports Any for pyright the assertion will fail, right?).

10 changes: 10 additions & 0 deletions tests/assert_type/core/files/test_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.core.files.storage import DefaultStorage, FileSystemStorage, Storage, default_storage, storages
from typing_extensions import assert_type

# The plugin can figure out what these are (but pyright can't):
assert_type(default_storage, FileSystemStorage) # pyright: ignore[reportAssertTypeFailure]
assert_type(storages["default"], FileSystemStorage) # pyright: ignore[reportAssertTypeFailure]

# what pyright thinks these are:
assert_type(default_storage, DefaultStorage) # mypy: ignore[assert-type]
assert_type(storages["default"], Storage) # mypy: ignore[assert-type]
5 changes: 5 additions & 0 deletions tests/typecheck/contrib/staticfiles/test_storage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- case: test_staticfiles_storage_defaults
main: |
from django.contrib.staticfiles.storage import staticfiles_storage

reveal_type(staticfiles_storage) # N: Revealed type is "django.contrib.staticfiles.storage.StaticFilesStorage"
57 changes: 57 additions & 0 deletions tests/typecheck/core/test_storage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
- case: test_storage_defaults
main: |
from django.core.files.storage import default_storage, storages

reveal_type(default_storage) # N: Revealed type is "django.core.files.storage.filesystem.FileSystemStorage"
reveal_type(storages["default"]) # N: Revealed type is "django.core.files.storage.filesystem.FileSystemStorage"
reveal_type(storages["staticfiles"]) # N: Revealed type is "django.contrib.staticfiles.storage.StaticFilesStorage"

- case: test_custom_storages
main: |
from django.core.files.storage import default_storage, storages

reveal_type(default_storage) # N: Revealed type is "myapp.storage.MyDefaultStorage"
reveal_type(storages["default"]) # N: Revealed type is "myapp.storage.MyDefaultStorage"
reveal_type(storages["custom"]) # N: Revealed type is "myapp.storage.MyStorage"
reveal_type(storages["staticfiles"]) # N: Revealed type is "django.contrib.staticfiles.storage.StaticFilesStorage"

custom_settings: |
from django.conf.global_settings import STORAGES as DEFAULT_STORAGES

STORAGES = {
**DEFAULT_STORAGES,
"default": {"BACKEND": "myapp.storage.MyDefaultStorage"},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will happen if MyDefaultStorage is something invalid? Like instance of value 1 or 'abc'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will probably break, and I figured Django checked this, but I have made the plugin more defensive.

"custom": {
"BACKEND": "myapp.storage.MyStorage",
"OPTIONS": {"option_enabled": False, "key": "test"},
}
}

files:
- path: myapp/storage.py
content: |
from django.core.files.storage import Storage

class MyDefaultStorage(Storage):
pass

class MyStorage(Storage):
pass

- case: test_improperly_configured_storages
main: |
from django.core.files.storage import default_storage, storages

reveal_type(default_storage) # N: Revealed type is "_DefaultStorage?"
reveal_type(storages["default"]) # E: "default" in settings.STORAGES is improperly configured. [misc] # N: Revealed type is "Never"
reveal_type(storages["custom"]) # E: "custom" in settings.STORAGES is improperly configured. [misc] # N: Revealed type is "Never"
reveal_type(storages["custom_two"]) # E: "custom_two" in settings.STORAGES is improperly configured. [misc] # N: Revealed type is "Never"
reveal_type(storages["staticfiles"]) # E: Could not find config for "staticfiles" in settings.STORAGES. [misc] # N: Revealed type is "Never"

custom_settings: |
STORAGES = {
"custom": {"BACKEND": "MyStorage"},
"custom_two": ["MyStorage"],
"default": True,
# "staticfiles" is missing
}
Loading