Skip to content

Commit 8525c98

Browse files
committed
add mypy plugin support for Django's storage framework
This change correctly resolves ``django.core.files.storage.default_storage`` and ``django.contrib.staticfiles.storage.staticfiles_storage`` which are configured in the project's Django settings. When ``django.core.files.storage.storages``, which has a dict like interface, is accessed with a literal value, then the type of the appropriate storage is returned.
1 parent 2a89bb5 commit 8525c98

File tree

12 files changed

+217
-15
lines changed

12 files changed

+217
-15
lines changed

django-stubs/contrib/staticfiles/finders.pyi

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ from collections.abc import Iterable, Iterator, Sequence
22
from typing import Any, Literal, overload
33

44
from django.core.checks.messages import CheckMessage
5-
from django.core.files.storage import FileSystemStorage, Storage
5+
from django.core.files.storage import FileSystemStorage, Storage, _DefaultStorage
66

77
searched_locations: Any
88

@@ -16,7 +16,7 @@ class BaseFinder:
1616

1717
class FileSystemFinder(BaseFinder):
1818
locations: list[tuple[str, str]]
19-
storages: dict[str, Any]
19+
storages: dict[str, FileSystemStorage]
2020
def __init__(self, app_names: Sequence[str] | None = None, *args: Any, **kwargs: Any) -> None: ...
2121
def find_location(self, root: str, path: str, prefix: str | None = None) -> str | None: ...
2222
@overload
@@ -48,7 +48,7 @@ class BaseStorageFinder(BaseFinder):
4848
def list(self, ignore_patterns: Iterable[str] | None) -> Iterable[Any]: ...
4949

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

5454
@overload

django-stubs/contrib/staticfiles/management/commands/collectstatic.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any
22

3+
from django.contrib.staticfiles.storage import _ConfiguredStorage
34
from django.core.files.storage import Storage
45
from django.core.management.base import BaseCommand
56
from django.utils.functional import cached_property
@@ -9,7 +10,7 @@ class Command(BaseCommand):
910
symlinked_files: Any
1011
unmodified_files: Any
1112
post_processed_files: Any
12-
storage: Any
13+
storage: _ConfiguredStorage
1314
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
1415
@cached_property
1516
def local(self) -> bool: ...

django-stubs/contrib/staticfiles/storage.pyi

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,8 @@ class ManifestFilesMixin(HashedFilesMixin):
5252
class ManifestStaticFilesStorage(ManifestFilesMixin, StaticFilesStorage): ... # type: ignore[misc]
5353
class ConfiguredStorage(LazyObject): ...
5454

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

django-stubs/core/files/storage/__init__.pyi

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import TypeAlias
2+
13
from django.utils.functional import LazyObject
24

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

1921
class DefaultStorage(LazyObject): ...
2022

23+
# This is our "placeholder" type the mypy plugin refines to configured
24+
# 'STORAGES["default"]["BACKEND"]' wherever it is used as a type.
25+
_DefaultStorage: TypeAlias = DefaultStorage
26+
2127
storages: StorageHandler
22-
# default_storage is actually an instance of DefaultStorage, but it proxies through to a Storage
23-
default_storage: Storage
28+
default_storage: _DefaultStorage
Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
from typing import Any
1+
from typing import Any, TypedDict, type_check_only
22

33
from django.core.exceptions import ImproperlyConfigured
44
from django.utils.functional import cached_property
5+
from typing_extensions import NotRequired
56

67
from .base import Storage
78

9+
@type_check_only
10+
class _StorageConfig(TypedDict):
11+
BACKEND: str
12+
OPTIONS: NotRequired[dict[str, Any]]
13+
814
class InvalidStorageError(ImproperlyConfigured): ...
915

