diff --git a/py.typed b/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py index e50023f..b9b5eac 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,9 @@ setup( keywords='python django boilerplate', url='http://git.ksol.io/karolyi/py-ktools', package_dir={'': 'src'}, - package_data={'ktools.django': ['jinja/ktools/macros/*.html']}, + package_data={ + '': ['py.typed'], + 'ktools.django': ['jinja/ktools/macros/*.html'] + }, include_package_data=True, license='MIT+NIGGER') diff --git a/src/ktools/cache/functional.py b/src/ktools/cache/functional.py index 0504c59..8acc29d 100644 --- a/src/ktools/cache/functional.py +++ b/src/ktools/cache/functional.py @@ -1,22 +1,32 @@ -from functools import lru_cache, wraps +from functools import _lru_cache_wrapper, lru_cache, wraps +from typing import Callable, TypeVar from weakref import ref -def memoized_method(*lru_args, **lru_kwargs): - 'http://stackoverflow.com/a/33672499/1067833' - def decorator(func): - @wraps(func) - def wrapped_func(self, *args, **kwargs): - # We're storing the wrapped method inside the instance. If we had - # a strong reference to self the instance would be never - # garbage collected. - self_weak = ref(self) +_T = TypeVar(name='_T') +MemoizedCallable = Callable[..., _T] - @wraps(func) - @lru_cache(*lru_args, **lru_kwargs) - def cached_method(*args, **kwargs): - return func(self_weak(), *args, **kwargs) - setattr(self, func.__name__, cached_method) - return cached_method(*args, **kwargs) - return wrapped_func - return decorator + +def memoized_method( + maxsize: int | None = 128, typed: bool = False +) -> Callable[[MemoizedCallable], _lru_cache_wrapper]: + """ + LRU Cache decorator that keeps a weak reference to `self`, by + Raymond Hettinger: https://stackoverflow.com/a/68052994 + """ + def wrapper(func: Callable[..., _T]): + + @lru_cache(maxsize=maxsize, typed=typed) + def _func(_weakself, *args, **kwargs) -> _T: + return func(_weakself(), *args, **kwargs) + + @wraps(func) + def inner(self, *args, **kwargs) -> _T: + return _func(ref(self), *args, **kwargs) + + setattr(inner, 'cache_info', _func.cache_info) + setattr(inner, 'cache_clear', _func.cache_clear) + setattr(inner, 'cache_parameters', _func.cache_parameters) + return inner + + return wrapper diff --git a/src/ktools/cache/functional.pyi b/src/ktools/cache/functional.pyi new file mode 100644 index 0000000..dec0dce --- /dev/null +++ b/src/ktools/cache/functional.pyi @@ -0,0 +1,9 @@ +from functools import _lru_cache_wrapper +from typing import Callable, TypeVar + +_T = TypeVar('_T') + + +def memoized_method( + maxsize: int | None = 128, typed: bool = False +) -> Callable[[Callable[..., _T]], _lru_cache_wrapper[_T]]: ... diff --git a/src/ktools/django/apps.py b/src/ktools/django/apps.py index 3858724..f7d1425 100644 --- a/src/ktools/django/apps.py +++ b/src/ktools/django/apps.py @@ -4,7 +4,7 @@ from django.template import engines from .conf import setup_template_backend from .conf.constants import TEMPLATE_ENGINE_NAME -from .jinja import templatetags +from .jinja.templatetags import template_getter from .utils.translation import gettext_safelazy as _ @@ -20,8 +20,10 @@ class KtoolsDjangoConfig(AppConfig): Set up the instance of `KtoolsJinjaBackend` for later private use. """ - engines.templates[TEMPLATE_ENGINE_NAME] = \ - setup_template_backend(DEBUG=settings.DEBUG) - templatetags._k_jinja_backend = engines[TEMPLATE_ENGINE_NAME] + backend_cfg = setup_template_backend(DEBUG=settings.DEBUG) + engines.templates[TEMPLATE_ENGINE_NAME] = backend_cfg + template_getter.setup( + backend=engines[TEMPLATE_ENGINE_NAME], + cache_enabled=backend_cfg['OPTIONS']['bytecode_cache']['enabled']) del engines.templates[TEMPLATE_ENGINE_NAME] del engines._engines[TEMPLATE_ENGINE_NAME] diff --git a/src/ktools/django/conf/__init__.py b/src/ktools/django/conf/__init__.py index f759394..04dcc1a 100644 --- a/src/ktools/django/conf/__init__.py +++ b/src/ktools/django/conf/__init__.py @@ -21,7 +21,7 @@ def setup_template_backend(DEBUG: bool) -> _BackendType: 'DIRS': [], 'OPTIONS': { # It's already got a sensible default but repeat it here - 'app_dirname': 'ktools-jinjatemplates', + 'app_dirname': 'ktemplates', 'match_extension': None, 'newstyle_gettext': True, 'extensions': DEFAULT_EXTENSIONS + [ diff --git a/src/ktools/django/jinja/templatetags.py b/src/ktools/django/jinja/templatetags.py index a8c4a29..cb3577f 100644 --- a/src/ktools/django/jinja/templatetags.py +++ b/src/ktools/django/jinja/templatetags.py @@ -1,6 +1,5 @@ from __future__ import annotations -from functools import lru_cache from typing import Any from django.forms.boundfield import BoundField @@ -15,15 +14,48 @@ from jinja2.utils import pass_context from ktools.django.jinja.backend import KtoolsJinjaBackend -# See assignment in apps.py's `ready` -_k_jinja_backend: KtoolsJinjaBackend + +class _TemplateGetter(object): + # See assignment in apps.py's `ready` + cache_enabled = False + k_jinja_backend: KtoolsJinjaBackend + + def _get_template(self, template_name: str) -> Template: + return self.k_jinja_backend.get_template( + template_name=template_name) # pyright: ignore[reportReturnType] + + def setup(self, backend: KtoolsJinjaBackend, cache_enabled: bool): + self.k_jinja_backend = backend + self.cache_enabled = cache_enabled or True + if cache_enabled or True: + self.hits = self.misses = 0 + self.cached = dict[str, Template]() + + def stats(self) -> str: + if not self.cache_enabled: + return 'Cache not enabled.' + return ( + f'Hits: {self.hits}, Misses: {self.misses}, ' + f'Size: {len(self.cached)}') + + def __call__(self, template_name: str) -> Template: + if not self.cache_enabled: + obj: Template = self.k_jinja_backend.get_template( + template_name=template_name + ) # pyright: ignore[reportAssignmentType] + return obj + elif template := self.cached.get(template_name): + self.hits += 1 + return template + self.misses += 1 + obj: Template = self.k_jinja_backend.get_template( + template_name=template_name + ) # pyright: ignore[reportAssignmentType] + self.cached[template_name] = obj + return obj -@lru_cache(maxsize=None) -def _get_template(template_name: str) -> Template: - 'Return (and cache) a `Template` to render.' - return _k_jinja_backend.get_template( - template_name=template_name) # pyright: ignore[reportReturnType] +template_getter = _TemplateGetter() @pass_context @@ -33,8 +65,8 @@ def ktools_render_messages( 'Rendering a HTML message in various formats from ktools templates.' match variant: case 'bootstrap5-v1': - template = _get_template(template_name=( - 'ktools/render-content/messages-bootstrap5-v1.html.jinja')) + template = template_getter(template_name=( + 'render-content/messages-bootstrap5-v1.html.jinja')) return template.render(context=context, request=request) case _: raise TemplateNotFound( @@ -48,27 +80,25 @@ _TEMPLATE_MAPPINGS = { 'bootstrap5-floating': { 'TextInput': { 'template_name': ( - 'ktools/widgets/bootstrap5/floating/input-textual.html.jinja') + 'widgets/bootstrap5/floating/input-textual.html.jinja') }, 'EmailInput': { 'template_name': ( - 'ktools/widgets/bootstrap5/floating/input-textual.html.jinja') + 'widgets/bootstrap5/floating/input-textual.html.jinja') }, 'RegionalPhoneNumberWidget': { 'template_name': ( - 'ktools/widgets/bootstrap5/floating/input-textual.html.jinja') + 'widgets/bootstrap5/floating/input-textual.html.jinja') }, 'PasswordInput': { 'template_name': ( - 'ktools/widgets/bootstrap5/floating/input-textual.html.jinja') + 'widgets/bootstrap5/floating/input-textual.html.jinja') }, 'CheckboxSelectMultiple': { 'template_name': ( - 'ktools/widgets/bootstrap5/floating/multiple-input.html' - '.jinja'), + 'widgets/bootstrap5/floating/multiple-input.html.jinja'), 'option_template_name': ( - 'ktools/widgets/bootstrap5/floating/input-option.html' - '.jinja') + 'widgets/bootstrap5/floating/input-option.html.jinja') }, } } @@ -87,7 +117,7 @@ class KTemplatesSetting(BaseRenderer): Add the `_KBoundField` back to the context so that the template can use it. """ - template = _get_template(template_name=template_name) + template = template_getter(template_name=template_name) context['_k_boundfield'] = self._k_boundfield return template.render(context=context, request=request).strip() diff --git a/src/ktools/django/ktools-jinjatemplates/ktools/macros/render-form-simple.html b/src/ktools/django/ktemplates/macros/render-form-simple.html similarity index 100% rename from src/ktools/django/ktools-jinjatemplates/ktools/macros/render-form-simple.html rename to src/ktools/django/ktemplates/macros/render-form-simple.html diff --git a/src/ktools/django/ktools-jinjatemplates/ktools/render-content/messages-bootstrap5-v1.html.jinja b/src/ktools/django/ktemplates/render-content/messages-bootstrap5-v1.html.jinja similarity index 100% rename from src/ktools/django/ktools-jinjatemplates/ktools/render-content/messages-bootstrap5-v1.html.jinja rename to src/ktools/django/ktemplates/render-content/messages-bootstrap5-v1.html.jinja diff --git a/src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/attr-class.html.jinja b/src/ktools/django/ktemplates/widgets/bootstrap5/floating/attr-class.html.jinja similarity index 100% rename from src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/attr-class.html.jinja rename to src/ktools/django/ktemplates/widgets/bootstrap5/floating/attr-class.html.jinja diff --git a/src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/attrs-wo-class.html.jinja b/src/ktools/django/ktemplates/widgets/bootstrap5/floating/attrs-wo-class.html.jinja similarity index 100% rename from src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/attrs-wo-class.html.jinja rename to src/ktools/django/ktemplates/widgets/bootstrap5/floating/attrs-wo-class.html.jinja diff --git a/src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/attrs.html.jinja b/src/ktools/django/ktemplates/widgets/bootstrap5/floating/attrs.html.jinja similarity index 100% rename from src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/attrs.html.jinja rename to src/ktools/django/ktemplates/widgets/bootstrap5/floating/attrs.html.jinja diff --git a/src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/input-option.html.jinja b/src/ktools/django/ktemplates/widgets/bootstrap5/floating/input-option.html.jinja similarity index 91% rename from src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/input-option.html.jinja rename to src/ktools/django/ktemplates/widgets/bootstrap5/floating/input-option.html.jinja index 86915b7..ec06c3c 100644 --- a/src/ktools/django/ktools-jinjatemplates/ktools/widgets/bootstrap5/floating/input-option.html.jinja +++ b/src/ktools/django/ktemplates/widgets/bootstrap5/floating/input-option.html.jinja @@ -12,7 +12,7 @@ {% else %} {{- attr_class(widget, 'form-check-input') -}} {% endif %} - {% include 'ktools/widgets/bootstrap5/floating/attrs-wo-class.html.jinja' %} + {% include 'widgets/bootstrap5/floating/attrs-wo-class.html.jinja' %} placeholder="{{ _k_boundfield.label|e }}">