Skip to content

Commit

Permalink
Add typing support to get_solo (#128)
Browse files Browse the repository at this point in the history
* Add typing support to `get_solo`

* Deprecate `get_cache`

* Update changelog
  • Loading branch information
Viicos authored Jan 15, 2024
1 parent b84391d commit 20d5ce6
Show file tree
Hide file tree
Showing 11 changed files with 98 additions and 53 deletions.
6 changes: 6 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
Unreleased
==========

* Add typing support
* Deprecate `solo.models.get_cache`

django-solo-2.2.0
=================

Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
include *.md
include LICENSE
include CHANGES
include solo/py.typed
recursive-include solo/templates *
recursive-include solo/locale *.mo *.po
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[tool.mypy]
ignore_missing_imports = true
strict = true
exclude = "solo/tests"
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ def get_version(package):
version=version,
description='Django Solo helps working with singletons',
python_requires='>=3.8',
install_requires=['django>=3.2'],
install_requires=[
'django>=3.2',
'typing-extensions>=4.0.1; python_version < "3.11"',
],
packages=find_packages(),
url='https://github.com/lazybird/django-solo/',
author='lazybird',
Expand Down
31 changes: 18 additions & 13 deletions solo/admin.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
from django.urls import re_path
from __future__ import annotations

from typing import Any

from django.db.models import Model
from django.urls import URLPattern, re_path
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.utils.encoding import force_str
from django.utils.translation import gettext as _

from solo.models import DEFAULT_SINGLETON_INSTANCE_ID
from solo import settings as solo_settings


class SingletonModelAdmin(admin.ModelAdmin):
class SingletonModelAdmin(admin.ModelAdmin): # type: ignore[type-arg]
object_history_template = "admin/solo/object_history.html"
change_form_template = "admin/solo/change_form.html"

def has_add_permission(self, request):
def has_add_permission(self, request: HttpRequest) -> bool:
return False

def has_delete_permission(self, request, obj=None):
def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
return False

def get_urls(self):
urls = super(SingletonModelAdmin, self).get_urls()
def get_urls(self) -> list[URLPattern]:
urls = super().get_urls()

if not solo_settings.SOLO_ADMIN_SKIP_OBJECT_LIST_PAGE:
return urls
Expand Down Expand Up @@ -50,7 +55,7 @@ def get_urls(self):
# By inserting the custom URLs first, we overwrite the standard URLs.
return custom_urls + urls

def response_change(self, request, obj):
def response_change(self, request: HttpRequest, obj: Model) -> HttpResponseRedirect:
msg = _('%(obj)s was changed successfully.') % {
'obj': force_str(obj)}
if '_continue' in request.POST:
Expand All @@ -61,32 +66,32 @@ def response_change(self, request, obj):
self.message_user(request, msg)
return HttpResponseRedirect("../../")

def change_view(self, request, object_id, form_url='', extra_context=None):
def change_view(self, request: HttpRequest, object_id: str, form_url: str = '', extra_context: dict[str, Any] | None = None) -> HttpResponse:
if object_id == str(self.singleton_instance_id):
self.model.objects.get_or_create(pk=self.singleton_instance_id)

if not extra_context:
extra_context = dict()
extra_context['skip_object_list_page'] = solo_settings.SOLO_ADMIN_SKIP_OBJECT_LIST_PAGE

return super(SingletonModelAdmin, self).change_view(
return super().change_view(
request,
object_id,
form_url=form_url,
extra_context=extra_context,
)

def history_view(self, request, object_id, extra_context=None):
def history_view(self, request: HttpRequest, object_id: str, extra_context: dict[str, Any] | None = None) -> HttpResponse:
if not extra_context:
extra_context = dict()
extra_context['skip_object_list_page'] = solo_settings.SOLO_ADMIN_SKIP_OBJECT_LIST_PAGE

return super(SingletonModelAdmin, self).history_view(
return super().history_view(
request,
object_id,
extra_context=extra_context,
)

@property
def singleton_instance_id(self):
def singleton_instance_id(self) -> int:
return getattr(self.model, 'singleton_instance_id', DEFAULT_SINGLETON_INSTANCE_ID)
60 changes: 39 additions & 21 deletions solo/models.py
Original file line number Diff line number Diff line change
@@ -1,63 +1,81 @@
from __future__ import annotations

import sys
import warnings
from typing import Any

from django.conf import settings
from django.core.cache import BaseCache, caches
from django.db import models

try:
from django.core.cache import caches
get_cache = lambda cache_name: caches[cache_name]
except ImportError:
from django.core.cache import get_cache

from solo import settings as solo_settings

if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self


DEFAULT_SINGLETON_INSTANCE_ID = 1


def get_cache(cache_name: str) -> BaseCache:
warnings.warn(
"'get_cache' is deprecated and will be removed in django-solo 2.4.0. "
"Instead, use 'caches' from 'django.core.cache'.",
DeprecationWarning,
stacklevel=2,
)
return caches[cache_name] # type: ignore[no-any-return] # mypy bug, unable to get a MRE


class SingletonModel(models.Model):
singleton_instance_id = DEFAULT_SINGLETON_INSTANCE_ID

class Meta:
abstract = True

def save(self, *args, **kwargs):
def save(self, *args: Any, **kwargs: Any) -> None:
self.pk = self.singleton_instance_id
super(SingletonModel, self).save(*args, **kwargs)
super().save(*args, **kwargs)
self.set_to_cache()

def delete(self, *args, **kwargs):
def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]:
self.clear_cache()
super(SingletonModel, self).delete(*args, **kwargs)
return super().delete(*args, **kwargs)

@classmethod
def clear_cache(cls):
def clear_cache(cls) -> None:
cache_name = getattr(settings, 'SOLO_CACHE', solo_settings.SOLO_CACHE)
if cache_name:
cache = get_cache(cache_name)
cache = caches[cache_name]
cache_key = cls.get_cache_key()
cache.delete(cache_key)

def set_to_cache(self):
def set_to_cache(self) -> None:
cache_name = getattr(settings, 'SOLO_CACHE', solo_settings.SOLO_CACHE)
if not cache_name:
return None
cache = get_cache(cache_name)
cache = caches[cache_name]
cache_key = self.get_cache_key()
timeout = getattr(settings, 'SOLO_CACHE_TIMEOUT', solo_settings.SOLO_CACHE_TIMEOUT)
cache.set(cache_key, self, timeout)

@classmethod
def get_cache_key(cls):
def get_cache_key(cls) -> str:
prefix = getattr(settings, 'SOLO_CACHE_PREFIX', solo_settings.SOLO_CACHE_PREFIX)
return '%s:%s' % (prefix, cls.__name__.lower())

@classmethod
def get_solo(cls):
def get_solo(cls) -> Self:
cache_name = getattr(settings, 'SOLO_CACHE', solo_settings.SOLO_CACHE)
if not cache_name:
obj, created = cls.objects.get_or_create(pk=cls.singleton_instance_id)
return obj
cache = get_cache(cache_name)
obj, _ = cls.objects.get_or_create(pk=cls.singleton_instance_id)
return obj # type: ignore[return-value]
cache = caches[cache_name]
cache_key = cls.get_cache_key()
obj = cache.get(cache_key)
if not obj:
obj, created = cls.objects.get_or_create(pk=cls.singleton_instance_id)
obj, _ = cls.objects.get_or_create(pk=cls.singleton_instance_id)
obj.set_to_cache()
return obj
return obj # type: ignore[return-value]
Empty file added solo/py.typed
Empty file.
16 changes: 10 additions & 6 deletions solo/settings.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
from __future__ import annotations

from django.conf import settings

# template parameters
GET_SOLO_TEMPLATE_TAG_NAME = getattr(settings,
'GET_SOLO_TEMPLATE_TAG_NAME', 'get_solo')
GET_SOLO_TEMPLATE_TAG_NAME: str = getattr(
settings, 'GET_SOLO_TEMPLATE_TAG_NAME', 'get_solo'
)

SOLO_ADMIN_SKIP_OBJECT_LIST_PAGE = getattr(settings,
'SOLO_ADMIN_SKIP_OBJECT_LIST_PAGE', True)
SOLO_ADMIN_SKIP_OBJECT_LIST_PAGE: bool = getattr(
settings, 'SOLO_ADMIN_SKIP_OBJECT_LIST_PAGE', True
)

# The cache that should be used, e.g. 'default'. Refers to Django CACHES setting.
# Set to None to disable caching.
SOLO_CACHE = None
SOLO_CACHE: str | None = None

SOLO_CACHE_TIMEOUT = 60*5
SOLO_CACHE_TIMEOUT = 60 * 5

SOLO_CACHE_PREFIX = 'solo'
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
13 changes: 4 additions & 9 deletions solo/templatetags/solo_tags.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
from django import template
from django.apps import apps
from django.utils.translation import gettext as _

from solo import settings as solo_settings

try:
from django.apps import apps
get_model = apps.get_model
except ImportError:
from django.db.models.loading import get_model

from solo.models import SingletonModel

register = template.Library()


@register.simple_tag(name=solo_settings.GET_SOLO_TEMPLATE_TAG_NAME)
def get_solo(model_path):
def get_solo(model_path: str) -> SingletonModel:
try:
app_label, model_name = model_path.rsplit('.', 1)
except ValueError:
raise template.TemplateSyntaxError(_(
"Templatetag requires the model dotted path: 'app_label.ModelName'. "
"Received '%s'." % model_path
))
model_class = get_model(app_label, model_name)
model_class: type[SingletonModel] = apps.get_model(app_label, model_name)
if not model_class:
raise template.TemplateSyntaxError(_(
"Could not get the model name '%(model)s' from the application "
Expand Down
4 changes: 2 additions & 2 deletions solo/tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from django.core.cache import caches
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template import Template, Context
from django.test import TestCase

from django.test.utils import override_settings
from solo.models import get_cache
from solo.tests.models import SiteConfiguration, SiteConfigurationWithExplicitlyGivenId


Expand All @@ -16,7 +16,7 @@ def setUp(self):
'{{ site_config.site_name }}'
'{{ site_config.file.url }}'
)
self.cache = get_cache('default')
self.cache = caches['default']
self.cache_key = SiteConfiguration.get_cache_key()
self.cache.clear()
SiteConfiguration.objects.all().delete()
Expand Down
11 changes: 10 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
python =
3.8: py38-django{32,42}
3.9: py39-django{32,42}
3.10: py310-django{32,42,50}
3.10: py310-django{32,42,50}, type-check
3.11: py311-django{42,50}
3.12: py312-django{42,50}

[tox]
envlist =
type-check
py{38,39,310}-django{32,42}
py{311,312}-django{42,50}

Expand All @@ -33,3 +34,11 @@ deps =
twine
commands =
{envpython} -m twine upload {toxinidir}/dist/*

[testenv:type-check]
skip_install = true
deps =
mypy==1.8.0
django-stubs==4.2.7
commands =
mypy solo

0 comments on commit 20d5ce6

Please sign in to comment.