1016
class StorageHandler:
11-
def __init__(self, backends: dict[str, Storage] | None = None) -> None: ...
17+
def __init__(self, backends: dict[str, _StorageConfig] | None = None) -> None: ...
1218
@cached_property
13-
def backends(self) -> dict[str, Storage]: ...
19+
def backends(self) -> dict[str, _StorageConfig]: ...
1420
def __getitem__(self, alias: str) -> Storage: ...
15-
def create_storage(self, params: dict[str, Any]) -> Storage: ...
21+
def create_storage(self, params: _StorageConfig) -> Storage: ...

mypy_django_plugin/lib/fullnames.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
MANYTOMANY_FIELD_FULLNAME = "django.db.models.fields.related.ManyToManyField"
1212
DUMMY_SETTINGS_BASE_CLASS = "django.conf._DjangoConfLazyObject"
1313
AUTH_USER_MODEL_FULLNAME = "django.conf.settings.AUTH_USER_MODEL"
14+
STORAGE_HANDLER_CLASS_FULLNAME = "django.core.files.storage.handler.StorageHandler"
1415

1516
QUERYSET_CLASS_FULLNAME = "django.db.models.query.QuerySet"
1617
BASE_MANAGER_CLASS_FULLNAME = "django.db.models.manager.BaseManager"

mypy_django_plugin/main.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
orm_lookups,
3636
querysets,
3737
settings,
38+
storage,
3839
)
3940
from mypy_django_plugin.transformers.auth import get_user_model
4041
from mypy_django_plugin.transformers.functional import resolve_str_promise_attribute
@@ -98,8 +99,29 @@ def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]:
9899
if file.fullname == "django.conf" and self.django_context.django_settings_module:
99100
return [self._new_dependency(self.django_context.django_settings_module, PRI_MED)]
100101

102+
# for settings.STORAGES["staticfiles"]
103+
if (
104+
file.fullname == "django.contrib.staticfiles.storage"
105+
and isinstance(storage_config := self.django_context.settings.STORAGES.get("staticfiles"), dict)
106+
and isinstance(storage_backend := storage_config.get("BACKEND"), str)
107+
and "." in storage_backend
108+
):
109+
return [self._new_dependency(storage_backend.rsplit(".", 1)[0])]
110+
111+
# for settings.STORAGES
112+
elif file.fullname == "django.core.files.storage":
113+
return [
114+
self._new_dependency(storage_backend.rsplit(".", 1)[0])
115+
for storage_config in self.django_context.settings.STORAGES.values()
116+
if (
117+
isinstance(storage_config, dict)
118+
and isinstance(storage_backend := storage_config.get("BACKEND"), str)
119+
and "." in storage_backend
120+
)
121+
]
122+
101123
# for values / values_list
102-
if file.fullname == "django.db.models":
124+
elif file.fullname == "django.db.models":
103125
return [self._new_dependency("typing"), self._new_dependency("django_stubs_ext")]
104126

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

