From 29b4da46ccab1815850cbbf57384f280bcf34c4c Mon Sep 17 00:00:00 2001 From: me Date: Tue, 17 Dec 2024 17:10:30 -0500 Subject: [PATCH 01/11] constrained form clean() methods validation to proj dir forms only; reformatted with isort + ruff(black) --- HISTORY.rst | 6 + django_fastdev/__init__.py | 8 +- django_fastdev/apps.py | 300 ++++++++++++++++--------- django_fastdev/templatetags/fastdev.py | 35 ++- 4 files changed, 220 insertions(+), 129 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 761a0cc..2500c1c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,12 @@ Changelog --------- +1.14.0 +~~~~~~ + +* Validation for Form clean() methods is now limited only to Forms existing within the project, and does not extend to third-party libraries + + 1.13.0 ~~~~~~ diff --git a/django_fastdev/__init__.py b/django_fastdev/__init__.py index 06cfbe8..b5939a4 100644 --- a/django_fastdev/__init__.py +++ b/django_fastdev/__init__.py @@ -1,12 +1,12 @@ -__version__ = '1.13.0' +__version__ = "1.14.0" from threading import Thread from time import sleep -default_app_config = 'django_fastdev.apps.FastDevConfig' +default_app_config = "django_fastdev.apps.FastDevConfig" -from django.core.management.commands.runserver import Command +from django.core.management.commands.runserver import Command # noqa: E402 orig_check = Command.check orig_check_migrations = Command.check_migrations @@ -16,6 +16,7 @@ def off_thread_check(self, *args, **kwargs): def inner(): sleep(0.1) # give the main thread some time to run orig_check(self, *args, **kwargs) + t = Thread(target=inner) t.start() @@ -24,6 +25,7 @@ def off_thread_check_migrations(self, *args, **kwargs): def inner(): sleep(0.1) # give the main thread some time to run orig_check_migrations(self, *args, **kwargs) + t = Thread(target=inner) t.start() diff --git a/django_fastdev/apps.py b/django_fastdev/apps.py index c66dce4..c2cb1c9 100644 --- a/django_fastdev/apps.py +++ b/django_fastdev/apps.py @@ -5,45 +5,33 @@ import subprocess import sys import threading -from functools import cache -from typing import Optional import warnings -from contextlib import ( - contextmanager, - nullcontext, -) +from contextlib import contextmanager, nullcontext +from functools import cache +from inspect import getmodule from pathlib import Path +from typing import Optional from django.apps import AppConfig from django.conf import settings -from django.db.models import ( - Model, - QuerySet, -) +from django.db.models import Model, QuerySet from django.forms import Form -from django.template import Context +from django.template import Context, engines from django.template.base import ( FilterExpression, TextNode, + TokenType, Variable, VariableDoesNotExist, - TokenType, -) -from django.template.defaulttags import ( - FirstOfNode, - IfNode, - ForNode -) -from django.template.loader_tags import ( - BlockNode, - ExtendsNode, ) +from django.template.defaulttags import ForNode # noqa: F401 +from django.template.defaulttags import FirstOfNode, IfNode +from django.template.loader_tags import BlockNode, ExtendsNode +from django.template.loaders.app_directories import Loader as AppDirLoader +from django.template.loaders.filesystem import Loader as FilesystemLoader from django.templatetags.i18n import BlockTranslateNode from django.urls.exceptions import NoReverseMatch from django.views.debug import DEBUG_ENGINE -from django.template import engines -from django.template.loaders.app_directories import Loader as AppDirLoader -from django.template.loaders.filesystem import Loader as FilesystemLoader class FastDevVariableDoesNotExist(Exception): @@ -83,7 +71,9 @@ def get_gitignore_path(): def is_absolute_url(url): - return bool(url.startswith('/') or url.startswith('http://') or url.startswith('https://')) + return bool( + url.startswith("/") or url.startswith("http://") or url.startswith("https://") + ) def validate_static_url_setting(): @@ -100,17 +90,20 @@ def validate_static_url_setting(): Returns: None """ - static_url = getattr(settings, 'STATIC_URL', None) + static_url = getattr(settings, "STATIC_URL", None) # check for static url if static_url and not is_absolute_url(static_url): - print(f""" + print( + f""" WARNING: You have STATIC_URL set to {static_url} in your settings.py file. It should start with either a '/' or 'http' to ensure it is an absolute URL. - """, file=sys.stderr) + """, + file=sys.stderr, + ) return @@ -129,10 +122,15 @@ def is_venv_ignored(project_path: Path) -> Optional[bool]: try: # ensure git is invoked as though it were run from the project directory, since # manage.py can be invoked from other directories. - check_ignore = subprocess.run(["git", "-C", project_path, "check-ignore", "--quiet", sys.prefix]) + check_ignore = subprocess.run( + ["git", "-C", project_path, "check-ignore", "--quiet", sys.prefix] + ) return check_ignore.returncode == 0 except FileNotFoundError: - print("git is not installed. django-fastdev can't check if venv is ignored in .gitignore", file=sys.stderr) + print( + "git is not installed. django-fastdev can't check if venv is ignored in .gitignore", + file=sys.stderr, + ) return None @@ -147,35 +145,43 @@ def validate_gitignore(path): is_pycache_ignored = False with open(path, "r") as git_ignore_file: - for (index, line) in enumerate(git_ignore_file.readlines()): - + for index, line in enumerate(git_ignore_file.readlines()): if check_for_migrations_in_gitignore(line): - bad_line_numbers_for_ignoring_migration.append(index+1) + bad_line_numbers_for_ignoring_migration.append(index + 1) if check_for_pycache_in_gitignore(line): is_pycache_ignored = True if bad_line_numbers_for_ignoring_migration: - print(f""" + print( + f""" You have excluded migrations folders from git This is not a good idea! It's very important to commit all your migrations files into git for migrations to work properly. https://docs.djangoproject.com/en/dev/topics/migrations/#version-control for more information - Bad pattern on lines : {', '.join(map(str, bad_line_numbers_for_ignoring_migration))}""", file=sys.stderr) + Bad pattern on lines : {', '.join(map(str, bad_line_numbers_for_ignoring_migration))}""", + file=sys.stderr, + ) if is_venv_ignored(project_path) is False: - print(f""" + print( + f""" {sys.prefix} is not ignored in .gitignore. Please add {sys.prefix} to .gitignore. - """, file=sys.stderr) + """, + file=sys.stderr, + ) if not is_pycache_ignored and "__pycache__" in list_of_subfolders: - print(f""" + print( + """ __pycache__ is not ignored in .gitignore. Please add __pycache__ to .gitignore. - """, file=sys.stderr) + """, + file=sys.stderr, + ) def validate_fk_field(model): @@ -209,16 +215,41 @@ def get_models_with_badly_named_pk(): car = ForeignKey(Car) Django will create a `car_id` field under the hood that is the ID of that field (normally a number).""", - file=sys.stderr + file=sys.stderr, ) def strict_if(): - return getattr(settings, 'FASTDEV_STRICT_IF', False) + return getattr(settings, "FASTDEV_STRICT_IF", False) def strict_template_checking(): - return getattr(settings, 'FASTDEV_STRICT_TEMPLATE_CHECKING', False) + return getattr(settings, "FASTDEV_STRICT_TEMPLATE_CHECKING", False) + + +def strict_form_checking(): + return getattr(settings, "FASTDEV_STRICT_FORM_CHECKING", False) + + +def is_from_project(cls): + """ + Check if a class originates from the project directory. + + Args: + cls: The class to check. + project_root: The root directory of your project (absolute path). + + Returns: + bool: True if the class originates from the project directory, False otherwise. + """ + module = getmodule(cls) + + # check if built-in module or dynamically created class + if not module or not hasattr(module, "__file__"): + return False + + module_path = os.path.abspath(module.__file__) + return module_path.startswith(os.path.abspath(settings.BASE_DIR)) def get_venv_folder_name(): @@ -231,7 +262,7 @@ def get_venv_folder_name(): @cache def get_ignored_template_list(): - ignored_templates_settings = getattr(settings, 'FASTDEV_IGNORED_TEMPLATES', []) + ignored_templates_settings = getattr(settings, "FASTDEV_IGNORED_TEMPLATES", []) ignored_templates = list() if ignored_templates_settings: for entry in ignored_templates_settings: @@ -248,15 +279,21 @@ def template_is_ignored(origin_name): class FastDevConfig(AppConfig): - name = 'django_fastdev' - verbose_name = 'django-fastdev' + name = "django_fastdev" + verbose_name = "django-fastdev" default = True def ready(self): orig_resolve = FilterExpression.resolve - def resolve_override(self, context, ignore_failures=False, ignore_failures_for_real=False): - if context.template_name is None and '{% if exception_type %}{{ exception_type }}' in context.template.source: + def resolve_override( + self, context, ignore_failures=False, ignore_failures_for_real=False + ): + if ( + context.template_name is None + and "{% if exception_type %}{{ exception_type }}" + in context.template.source + ): # best guess we are in the 500 error page, do the default return orig_resolve(self, context) @@ -273,49 +310,61 @@ def resolve_override(self, context, ignore_failures=False, ignore_failures_for_r if not strict_template_checking(): # worry only about templates inside our project dir; if they # exist elsewhere, then go to standard django behavior - venv_dir = os.environ.get('VIRTUAL_ENV', '') + venv_dir = os.environ.get("VIRTUAL_ENV", "") origin = context.template.origin.name if ( - origin != '' and - 'django-fastdev/tests/' not in origin + origin != "" + and "django-fastdev/tests/" not in origin and ( not origin.startswith(str(settings.BASE_DIR)) or (venv_dir and origin.startswith(venv_dir)) ) ): - return orig_resolve(self, context, ignore_failures=ignore_failures) - if ignore_failures_for_real or getattr(_local, 'ignore_errors', False): + return orig_resolve( + self, context, ignore_failures=ignore_failures + ) + if ignore_failures_for_real or getattr( + _local, "ignore_errors", False + ): if _local.deprecation_warning: - warnings.warn(_local.deprecation_warning, category=DeprecationWarning) + warnings.warn( + _local.deprecation_warning, category=DeprecationWarning + ) return orig_resolve(self, context, ignore_failures=True) if context.template.engine == DEBUG_ENGINE: - return orig_resolve(self, context, ignore_failures=ignore_failures) + return orig_resolve( + self, context, ignore_failures=ignore_failures + ) bit, current = e.params if len(self.var.lookups) == 1: - available = '\n '.join(sorted(context.flatten().keys())) - raise FastDevVariableDoesNotExist(f'''{self.var} does not exist in context. Available top level variables: + available = "\n ".join(sorted(context.flatten().keys())) + raise FastDevVariableDoesNotExist(f"""{self.var} does not exist in context. Available top level variables: {available} -''') +""") else: - full_name = '.'.join(self.var.lookups) - extra = '' + full_name = ".".join(self.var.lookups) + extra = "" if isinstance(current, Context): current = current.flatten() if isinstance(current, dict): - available_keys = '\n '.join(sorted(current.keys())) - extra = f'\nYou can access keys in the dict by their name. Available keys:\n\n {available_keys}\n' + available_keys = "\n ".join(sorted(current.keys())) + extra = f"\nYou can access keys in the dict by their name. Available keys:\n\n {available_keys}\n" error = f"dict does not have a key '{bit}', and does not have a member {bit}" else: - name = f'{type(current).__module__}.{type(current).__name__}' - error = f'{name} does not have a member {bit}' - available = '\n '.join(sorted(x for x in dir(current) if not x.startswith('_'))) + name = ( + f"{type(current).__module__}.{type(current).__name__}" + ) + error = f"{name} does not have a member {bit}" + available = "\n ".join( + sorted(x for x in dir(current) if not x.startswith("_")) + ) - raise FastDevVariableDoesNotExist(f'''Tried looking up {full_name} in context + raise FastDevVariableDoesNotExist(f"""Tried looking up {full_name} in context {error} {extra} @@ -324,7 +373,7 @@ def resolve_override(self, context, ignore_failures=False, ignore_failures_for_r {available} The object was: {current!r} -''') +""") return orig_resolve(self, context, ignore_failures) @@ -344,8 +393,14 @@ def if_render_override(self, context): for condition, nodelist in self.conditions_nodelists: if condition is not None: # if / elif clause context_handler = nullcontext() - if not strict_if() or '{% if exception_type %}{{ exception_type }}' in context.template.source: - context_handler = ignore_template_errors(deprecation_warning='set FASTDEV_STRICT_IF in settings, and use {% ifexists %} instead of {% if %} to check if a variable exists.') + if ( + not strict_if() + or "{% if exception_type %}{{ exception_type }}" + in context.template.source + ): + context_handler = ignore_template_errors( + deprecation_warning="set FASTDEV_STRICT_IF in settings, and use {% ifexists %} instead of {% if %} to check if a variable exists." + ) with context_handler: try: @@ -358,14 +413,16 @@ def if_render_override(self, context): if match: return nodelist.render(context) - return '' + return "" IfNode.render = if_render_override # Better reverse() errors import django.urls.resolvers as res + res.NoReverseMatch = FastDevNoReverseMatch import django.urls.base as bas + bas.NoReverseMatch = FastDevNoReverseMatchNamespace # Forms validation @@ -373,14 +430,21 @@ def if_render_override(self, context): def fastdev_full_clean(self): orig_form_full_clean(self) - from django.conf import settings - if settings.DEBUG: - prefix = 'clean_' - for name in dir(self): - if name.startswith(prefix) and callable(getattr(self, name)) and name[len(prefix):] not in self.fields: - fields = '\n '.join(sorted(self.fields.keys())) + # check if class is from our project, or strict form checking is enabled + if is_from_project(type(self)) or strict_form_checking(): + from django.conf import settings + + if settings.DEBUG: + prefix = "clean_" + for name in dir(self): + if ( + name.startswith(prefix) + and callable(getattr(self, name)) + and name[len(prefix) :] not in self.fields + ): + fields = "\n ".join(sorted(self.fields.keys())) - raise InvalidCleanMethod(f"""Clean method {name} of class {self.__class__.__name__} won't apply to any field. Available fields: + raise InvalidCleanMethod(f"""Clean method {name} of class {self.__class__.__name__} won't apply to any field. Available fields: {fields}""") @@ -393,10 +457,10 @@ def fixup_query_exception(e, args, kwargs): assert len(e.args) == 1 message = e.args[0] if args: - message += f'\n\nQuery args:\n\n {args}' + message += f"\n\nQuery args:\n\n {args}" if kwargs: - kwargs = '\n '.join([f'{k}: {v!r}' for k, v in kwargs.items()]) - message += f'\n\nQuery kwargs:\n\n {kwargs}' + kwargs = "\n ".join([f"{k}: {v!r}" for k, v in kwargs.items()]) + message += f"\n\nQuery kwargs:\n\n {kwargs}" e.args = (message,) def fast_dev_get(self, *args, **kwargs): @@ -415,7 +479,7 @@ def fast_dev_get(self, *args, **kwargs): # Gitignore validation git_ignore = get_gitignore_path() if git_ignore: - threading.Thread(target=validate_gitignore, args=(git_ignore, )).start() + threading.Thread(target=validate_gitignore, args=(git_ignore,)).start() # ForeignKey validation threading.Thread(target=get_models_with_badly_named_pk).start() @@ -428,8 +492,10 @@ def fast_dev_get(self, *args, **kwargs): def fastdev_render_token_list(self, tokens): for token in tokens: if token.token_type == TokenType.VAR: - if '.' in token.contents: - raise FastDevVariableDoesNotExist("You can't use dotted paths in blocktrans. You must use {% with foo = something.bar %} around the blocktrans.") + if "." in token.contents: + raise FastDevVariableDoesNotExist( + "You can't use dotted paths in blocktrans. You must use {% with foo = something.bar %} around the blocktrans." + ) return orig_blocktrans_render_token_list(self, tokens) BlockTranslateNode.render_token_list = fastdev_render_token_list @@ -448,7 +514,9 @@ def collect_nested_blocks(node): def get_extends_node_parent(extends_node, context): compiled_parent = extends_node.get_parent(context) - del context.render_context[extends_node.context_key] # remove our history of doing this + del context.render_context[ + extends_node.context_key + ] # remove our history of doing this return compiled_parent def collect_valid_blocks(template, context): @@ -456,8 +524,10 @@ def collect_valid_blocks(template, context): for x in template.nodelist: if isinstance(x, ExtendsNode): result |= collect_nested_blocks(x) - result |= collect_valid_blocks(get_extends_node_parent(x, context), context) - elif hasattr(x, 'child_nodelists'): + result |= collect_valid_blocks( + get_extends_node_parent(x, context), context + ) + elif hasattr(x, "child_nodelists"): # to be more explicit, could make the condition above # 'isinstance(x, (AutoEscapeControlNode, BlockNode, FilterNode, ForNode, IfNode, # IfChangedNode, SpacelessNode))' at the risk of missing some we don't know about @@ -468,17 +538,29 @@ def collect_valid_blocks(template, context): def extends_render(self, context): if settings.DEBUG: - valid_blocks = collect_valid_blocks(get_extends_node_parent(self, context), context) - actual_blocks = {x.name for x in self.nodelist if isinstance(x, BlockNode)} + valid_blocks = collect_valid_blocks( + get_extends_node_parent(self, context), context + ) + actual_blocks = { + x.name for x in self.nodelist if isinstance(x, BlockNode) + } invalid_blocks = actual_blocks - valid_blocks if invalid_blocks: - invalid_names = ' ' + '\n '.join(sorted(invalid_blocks)) - valid_names = ' ' + '\n '.join(sorted(valid_blocks)) - raise Exception(f'Invalid blocks specified:\n\n{invalid_names}\n\nValid blocks:\n\n{valid_names}') + invalid_names = " " + "\n ".join(sorted(invalid_blocks)) + valid_names = " " + "\n ".join(sorted(valid_blocks)) + raise Exception( + f"Invalid blocks specified:\n\n{invalid_names}\n\nValid blocks:\n\n{valid_names}" + ) # Validate no thrown away (non-whitespace) text blocks - thrown_away_text = '\n '.join([repr(x.s.strip()) for x in self.nodelist if isinstance(x, TextNode) and x.s.strip()]) - assert not thrown_away_text, f'The following html was thrown away when rendering {self.origin.template_name}:\n\n {thrown_away_text}' + thrown_away_text = "\n ".join( + [ + repr(x.s.strip()) + for x in self.nodelist + if isinstance(x, TextNode) and x.s.strip() + ] + ) + assert not thrown_away_text, f"The following html was thrown away when rendering {self.origin.template_name}:\n\n {thrown_away_text}" return orig_extends_render(self, context) @@ -501,7 +583,9 @@ def get_template_files(directory): for root, _, files in os.walk(directory): for file in files: template_extensions = getattr( - settings, "SHOWTEMPLATE_EXTENSIONS", [".html", ".htm", ".django", ".jinja", ".md"] + settings, + "SHOWTEMPLATE_EXTENSIONS", + [".html", ".htm", ".django", ".jinja", ".md"], ) if file.endswith(tuple(template_extensions)): full_path = os.path.join(root, file) @@ -574,12 +658,12 @@ def get_all_templates(): return template_list -from django.template import TemplateDoesNotExist +from django.template import TemplateDoesNotExist # noqa: E402 def fastdev_template_does_not_exist_error(self): if not settings.DEBUG: - return ''.join(self.args) + return "".join(self.args) r = list(self.args) @@ -587,13 +671,13 @@ def fastdev_template_does_not_exist_error(self): suggestions = difflib.get_close_matches(self.args[0], templates) if suggestions: - r += ['', 'Did you mean?'] - r += [f' {x}' for x in suggestions] + r += ["", "Did you mean?"] + r += [f" {x}" for x in suggestions] - r += ['', 'Valid values:'] - r += [f' {x}' for x in templates] + r += ["", "Valid values:"] + r += [f" {x}" for x in templates] - return '\n'.join(r) + return "\n".join(r) class InvalidCleanMethod(Exception): @@ -601,36 +685,36 @@ class InvalidCleanMethod(Exception): class FastDevNoReverseMatchNamespace(NoReverseMatch): - def __init__(self, msg): from django.conf import settings + if settings.DEBUG: frame = inspect.currentframe().f_back - resolver = frame.f_locals['resolver'] + resolver = frame.f_locals["resolver"] - msg += '\n\nAvailable namespaces:\n ' - msg += '\n '.join(sorted(resolver.namespace_dict.keys())) + msg += "\n\nAvailable namespaces:\n " + msg += "\n ".join(sorted(resolver.namespace_dict.keys())) super().__init__(msg) class FastDevNoReverseMatch(NoReverseMatch): - def __init__(self, msg): from django.conf import settings + if settings.DEBUG: frame = inspect.currentframe().f_back - msg += '\n\nThese names exist:\n\n ' + msg += "\n\nThese names exist:\n\n " names = [] - resolver = frame.f_locals['self'] + resolver = frame.f_locals["self"] for k in resolver.reverse_dict.keys(): if callable(k): continue names.append(k) - msg += '\n '.join(sorted(names)) + msg += "\n ".join(sorted(names)) super().__init__(msg) diff --git a/django_fastdev/templatetags/fastdev.py b/django_fastdev/templatetags/fastdev.py index 49bb175..8c70953 100644 --- a/django_fastdev/templatetags/fastdev.py +++ b/django_fastdev/templatetags/fastdev.py @@ -1,10 +1,7 @@ from django import template + # noinspection PyProtectedMember -from django.template import ( - Node, - NodeList, - TemplateSyntaxError, -) +from django.template import Node, NodeList, TemplateSyntaxError from django.template.defaulttags import TemplateIfParser from django_fastdev.apps import FastDevVariableDoesNotExist @@ -13,12 +10,11 @@ class IfExistsNode(Node): - def __init__(self, conditions_nodelists): self.conditions_nodelists = conditions_nodelists def __repr__(self): - return '<%s>' % self.__class__.__name__ + return "<%s>" % self.__class__.__name__ def __iter__(self): for _, nodelist in self.conditions_nodelists: @@ -30,20 +26,19 @@ def nodelist(self): def render(self, context): for condition, nodelist in self.conditions_nodelists: - - if condition is not None: # ifexists / elifexists clause + if condition is not None: # ifexists / elifexists clause try: condition.eval(context) match = True except FastDevVariableDoesNotExist: match = False - else: # else clause + else: # else clause match = True if match: return nodelist.render(context) - return '' + return "" @register.tag @@ -51,26 +46,30 @@ def ifexists(parser, token): # {% ifexists ... %} bits = token.split_contents()[1:] condition = TemplateIfParser(parser, bits).parse() - nodelist = parser.parse(('elifexists', 'else', 'endifexists')) + nodelist = parser.parse(("elifexists", "else", "endifexists")) conditions_nodelists = [(condition, nodelist)] token = parser.next_token() # {% elifexists ... %} (repeatable) - while token.contents.startswith('elifexists'): + while token.contents.startswith("elifexists"): bits = token.split_contents()[1:] condition = TemplateIfParser(parser, bits).parse() - nodelist = parser.parse(('elifexists', 'else', 'endifexists')) + nodelist = parser.parse(("elifexists", "else", "endifexists")) conditions_nodelists.append((condition, nodelist)) token = parser.next_token() # {% else %} (optional) - if token.contents == 'else': - nodelist = parser.parse(('endifexists',)) + if token.contents == "else": + nodelist = parser.parse(("endifexists",)) conditions_nodelists.append((None, nodelist)) token = parser.next_token() # {% endif %} - if token.contents != 'endifexists': - raise TemplateSyntaxError('Malformed template tag at line {}: "{}"'.format(token.lineno, token.contents)) + if token.contents != "endifexists": + raise TemplateSyntaxError( + 'Malformed template tag at line {}: "{}"'.format( + token.lineno, token.contents + ) + ) return IfExistsNode(conditions_nodelists) From dcfb8551a37b05ad1877bdd6ebc2cd42ef782a38 Mon Sep 17 00:00:00 2001 From: me Date: Tue, 17 Dec 2024 17:17:26 -0500 Subject: [PATCH 02/11] updated readme --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index 3fd4a36..c1e81fa 100644 --- a/README.rst +++ b/README.rst @@ -128,6 +128,11 @@ an error message telling you that your clean method doesn't match anything. This is also very useful during refactoring. Renaming a field is a lot safer as if you forget to rename the clean method :code:`django-fastdev` will tell you! +By default, :code:`django-fastdev` will check only forms that exist within your project, +and not third-party libraries. If you would like to enable stricter validation that will +extend to ALL forms, you can set this by configuring :code:`FASTDEV_STRICT_FORM_CHECKING` +to :code:`True` in your Django settings. + Faster startup ~~~~~~~~~~~~~~ From 67ee8d06d918d9827f27d30617e957005fcdd79f Mon Sep 17 00:00:00 2001 From: Naggafin Date: Tue, 17 Dec 2024 17:38:47 -0500 Subject: [PATCH 03/11] also made it work if venv is inside proj dir --- django_fastdev/apps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/django_fastdev/apps.py b/django_fastdev/apps.py index c2cb1c9..a550c00 100644 --- a/django_fastdev/apps.py +++ b/django_fastdev/apps.py @@ -248,8 +248,11 @@ def is_from_project(cls): if not module or not hasattr(module, "__file__"): return False + venv_dir = os.environ.get("VIRTUAL_ENV", "") module_path = os.path.abspath(module.__file__) - return module_path.startswith(os.path.abspath(settings.BASE_DIR)) + return module_path.startswith(str(settings.BASE_DIR)) or module_path.startswith( + venv_dir + ) def get_venv_folder_name(): From 14c719c99e2b4d82e9ecae03e91562f1f3d3b32d Mon Sep 17 00:00:00 2001 From: Naggafin Date: Tue, 17 Dec 2024 17:45:57 -0500 Subject: [PATCH 04/11] made it ignore the venv for real this time --- django_fastdev/apps.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/django_fastdev/apps.py b/django_fastdev/apps.py index a550c00..80b7cc4 100644 --- a/django_fastdev/apps.py +++ b/django_fastdev/apps.py @@ -250,9 +250,9 @@ def is_from_project(cls): venv_dir = os.environ.get("VIRTUAL_ENV", "") module_path = os.path.abspath(module.__file__) - return module_path.startswith(str(settings.BASE_DIR)) or module_path.startswith( - venv_dir - ) + return module_path.startswith( + str(settings.BASE_DIR) + ) and not module_path.startswith(venv_dir) def get_venv_folder_name(): From e78a23410811e0ce10459ad5d8fbcd0a157809f2 Mon Sep 17 00:00:00 2001 From: Naggafin Date: Tue, 17 Dec 2024 19:11:38 -0500 Subject: [PATCH 05/11] fixed form tests --- tests/test_forms.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_forms.py b/tests/test_forms.py index 49a1d40..363dd9b 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -30,6 +30,7 @@ def clean_flield(self): MyForm().errors settings.DEBUG = True + settings.FASTDEV_STRICT_FORM_CHECKING = True # set strict mode otherwise test will fail (because dynamically type form; doesn't exist in module) with pytest.raises(InvalidCleanMethod) as e: MyForm().errors @@ -55,6 +56,7 @@ def clean_flield(self): MyForm().errors settings.DEBUG = True + settings.FASTDEV_STRICT_FORM_CHECKING = True # set strict mode otherwise test will fail (because dynamically type form; doesn't exist in module) with pytest.raises(InvalidCleanMethod) as e: MyForm().errors From 62d87a2a8dc0adfbbd113cd403e7f8ebd1523680 Mon Sep 17 00:00:00 2001 From: Naggafin Date: Tue, 17 Dec 2024 19:13:21 -0500 Subject: [PATCH 06/11] fixed form tests pt. 2 --- tests/test_forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_forms.py b/tests/test_forms.py index 363dd9b..dd71bde 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -9,6 +9,7 @@ def test_ok_form_works(settings): settings.DEBUG = True + settings.FASTDEV_STRICT_FORM_CHECKING = True class MyForm(Form): field = CharField() From 2a197e253bb308ddbd4406fe0e3555700c099ac0 Mon Sep 17 00:00:00 2001 From: Naggafin Date: Mon, 23 Dec 2024 21:13:47 -0500 Subject: [PATCH 07/11] Revert "Release" This reverts commit 4db673681de1a0fc60c71c7f1aea321b0dd1f4bf. --- HISTORY.rst | 6 - README.rst | 5 - django_fastdev/__init__.py | 8 +- django_fastdev/apps.py | 303 +++++++++---------------- django_fastdev/templatetags/fastdev.py | 35 +-- tests/test_forms.py | 3 - 6 files changed, 129 insertions(+), 231 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 2500c1c..761a0cc 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,12 +1,6 @@ Changelog --------- -1.14.0 -~~~~~~ - -* Validation for Form clean() methods is now limited only to Forms existing within the project, and does not extend to third-party libraries - - 1.13.0 ~~~~~~ diff --git a/README.rst b/README.rst index c1e81fa..3fd4a36 100644 --- a/README.rst +++ b/README.rst @@ -128,11 +128,6 @@ an error message telling you that your clean method doesn't match anything. This is also very useful during refactoring. Renaming a field is a lot safer as if you forget to rename the clean method :code:`django-fastdev` will tell you! -By default, :code:`django-fastdev` will check only forms that exist within your project, -and not third-party libraries. If you would like to enable stricter validation that will -extend to ALL forms, you can set this by configuring :code:`FASTDEV_STRICT_FORM_CHECKING` -to :code:`True` in your Django settings. - Faster startup ~~~~~~~~~~~~~~ diff --git a/django_fastdev/__init__.py b/django_fastdev/__init__.py index b5939a4..06cfbe8 100644 --- a/django_fastdev/__init__.py +++ b/django_fastdev/__init__.py @@ -1,12 +1,12 @@ -__version__ = "1.14.0" +__version__ = '1.13.0' from threading import Thread from time import sleep -default_app_config = "django_fastdev.apps.FastDevConfig" +default_app_config = 'django_fastdev.apps.FastDevConfig' -from django.core.management.commands.runserver import Command # noqa: E402 +from django.core.management.commands.runserver import Command orig_check = Command.check orig_check_migrations = Command.check_migrations @@ -16,7 +16,6 @@ def off_thread_check(self, *args, **kwargs): def inner(): sleep(0.1) # give the main thread some time to run orig_check(self, *args, **kwargs) - t = Thread(target=inner) t.start() @@ -25,7 +24,6 @@ def off_thread_check_migrations(self, *args, **kwargs): def inner(): sleep(0.1) # give the main thread some time to run orig_check_migrations(self, *args, **kwargs) - t = Thread(target=inner) t.start() diff --git a/django_fastdev/apps.py b/django_fastdev/apps.py index 80b7cc4..c66dce4 100644 --- a/django_fastdev/apps.py +++ b/django_fastdev/apps.py @@ -5,33 +5,45 @@ import subprocess import sys import threading -import warnings -from contextlib import contextmanager, nullcontext from functools import cache -from inspect import getmodule -from pathlib import Path from typing import Optional +import warnings +from contextlib import ( + contextmanager, + nullcontext, +) +from pathlib import Path from django.apps import AppConfig from django.conf import settings -from django.db.models import Model, QuerySet +from django.db.models import ( + Model, + QuerySet, +) from django.forms import Form -from django.template import Context, engines +from django.template import Context from django.template.base import ( FilterExpression, TextNode, - TokenType, Variable, VariableDoesNotExist, + TokenType, +) +from django.template.defaulttags import ( + FirstOfNode, + IfNode, + ForNode +) +from django.template.loader_tags import ( + BlockNode, + ExtendsNode, ) -from django.template.defaulttags import ForNode # noqa: F401 -from django.template.defaulttags import FirstOfNode, IfNode -from django.template.loader_tags import BlockNode, ExtendsNode -from django.template.loaders.app_directories import Loader as AppDirLoader -from django.template.loaders.filesystem import Loader as FilesystemLoader from django.templatetags.i18n import BlockTranslateNode from django.urls.exceptions import NoReverseMatch from django.views.debug import DEBUG_ENGINE +from django.template import engines +from django.template.loaders.app_directories import Loader as AppDirLoader +from django.template.loaders.filesystem import Loader as FilesystemLoader class FastDevVariableDoesNotExist(Exception): @@ -71,9 +83,7 @@ def get_gitignore_path(): def is_absolute_url(url): - return bool( - url.startswith("/") or url.startswith("http://") or url.startswith("https://") - ) + return bool(url.startswith('/') or url.startswith('http://') or url.startswith('https://')) def validate_static_url_setting(): @@ -90,20 +100,17 @@ def validate_static_url_setting(): Returns: None """ - static_url = getattr(settings, "STATIC_URL", None) + static_url = getattr(settings, 'STATIC_URL', None) # check for static url if static_url and not is_absolute_url(static_url): - print( - f""" + print(f""" WARNING: You have STATIC_URL set to {static_url} in your settings.py file. It should start with either a '/' or 'http' to ensure it is an absolute URL. - """, - file=sys.stderr, - ) + """, file=sys.stderr) return @@ -122,15 +129,10 @@ def is_venv_ignored(project_path: Path) -> Optional[bool]: try: # ensure git is invoked as though it were run from the project directory, since # manage.py can be invoked from other directories. - check_ignore = subprocess.run( - ["git", "-C", project_path, "check-ignore", "--quiet", sys.prefix] - ) + check_ignore = subprocess.run(["git", "-C", project_path, "check-ignore", "--quiet", sys.prefix]) return check_ignore.returncode == 0 except FileNotFoundError: - print( - "git is not installed. django-fastdev can't check if venv is ignored in .gitignore", - file=sys.stderr, - ) + print("git is not installed. django-fastdev can't check if venv is ignored in .gitignore", file=sys.stderr) return None @@ -145,43 +147,35 @@ def validate_gitignore(path): is_pycache_ignored = False with open(path, "r") as git_ignore_file: - for index, line in enumerate(git_ignore_file.readlines()): + for (index, line) in enumerate(git_ignore_file.readlines()): + if check_for_migrations_in_gitignore(line): - bad_line_numbers_for_ignoring_migration.append(index + 1) + bad_line_numbers_for_ignoring_migration.append(index+1) if check_for_pycache_in_gitignore(line): is_pycache_ignored = True if bad_line_numbers_for_ignoring_migration: - print( - f""" + print(f""" You have excluded migrations folders from git This is not a good idea! It's very important to commit all your migrations files into git for migrations to work properly. https://docs.djangoproject.com/en/dev/topics/migrations/#version-control for more information - Bad pattern on lines : {', '.join(map(str, bad_line_numbers_for_ignoring_migration))}""", - file=sys.stderr, - ) + Bad pattern on lines : {', '.join(map(str, bad_line_numbers_for_ignoring_migration))}""", file=sys.stderr) if is_venv_ignored(project_path) is False: - print( - f""" + print(f""" {sys.prefix} is not ignored in .gitignore. Please add {sys.prefix} to .gitignore. - """, - file=sys.stderr, - ) + """, file=sys.stderr) if not is_pycache_ignored and "__pycache__" in list_of_subfolders: - print( - """ + print(f""" __pycache__ is not ignored in .gitignore. Please add __pycache__ to .gitignore. - """, - file=sys.stderr, - ) + """, file=sys.stderr) def validate_fk_field(model): @@ -215,44 +209,16 @@ def get_models_with_badly_named_pk(): car = ForeignKey(Car) Django will create a `car_id` field under the hood that is the ID of that field (normally a number).""", - file=sys.stderr, + file=sys.stderr ) def strict_if(): - return getattr(settings, "FASTDEV_STRICT_IF", False) + return getattr(settings, 'FASTDEV_STRICT_IF', False) def strict_template_checking(): - return getattr(settings, "FASTDEV_STRICT_TEMPLATE_CHECKING", False) - - -def strict_form_checking(): - return getattr(settings, "FASTDEV_STRICT_FORM_CHECKING", False) - - -def is_from_project(cls): - """ - Check if a class originates from the project directory. - - Args: - cls: The class to check. - project_root: The root directory of your project (absolute path). - - Returns: - bool: True if the class originates from the project directory, False otherwise. - """ - module = getmodule(cls) - - # check if built-in module or dynamically created class - if not module or not hasattr(module, "__file__"): - return False - - venv_dir = os.environ.get("VIRTUAL_ENV", "") - module_path = os.path.abspath(module.__file__) - return module_path.startswith( - str(settings.BASE_DIR) - ) and not module_path.startswith(venv_dir) + return getattr(settings, 'FASTDEV_STRICT_TEMPLATE_CHECKING', False) def get_venv_folder_name(): @@ -265,7 +231,7 @@ def get_venv_folder_name(): @cache def get_ignored_template_list(): - ignored_templates_settings = getattr(settings, "FASTDEV_IGNORED_TEMPLATES", []) + ignored_templates_settings = getattr(settings, 'FASTDEV_IGNORED_TEMPLATES', []) ignored_templates = list() if ignored_templates_settings: for entry in ignored_templates_settings: @@ -282,21 +248,15 @@ def template_is_ignored(origin_name): class FastDevConfig(AppConfig): - name = "django_fastdev" - verbose_name = "django-fastdev" + name = 'django_fastdev' + verbose_name = 'django-fastdev' default = True def ready(self): orig_resolve = FilterExpression.resolve - def resolve_override( - self, context, ignore_failures=False, ignore_failures_for_real=False - ): - if ( - context.template_name is None - and "{% if exception_type %}{{ exception_type }}" - in context.template.source - ): + def resolve_override(self, context, ignore_failures=False, ignore_failures_for_real=False): + if context.template_name is None and '{% if exception_type %}{{ exception_type }}' in context.template.source: # best guess we are in the 500 error page, do the default return orig_resolve(self, context) @@ -313,61 +273,49 @@ def resolve_override( if not strict_template_checking(): # worry only about templates inside our project dir; if they # exist elsewhere, then go to standard django behavior - venv_dir = os.environ.get("VIRTUAL_ENV", "") + venv_dir = os.environ.get('VIRTUAL_ENV', '') origin = context.template.origin.name if ( - origin != "" - and "django-fastdev/tests/" not in origin + origin != '' and + 'django-fastdev/tests/' not in origin and ( not origin.startswith(str(settings.BASE_DIR)) or (venv_dir and origin.startswith(venv_dir)) ) ): - return orig_resolve( - self, context, ignore_failures=ignore_failures - ) - if ignore_failures_for_real or getattr( - _local, "ignore_errors", False - ): + return orig_resolve(self, context, ignore_failures=ignore_failures) + if ignore_failures_for_real or getattr(_local, 'ignore_errors', False): if _local.deprecation_warning: - warnings.warn( - _local.deprecation_warning, category=DeprecationWarning - ) + warnings.warn(_local.deprecation_warning, category=DeprecationWarning) return orig_resolve(self, context, ignore_failures=True) if context.template.engine == DEBUG_ENGINE: - return orig_resolve( - self, context, ignore_failures=ignore_failures - ) + return orig_resolve(self, context, ignore_failures=ignore_failures) bit, current = e.params if len(self.var.lookups) == 1: - available = "\n ".join(sorted(context.flatten().keys())) - raise FastDevVariableDoesNotExist(f"""{self.var} does not exist in context. Available top level variables: + available = '\n '.join(sorted(context.flatten().keys())) + raise FastDevVariableDoesNotExist(f'''{self.var} does not exist in context. Available top level variables: {available} -""") +''') else: - full_name = ".".join(self.var.lookups) - extra = "" + full_name = '.'.join(self.var.lookups) + extra = '' if isinstance(current, Context): current = current.flatten() if isinstance(current, dict): - available_keys = "\n ".join(sorted(current.keys())) - extra = f"\nYou can access keys in the dict by their name. Available keys:\n\n {available_keys}\n" + available_keys = '\n '.join(sorted(current.keys())) + extra = f'\nYou can access keys in the dict by their name. Available keys:\n\n {available_keys}\n' error = f"dict does not have a key '{bit}', and does not have a member {bit}" else: - name = ( - f"{type(current).__module__}.{type(current).__name__}" - ) - error = f"{name} does not have a member {bit}" - available = "\n ".join( - sorted(x for x in dir(current) if not x.startswith("_")) - ) + name = f'{type(current).__module__}.{type(current).__name__}' + error = f'{name} does not have a member {bit}' + available = '\n '.join(sorted(x for x in dir(current) if not x.startswith('_'))) - raise FastDevVariableDoesNotExist(f"""Tried looking up {full_name} in context + raise FastDevVariableDoesNotExist(f'''Tried looking up {full_name} in context {error} {extra} @@ -376,7 +324,7 @@ def resolve_override( {available} The object was: {current!r} -""") +''') return orig_resolve(self, context, ignore_failures) @@ -396,14 +344,8 @@ def if_render_override(self, context): for condition, nodelist in self.conditions_nodelists: if condition is not None: # if / elif clause context_handler = nullcontext() - if ( - not strict_if() - or "{% if exception_type %}{{ exception_type }}" - in context.template.source - ): - context_handler = ignore_template_errors( - deprecation_warning="set FASTDEV_STRICT_IF in settings, and use {% ifexists %} instead of {% if %} to check if a variable exists." - ) + if not strict_if() or '{% if exception_type %}{{ exception_type }}' in context.template.source: + context_handler = ignore_template_errors(deprecation_warning='set FASTDEV_STRICT_IF in settings, and use {% ifexists %} instead of {% if %} to check if a variable exists.') with context_handler: try: @@ -416,16 +358,14 @@ def if_render_override(self, context): if match: return nodelist.render(context) - return "" + return '' IfNode.render = if_render_override # Better reverse() errors import django.urls.resolvers as res - res.NoReverseMatch = FastDevNoReverseMatch import django.urls.base as bas - bas.NoReverseMatch = FastDevNoReverseMatchNamespace # Forms validation @@ -433,21 +373,14 @@ def if_render_override(self, context): def fastdev_full_clean(self): orig_form_full_clean(self) - # check if class is from our project, or strict form checking is enabled - if is_from_project(type(self)) or strict_form_checking(): - from django.conf import settings - - if settings.DEBUG: - prefix = "clean_" - for name in dir(self): - if ( - name.startswith(prefix) - and callable(getattr(self, name)) - and name[len(prefix) :] not in self.fields - ): - fields = "\n ".join(sorted(self.fields.keys())) + from django.conf import settings + if settings.DEBUG: + prefix = 'clean_' + for name in dir(self): + if name.startswith(prefix) and callable(getattr(self, name)) and name[len(prefix):] not in self.fields: + fields = '\n '.join(sorted(self.fields.keys())) - raise InvalidCleanMethod(f"""Clean method {name} of class {self.__class__.__name__} won't apply to any field. Available fields: + raise InvalidCleanMethod(f"""Clean method {name} of class {self.__class__.__name__} won't apply to any field. Available fields: {fields}""") @@ -460,10 +393,10 @@ def fixup_query_exception(e, args, kwargs): assert len(e.args) == 1 message = e.args[0] if args: - message += f"\n\nQuery args:\n\n {args}" + message += f'\n\nQuery args:\n\n {args}' if kwargs: - kwargs = "\n ".join([f"{k}: {v!r}" for k, v in kwargs.items()]) - message += f"\n\nQuery kwargs:\n\n {kwargs}" + kwargs = '\n '.join([f'{k}: {v!r}' for k, v in kwargs.items()]) + message += f'\n\nQuery kwargs:\n\n {kwargs}' e.args = (message,) def fast_dev_get(self, *args, **kwargs): @@ -482,7 +415,7 @@ def fast_dev_get(self, *args, **kwargs): # Gitignore validation git_ignore = get_gitignore_path() if git_ignore: - threading.Thread(target=validate_gitignore, args=(git_ignore,)).start() + threading.Thread(target=validate_gitignore, args=(git_ignore, )).start() # ForeignKey validation threading.Thread(target=get_models_with_badly_named_pk).start() @@ -495,10 +428,8 @@ def fast_dev_get(self, *args, **kwargs): def fastdev_render_token_list(self, tokens): for token in tokens: if token.token_type == TokenType.VAR: - if "." in token.contents: - raise FastDevVariableDoesNotExist( - "You can't use dotted paths in blocktrans. You must use {% with foo = something.bar %} around the blocktrans." - ) + if '.' in token.contents: + raise FastDevVariableDoesNotExist("You can't use dotted paths in blocktrans. You must use {% with foo = something.bar %} around the blocktrans.") return orig_blocktrans_render_token_list(self, tokens) BlockTranslateNode.render_token_list = fastdev_render_token_list @@ -517,9 +448,7 @@ def collect_nested_blocks(node): def get_extends_node_parent(extends_node, context): compiled_parent = extends_node.get_parent(context) - del context.render_context[ - extends_node.context_key - ] # remove our history of doing this + del context.render_context[extends_node.context_key] # remove our history of doing this return compiled_parent def collect_valid_blocks(template, context): @@ -527,10 +456,8 @@ def collect_valid_blocks(template, context): for x in template.nodelist: if isinstance(x, ExtendsNode): result |= collect_nested_blocks(x) - result |= collect_valid_blocks( - get_extends_node_parent(x, context), context - ) - elif hasattr(x, "child_nodelists"): + result |= collect_valid_blocks(get_extends_node_parent(x, context), context) + elif hasattr(x, 'child_nodelists'): # to be more explicit, could make the condition above # 'isinstance(x, (AutoEscapeControlNode, BlockNode, FilterNode, ForNode, IfNode, # IfChangedNode, SpacelessNode))' at the risk of missing some we don't know about @@ -541,29 +468,17 @@ def collect_valid_blocks(template, context): def extends_render(self, context): if settings.DEBUG: - valid_blocks = collect_valid_blocks( - get_extends_node_parent(self, context), context - ) - actual_blocks = { - x.name for x in self.nodelist if isinstance(x, BlockNode) - } + valid_blocks = collect_valid_blocks(get_extends_node_parent(self, context), context) + actual_blocks = {x.name for x in self.nodelist if isinstance(x, BlockNode)} invalid_blocks = actual_blocks - valid_blocks if invalid_blocks: - invalid_names = " " + "\n ".join(sorted(invalid_blocks)) - valid_names = " " + "\n ".join(sorted(valid_blocks)) - raise Exception( - f"Invalid blocks specified:\n\n{invalid_names}\n\nValid blocks:\n\n{valid_names}" - ) + invalid_names = ' ' + '\n '.join(sorted(invalid_blocks)) + valid_names = ' ' + '\n '.join(sorted(valid_blocks)) + raise Exception(f'Invalid blocks specified:\n\n{invalid_names}\n\nValid blocks:\n\n{valid_names}') # Validate no thrown away (non-whitespace) text blocks - thrown_away_text = "\n ".join( - [ - repr(x.s.strip()) - for x in self.nodelist - if isinstance(x, TextNode) and x.s.strip() - ] - ) - assert not thrown_away_text, f"The following html was thrown away when rendering {self.origin.template_name}:\n\n {thrown_away_text}" + thrown_away_text = '\n '.join([repr(x.s.strip()) for x in self.nodelist if isinstance(x, TextNode) and x.s.strip()]) + assert not thrown_away_text, f'The following html was thrown away when rendering {self.origin.template_name}:\n\n {thrown_away_text}' return orig_extends_render(self, context) @@ -586,9 +501,7 @@ def get_template_files(directory): for root, _, files in os.walk(directory): for file in files: template_extensions = getattr( - settings, - "SHOWTEMPLATE_EXTENSIONS", - [".html", ".htm", ".django", ".jinja", ".md"], + settings, "SHOWTEMPLATE_EXTENSIONS", [".html", ".htm", ".django", ".jinja", ".md"] ) if file.endswith(tuple(template_extensions)): full_path = os.path.join(root, file) @@ -661,12 +574,12 @@ def get_all_templates(): return template_list -from django.template import TemplateDoesNotExist # noqa: E402 +from django.template import TemplateDoesNotExist def fastdev_template_does_not_exist_error(self): if not settings.DEBUG: - return "".join(self.args) + return ''.join(self.args) r = list(self.args) @@ -674,13 +587,13 @@ def fastdev_template_does_not_exist_error(self): suggestions = difflib.get_close_matches(self.args[0], templates) if suggestions: - r += ["", "Did you mean?"] - r += [f" {x}" for x in suggestions] + r += ['', 'Did you mean?'] + r += [f' {x}' for x in suggestions] - r += ["", "Valid values:"] - r += [f" {x}" for x in templates] + r += ['', 'Valid values:'] + r += [f' {x}' for x in templates] - return "\n".join(r) + return '\n'.join(r) class InvalidCleanMethod(Exception): @@ -688,36 +601,36 @@ class InvalidCleanMethod(Exception): class FastDevNoReverseMatchNamespace(NoReverseMatch): + def __init__(self, msg): from django.conf import settings - if settings.DEBUG: frame = inspect.currentframe().f_back - resolver = frame.f_locals["resolver"] + resolver = frame.f_locals['resolver'] - msg += "\n\nAvailable namespaces:\n " - msg += "\n ".join(sorted(resolver.namespace_dict.keys())) + msg += '\n\nAvailable namespaces:\n ' + msg += '\n '.join(sorted(resolver.namespace_dict.keys())) super().__init__(msg) class FastDevNoReverseMatch(NoReverseMatch): + def __init__(self, msg): from django.conf import settings - if settings.DEBUG: frame = inspect.currentframe().f_back - msg += "\n\nThese names exist:\n\n " + msg += '\n\nThese names exist:\n\n ' names = [] - resolver = frame.f_locals["self"] + resolver = frame.f_locals['self'] for k in resolver.reverse_dict.keys(): if callable(k): continue names.append(k) - msg += "\n ".join(sorted(names)) + msg += '\n '.join(sorted(names)) super().__init__(msg) diff --git a/django_fastdev/templatetags/fastdev.py b/django_fastdev/templatetags/fastdev.py index 8c70953..49bb175 100644 --- a/django_fastdev/templatetags/fastdev.py +++ b/django_fastdev/templatetags/fastdev.py @@ -1,7 +1,10 @@ from django import template - # noinspection PyProtectedMember -from django.template import Node, NodeList, TemplateSyntaxError +from django.template import ( + Node, + NodeList, + TemplateSyntaxError, +) from django.template.defaulttags import TemplateIfParser from django_fastdev.apps import FastDevVariableDoesNotExist @@ -10,11 +13,12 @@ class IfExistsNode(Node): + def __init__(self, conditions_nodelists): self.conditions_nodelists = conditions_nodelists def __repr__(self): - return "<%s>" % self.__class__.__name__ + return '<%s>' % self.__class__.__name__ def __iter__(self): for _, nodelist in self.conditions_nodelists: @@ -26,19 +30,20 @@ def nodelist(self): def render(self, context): for condition, nodelist in self.conditions_nodelists: - if condition is not None: # ifexists / elifexists clause + + if condition is not None: # ifexists / elifexists clause try: condition.eval(context) match = True except FastDevVariableDoesNotExist: match = False - else: # else clause + else: # else clause match = True if match: return nodelist.render(context) - return "" + return '' @register.tag @@ -46,30 +51,26 @@ def ifexists(parser, token): # {% ifexists ... %} bits = token.split_contents()[1:] condition = TemplateIfParser(parser, bits).parse() - nodelist = parser.parse(("elifexists", "else", "endifexists")) + nodelist = parser.parse(('elifexists', 'else', 'endifexists')) conditions_nodelists = [(condition, nodelist)] token = parser.next_token() # {% elifexists ... %} (repeatable) - while token.contents.startswith("elifexists"): + while token.contents.startswith('elifexists'): bits = token.split_contents()[1:] condition = TemplateIfParser(parser, bits).parse() - nodelist = parser.parse(("elifexists", "else", "endifexists")) + nodelist = parser.parse(('elifexists', 'else', 'endifexists')) conditions_nodelists.append((condition, nodelist)) token = parser.next_token() # {% else %} (optional) - if token.contents == "else": - nodelist = parser.parse(("endifexists",)) + if token.contents == 'else': + nodelist = parser.parse(('endifexists',)) conditions_nodelists.append((None, nodelist)) token = parser.next_token() # {% endif %} - if token.contents != "endifexists": - raise TemplateSyntaxError( - 'Malformed template tag at line {}: "{}"'.format( - token.lineno, token.contents - ) - ) + if token.contents != 'endifexists': + raise TemplateSyntaxError('Malformed template tag at line {}: "{}"'.format(token.lineno, token.contents)) return IfExistsNode(conditions_nodelists) diff --git a/tests/test_forms.py b/tests/test_forms.py index dd71bde..49a1d40 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -9,7 +9,6 @@ def test_ok_form_works(settings): settings.DEBUG = True - settings.FASTDEV_STRICT_FORM_CHECKING = True class MyForm(Form): field = CharField() @@ -31,7 +30,6 @@ def clean_flield(self): MyForm().errors settings.DEBUG = True - settings.FASTDEV_STRICT_FORM_CHECKING = True # set strict mode otherwise test will fail (because dynamically type form; doesn't exist in module) with pytest.raises(InvalidCleanMethod) as e: MyForm().errors @@ -57,7 +55,6 @@ def clean_flield(self): MyForm().errors settings.DEBUG = True - settings.FASTDEV_STRICT_FORM_CHECKING = True # set strict mode otherwise test will fail (because dynamically type form; doesn't exist in module) with pytest.raises(InvalidCleanMethod) as e: MyForm().errors From 6578b080ab535aeca64a9d7ccb2aa419dda0f4c0 Mon Sep 17 00:00:00 2001 From: Naggafin Date: Mon, 23 Dec 2024 21:36:55 -0500 Subject: [PATCH 08/11] revised --- HISTORY.rst | 6 +++++ README.rst | 5 ++++ django_fastdev/__init__.py | 2 +- django_fastdev/apps.py | 55 ++++++++++++++++++++++++++++++++------ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 761a0cc..2500c1c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,12 @@ Changelog --------- +1.14.0 +~~~~~~ + +* Validation for Form clean() methods is now limited only to Forms existing within the project, and does not extend to third-party libraries + + 1.13.0 ~~~~~~ diff --git a/README.rst b/README.rst index 3fd4a36..2e52520 100644 --- a/README.rst +++ b/README.rst @@ -128,6 +128,11 @@ an error message telling you that your clean method doesn't match anything. This is also very useful during refactoring. Renaming a field is a lot safer as if you forget to rename the clean method :code:`django-fastdev` will tell you! +By default, :code:`django-fastdev` will check only forms that exist within your project, +and not third-party libraries. If you would like to enable stricter validation that will +extend to ALL forms, you can set this by configuring :code:`FASTDEV_STRICT_FORM_CHECKING` +to :code:`True` in your Django settings. + Faster startup ~~~~~~~~~~~~~~ diff --git a/django_fastdev/__init__.py b/django_fastdev/__init__.py index 06cfbe8..f9fd48a 100644 --- a/django_fastdev/__init__.py +++ b/django_fastdev/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.13.0' +__version__ = '1.14.0' from threading import Thread from time import sleep diff --git a/django_fastdev/apps.py b/django_fastdev/apps.py index c66dce4..a6fe864 100644 --- a/django_fastdev/apps.py +++ b/django_fastdev/apps.py @@ -6,6 +6,7 @@ import sys import threading from functools import cache +from inspect import getmodule from typing import Optional import warnings from contextlib import ( @@ -221,6 +222,42 @@ def strict_template_checking(): return getattr(settings, 'FASTDEV_STRICT_TEMPLATE_CHECKING', False) +def strict_form_checking(): + return getattr(settings, "FASTDEV_STRICT_FORM_CHECKING", False) + + +def is_from_project(cls): + """ + Check if a class originates from the project directory. + + Args: + cls: The class to check. + project_root: The root directory of your project (absolute path). + + Returns: + bool: True if the class originates from the project directory, False otherwise. + """ + module = getmodule(cls) + + # check if built-in module or dynamically created class + if not module or not hasattr(module, "__file__"): + return False + + venv_dir = os.environ.get("VIRTUAL_ENV", "") + module_path = os.path.abspath(module.__file__) + return module_path.startswith( + str(settings.BASE_DIR) + ) and not module_path.startswith(venv_dir) + + +def fastdev_ignore(): + """A decorator to exclude a function or class from fastdev checks.""" + def decorator(target): + setattr(target, "fastdev_ignore", True) + return target + return decorator + + def get_venv_folder_name(): import os @@ -373,14 +410,16 @@ def if_render_override(self, context): def fastdev_full_clean(self): orig_form_full_clean(self) - from django.conf import settings - if settings.DEBUG: - prefix = 'clean_' - for name in dir(self): - if name.startswith(prefix) and callable(getattr(self, name)) and name[len(prefix):] not in self.fields: - fields = '\n '.join(sorted(self.fields.keys())) + # check if class is from our project, or strict form checking is enabled + if is_from_project(type(self)) or strict_form_checking(): + from django.conf import settings + if settings.DEBUG: + prefix = 'clean_' + for name in dir(self): + if name.startswith(prefix) and callable(getattr(self, name)) and name[len(prefix):] not in self.fields: + fields = '\n '.join(sorted(self.fields.keys())) - raise InvalidCleanMethod(f"""Clean method {name} of class {self.__class__.__name__} won't apply to any field. Available fields: + raise InvalidCleanMethod(f"""Clean method {name} of class {self.__class__.__name__} won't apply to any field. Available fields: {fields}""") @@ -574,7 +613,7 @@ def get_all_templates(): return template_list -from django.template import TemplateDoesNotExist +from django.template import TemplateDoesNotExist # noqa: E402 def fastdev_template_does_not_exist_error(self): From 2ff9b98f8829d11f12aa4238ccab40ee29a199c9 Mon Sep 17 00:00:00 2001 From: Naggafin Date: Mon, 23 Dec 2024 21:41:42 -0500 Subject: [PATCH 09/11] fixed tests again --- tests/test_forms.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_forms.py b/tests/test_forms.py index 49a1d40..43c6c3b 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -30,10 +30,12 @@ def clean_flield(self): MyForm().errors settings.DEBUG = True + # set strict mode otherwise test will fail (because dynamically type form; doesn't exist in module) + settings.FASTDEV_STRICT_FORM_CHECKING = True with pytest.raises(InvalidCleanMethod) as e: MyForm().errors - assert str(e.value) == """Clean method clean_flield of class MyForm won't apply to any field. Available fields:\n\n field""" + assert str(e.value) == """Clean method clean_field of class MyForm won't apply to any field. Available fields:\n\n field""" # noinspection PyStatementEffect @@ -55,7 +57,9 @@ def clean_flield(self): MyForm().errors settings.DEBUG = True + # set strict mode otherwise test will fail (because dynamically type form; doesn't exist in module) + settings.FASTDEV_STRICT_FORM_CHECKING = True with pytest.raises(InvalidCleanMethod) as e: MyForm().errors - assert str(e.value) == """Clean method clean_flield of class MyForm won't apply to any field. Available fields:\n\n field""" + assert str(e.value) == """Clean method clean_field of class MyForm won't apply to any field. Available fields:\n\n field""" From c547b65effe1fcfe84294d07dc4d6c9240f44997 Mon Sep 17 00:00:00 2001 From: Naggafin Date: Mon, 23 Dec 2024 21:48:15 -0500 Subject: [PATCH 10/11] fixed tests --- tests/test_forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_forms.py b/tests/test_forms.py index 43c6c3b..cb50b9d 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -35,7 +35,7 @@ def clean_flield(self): with pytest.raises(InvalidCleanMethod) as e: MyForm().errors - assert str(e.value) == """Clean method clean_field of class MyForm won't apply to any field. Available fields:\n\n field""" + assert str(e.value) == """Clean method clean_flield of class MyForm won't apply to any field. Available fields:\n\n field""" # noinspection PyStatementEffect @@ -62,4 +62,4 @@ def clean_flield(self): with pytest.raises(InvalidCleanMethod) as e: MyForm().errors - assert str(e.value) == """Clean method clean_field of class MyForm won't apply to any field. Available fields:\n\n field""" + assert str(e.value) == """Clean method clean_flield of class MyForm won't apply to any field. Available fields:\n\n field""" From 6ebf020b876e0627ddf34654dc26cb590ff2437f Mon Sep 17 00:00:00 2001 From: Naggafin Date: Tue, 24 Dec 2024 13:43:41 -0500 Subject: [PATCH 11/11] added a fastdev_ignore decorator to allow force skipping fastdev checks (only applies to form checking right now) --- django_fastdev/__init__.py | 2 ++ django_fastdev/apps.py | 10 ++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/django_fastdev/__init__.py b/django_fastdev/__init__.py index f9fd48a..54a963c 100644 --- a/django_fastdev/__init__.py +++ b/django_fastdev/__init__.py @@ -8,6 +8,8 @@ from django.core.management.commands.runserver import Command +from .apps import fastdev_ignore # noqa + orig_check = Command.check orig_check_migrations = Command.check_migrations diff --git a/django_fastdev/apps.py b/django_fastdev/apps.py index a6fe864..df5210b 100644 --- a/django_fastdev/apps.py +++ b/django_fastdev/apps.py @@ -250,12 +250,10 @@ def is_from_project(cls): ) and not module_path.startswith(venv_dir) -def fastdev_ignore(): +def fastdev_ignore(target): """A decorator to exclude a function or class from fastdev checks.""" - def decorator(target): - setattr(target, "fastdev_ignore", True) - return target - return decorator + setattr(target, "fastdev_ignore", True) + return target def get_venv_folder_name(): @@ -411,7 +409,7 @@ def if_render_override(self, context): def fastdev_full_clean(self): orig_form_full_clean(self) # check if class is from our project, or strict form checking is enabled - if is_from_project(type(self)) or strict_form_checking(): + if is_from_project(type(self)) or strict_form_checking() and not getattr(self, 'fastdev_ignore', False): from django.conf import settings if settings.DEBUG: prefix = 'clean_'