Skip to content

Commit 792c3be

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 792c3be

File tree

10 files changed

+145
-14
lines changed

10 files changed

+145
-14
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: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
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
55

66
from .base import Storage
77

8+
@type_check_only
9+
class _StorageConfig(TypedDict):
10+
BACKEND: str
11+
OPTIONS: dict[str, Any] | None
12+
813
class InvalidStorageError(ImproperlyConfigured): ...
914

1015
class StorageHandler:
11-
def __init__(self, backends: dict[str, Storage] | None = None) -> None: ...
16+
def __init__(self, backends: dict[str, _StorageConfig] | None = None) -> None: ...
1217
@cached_property
13-
def backends(self) -> dict[str, Storage]: ...
18+
def backends(self) -> dict[str, _StorageConfig]: ...
1419
def __getitem__(self, alias: str) -> Storage: ...
15-
def create_storage(self, params: dict[str, Any]) -> Storage: ...
20+
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: 27 additions & 3 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,21 @@ 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 file.fullname == "django.contrib.staticfiles.storage":
104+
return [
105+
self._new_dependency(self.django_context.settings.STORAGES["staticfiles"]["BACKEND"].rsplit(".", 1)[0])
106+
]
107+
108+
# for settings.STORAGES
109+
elif file.fullname == "django.core.files.storage":
110+
return [
111+
self._new_dependency(s["BACKEND"].rsplit(".", 1)[0])
112+
for s in self.django_context.settings.STORAGES.values()
113+
]
114+
101115
# for values / values_list
102-
if file.fullname == "django.db.models":
116+
elif file.fullname == "django.db.models":
103117
return [self._new_dependency("typing"), self._new_dependency("django_stubs_ext")]
104118

105119
# for `get_user_model()`
@@ -200,6 +214,9 @@ def get_method_hook(self, fullname: str) -> Callable[[MethodContext], MypyType]
200214
}
201215
return hooks.get(class_fullname)
202216

217+
elif method_name == "__getitem__" and class_fullname == fullnames.STORAGE_HANDLER_CLASS_FULLNAME:
218+
return partial(storage.extract_proper_type_for_getitem, django_context=self.django_context)
219+
203220
if method_name in self.manager_and_queryset_method_hooks:
204221
info = self._get_typeinfo_or_none(class_fullname)
205222
if info and helpers.has_any_of_bases(
@@ -298,6 +315,10 @@ def get_type_analyze_hook(self, fullname: str) -> Callable[[AnalyzeTypeContext],
298315
return partial(handle_annotated_type, fullname=fullname)
299316
elif fullname == "django.contrib.auth.models._User":
300317
return partial(get_user_model, django_context=self.django_context)
318+
elif fullname == "django.contrib.staticfiles.storage._ConfiguredStorage":
319+
return partial(storage.get_storage, alias="staticfiles", django_context=self.django_context)
320+
elif fullname == "django.core.files.storage._DefaultStorage":
321+
return partial(storage.get_storage, alias="default", django_context=self.django_context)
301322
return None
302323

303324
def get_dynamic_class_hook(self, fullname: str) -> Callable[[DynamicClassDefContext], None] | None:
@@ -312,8 +333,11 @@ def get_dynamic_class_hook(self, fullname: str) -> Callable[[DynamicClassDefCont
312333
def report_config_data(self, ctx: ReportConfigContext) -> dict[str, Any]:
313334
# Cache would be cleared if any settings do change.
314335
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
336+
# In all places we use '_DefaultStorage' or '_ConfiguredStorage' aliases as a type we want to clear the cache
337+
# if STORAGES setting changes
338+
if ctx.id.startswith("django.contrib.staticfiles") or ctx.id.startswith("django.core.files.storage"):
339+
extra_data["STORAGES"] = [s["BACKEND"] for s in self.django_context.settings.STORAGES.values()]
340+
# In all places we use '_User' alias as a type we want to clear the cache if AUTH_USER_MODEL setting changes
317341
if ctx.id.startswith("django.contrib.auth") or ctx.id in {"django.http.request", "django.test.client"}:
318342
extra_data["AUTH_USER_MODEL"] = self.django_context.settings.AUTH_USER_MODEL
319343
return self.plugin_config.to_json(extra_data)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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, get_proper_type
6+
from mypy.types import Type as MypyType
7+
8+
from mypy_django_plugin.django.context import DjangoContext
9+
from mypy_django_plugin.lib import helpers
10+
11+
12+
def get_storage(ctx: AnalyzeTypeContext, alias: str, django_context: DjangoContext) -> MypyType:
13+
assert isinstance(ctx.api, TypeAnalyser)
14+
assert isinstance(ctx.api.api, SemanticAnalyzer)
15+
16+
fullname = django_context.settings.STORAGES[alias]["BACKEND"]
17+
type_info = helpers.lookup_fully_qualified_typeinfo(ctx.api.api, fullname)
18+
19+
if type_info is None:
20+
if not ctx.api.api.final_iteration:
21+
ctx.api.api.defer()
22+
return PlaceholderType(fullname=fullname, args=[], line=ctx.context.line)
23+
else:
24+
ctx.api.fail(f'Cannot resolve storage backend "{fullname}"', ctx.context)
25+
return ctx.type
26+
27+
assert type_info
28+
return Instance(type_info, [])
29+
30+
31+
def extract_proper_type_for_getitem(ctx: MethodContext, django_context: DjangoContext) -> MypyType:
32+
assert isinstance(ctx.api, TypeChecker)
33+
34+
if ctx.arg_types:
35+
alias_type = get_proper_type(ctx.arg_types[0][0])
36+
37+
if (
38+
isinstance(alias_type, Instance)
39+
and (alias_literal := alias_type.last_known_value)
40+
and isinstance(alias_literal.value, str)
41+
):
42+
fullname = django_context.settings.STORAGES[alias_literal.value]["BACKEND"]
43+
type_info = helpers.lookup_fully_qualified_typeinfo(ctx.api, fullname)
44+
assert type_info
45+
return Instance(type_info, [])
46+
47+
return ctx.default_return_type
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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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

0 commit comments

Comments
 (0)