225+
elif method_name == "__getitem__" and class_fullname == fullnames.STORAGE_HANDLER_CLASS_FULLNAME:
226+
return partial(storage.extract_proper_type_for_getitem, django_context=self.django_context)
227+
203228
if method_name in self.manager_and_queryset_method_hooks:
204229
info = self._get_typeinfo_or_none(class_fullname)
205230
if info and helpers.has_any_of_bases(
@@ -298,6 +323,10 @@ def get_type_analyze_hook(self, fullname: str) -> Callable[[AnalyzeTypeContext],
298323
return partial(handle_annotated_type, fullname=fullname)
299324
elif fullname == "django.contrib.auth.models._User":
300325
return partial(get_user_model, django_context=self.django_context)
326+
elif fullname == "django.contrib.staticfiles.storage._ConfiguredStorage":
327+
return partial(storage.get_storage, alias="staticfiles", django_context=self.django_context)
328+
elif fullname == "django.core.files.storage._DefaultStorage":
329+
return partial(storage.get_storage, alias="default", django_context=self.django_context)
301330
return None
302331

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

312341
def report_config_data(self, ctx: ReportConfigContext) -> dict[str, Any]:
313342
# Cache would be cleared if any settings do change.
314-
extra_data = {}
315-
# In all places we use '_User' alias as a type we want to clear cache if
316-
# AUTH_USER_MODEL setting changes
343+
extra_data: dict[str, Any] = {}
344+
# In all places we use '_DefaultStorage' or '_ConfiguredStorage' aliases as a type we want to clear the cache
345+
# if STORAGES setting changes
346+
if ctx.id.startswith("django.contrib.staticfiles") or ctx.id.startswith("django.core.files.storage"):
347+
extra_data["STORAGES"] = [
348+
storage_backend
349+
for storage_config in self.django_context.settings.STORAGES.values()
350+
if (
351+
isinstance(storage_config, dict)
352+
and isinstance(storage_backend := storage_config.get("BACKEND"), str)
353+
and "." in storage_backend
354+
)
355+
]
356+
# In all places we use '_User' alias as a type we want to clear the cache if AUTH_USER_MODEL setting changes
317357
if ctx.id.startswith("django.contrib.auth") or ctx.id in {"django.http.request", "django.test.client"}:
318358
extra_data["AUTH_USER_MODEL"] = self.django_context.settings.AUTH_USER_MODEL
319359
return self.plugin_config.to_json(extra_data)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from mypy.checker import TypeChecker
2+
from mypy.plugin import AnalyzeTypeContext, MethodContext
3+
from mypy.semanal import SemanticAnalyzer
4+
from mypy.typeanal import TypeAnalyser
5+
from mypy.types import Instance, PlaceholderType, UninhabitedType, get_proper_type
6+
from mypy.types import Type as MypyType
7+
from mypy.typevars import fill_typevars
8+
9+
from mypy_django_plugin.django.context import DjangoContext
10+
from mypy_django_plugin.lib import helpers
11+
12+
13+
def get_storage_backend(alias: str, django_context: DjangoContext) -> str | None:
14+
"Defensively look for a settings.STORAGES by its alias."
15+
16+
try:
17+
fullname = django_context.settings.STORAGES[alias]["BACKEND"]
18+
if not isinstance(fullname, str) or "." not in fullname:
19+
return None
20+
21+
return fullname
22+
except (AssertionError, KeyError, TypeError):
23+
return None
24+
25+
26+
def get_storage(ctx: AnalyzeTypeContext, alias: str, django_context: DjangoContext) -> MypyType:
27+
"""
28+
Get a storage type by its alias, but do not fail if it cannot be found since this is resolving an internal type-var,
29+
and errors would be reported in the type stubs.
30+
"""
31+
32+
assert isinstance(ctx.api, TypeAnalyser)
33+
assert isinstance(ctx.api.api, SemanticAnalyzer)
34+
35+
if fullname := get_storage_backend(alias, django_context):
36+
if type_info := helpers.lookup_fully_qualified_typeinfo(ctx.api.api, fullname):
37+
return fill_typevars(type_info)
38+
39+
if not ctx.api.api.final_iteration:
40+
ctx.api.api.defer()
41+
return PlaceholderType(fullname=fullname, args=[], line=ctx.context.line)
42+
43+
return ctx.type
44+
45+
46+
def extract_proper_type_for_getitem(ctx: MethodContext, django_context: DjangoContext) -> MypyType:
47+
"""
48+
Provide type information for `StorageHandler.__getitem__` when providing a literal value.
49+
"""
50+
51+
assert isinstance(ctx.api, TypeChecker)
52+
53+
if ctx.arg_types:
54+
alias_type = get_proper_type(ctx.arg_types[0][0])
55+
56+
if (
57+
isinstance(alias_type, Instance)
58+
and (alias_literal := alias_type.last_known_value)
59+
and isinstance(alias := alias_literal.value, str)
60+
):
61+
if alias not in django_context.settings.STORAGES:
62+
ctx.api.fail(f'Could not find config for "{alias}" in settings.STORAGES.', ctx.context)
63+
64+
elif fullname := get_storage_backend(alias, django_context):
65+
type_info = helpers.lookup_fully_qualified_typeinfo(ctx.api, fullname)
66+
assert type_info
67+
return fill_typevars(type_info)
68+
69+
else:
70+
ctx.api.fail(f'"{alias}" in settings.STORAGES is improperly configured.', ctx.context)
71+
72+
return UninhabitedType()
73+
74+
return ctx.default_return_type
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from django.contrib.staticfiles.storage import ConfiguredStorage, staticfiles_storage
2+
from typing_extensions import assert_type
3+
4+
assert_type(staticfiles_storage, ConfiguredStorage)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.core.files.storage import DefaultStorage, Storage, default_storage, storages
2+
from typing_extensions import assert_type
3+
4+
assert_type(default_storage, DefaultStorage)
5+
assert_type(storages["default"], Storage)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- case: test_staticfiles_storage_defaults
2+
main: |
3+
from django.contrib.staticfiles.storage import staticfiles_storage
4+
5+
reveal_type(staticfiles_storage) # N: Revealed type is "django.contrib.staticfiles.storage.StaticFilesStorage"

tests/typecheck/core/test_storage.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
- case: test_storage_defaults
2+
main: |
3+
from django.core.files.storage import default_storage, storages
4+
5+
reveal_type(default_storage) # N: Revealed type is "django.core.files.storage.filesystem.FileSystemStorage"
6+
reveal_type(storages["default"]) # N: Revealed type is "django.core.files.storage.filesystem.FileSystemStorage"
7+
reveal_type(storages["staticfiles"]) # N: Revealed type is "django.contrib.staticfiles.storage.StaticFilesStorage"
8+
9+
- case: test_custom_storages
10+
main: |
11+
from django.core.files.storage import default_storage, storages
12+
13+
reveal_type(default_storage) # N: Revealed type is "myapp.storage.MyDefaultStorage"
14+
reveal_type(storages["default"]) # N: Revealed type is "myapp.storage.MyDefaultStorage"
15+
reveal_type(storages["custom"]) # N: Revealed type is "myapp.storage.MyStorage"
16+
reveal_type(storages["staticfiles"]) # N: Revealed type is "django.contrib.staticfiles.storage.StaticFilesStorage"
17+
18+
custom_settings: |
19+
from django.conf.global_settings import STORAGES as DEFAULT_STORAGES
20+
21+
STORAGES = {
22+
**DEFAULT_STORAGES,
23+
"default": {"BACKEND": "myapp.storage.MyDefaultStorage"},
24+
"custom": {
25+
"BACKEND": "myapp.storage.MyStorage",
26+
"OPTIONS": {"option_enabled": False, "key": "test"},
27+
}
28+
}
29+
30+
files:
31+
- path: myapp/storage.py
32+
content: |
33+
from django.core.files.storage import Storage
34+
35+
class MyDefaultStorage(Storage):
36+
pass
37+
38+
class MyStorage(Storage):
39+
pass
40+
41+
- case: test_improperly_configured_storages
42+
main: |
43+
from django.core.files.storage import default_storage, storages
44+
45+
reveal_type(default_storage) # N: Revealed type is "_DefaultStorage?"
46+
reveal_type(storages["default"]) # E: "default" in settings.STORAGES is improperly configured. [misc] # N: Revealed type is "Never"
47+
reveal_type(storages["custom"]) # E: "custom" in settings.STORAGES is improperly configured. [misc] # N: Revealed type is "Never"
48+
reveal_type(storages["custom_two"]) # E: "custom_two" in settings.STORAGES is improperly configured. [misc] # N: Revealed type is "Never"
49+
reveal_type(storages["staticfiles"]) # E: Could not find config for "staticfiles" in settings.STORAGES. [misc] # N: Revealed type is "Never"
50+
51+
custom_settings: |
52+
STORAGES = {
53+
"custom": {"BACKEND": "MyStorage"},
54+
"custom_two": ["MyStorage"],
55+
"default": True,
56+
# "staticfiles" is missing
57+
}

0 commit comments

Comments
 (0)