Skip to content

Commit 6089426

Browse files
GitRonChris Teichmeister
authored and
Chris Teichmeister
committed
v6.12.0
1 parent 8eb6c12 commit 6089426

File tree

22 files changed

+411
-6
lines changed

22 files changed

+411
-6
lines changed

CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
* **6.12.0** (2023-03-16)
4+
* Added `validate_test_structure` management command for validating project test structure
5+
* Fixed syntax error in docs
6+
* Fixed typo in docstring of `concat` method
7+
* Improved code in test app
8+
39
* **6.11.0** (2023-02-19)
410
* Added `HtmxResponseMixin` for Django views
511
* Added missing `object` class attribute to `ToggleView`

ai_django_core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Ambient toolbox - Lots of helper functions and useful widgets"""
22

3-
__version__ = '6.11.0'
3+
__version__ = '6.12.0'

ai_django_core/apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.apps import AppConfig
2+
from django.utils.translation import gettext_lazy as _
3+
4+
5+
class AmbientToolboxConfig(AppConfig):
6+
name = 'ai_django_core'
7+
verbose_name = _('Ambient Toolbox')

ai_django_core/management/__init__.py

Whitespace-only changes.

ai_django_core/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.core.management.base import BaseCommand
2+
3+
from ai_django_core.tests.structure_validator.test_structure_validator import TestStructureValidator
4+
5+
6+
class Command(BaseCommand):
7+
def handle(self, *args, **options):
8+
service = TestStructureValidator()
9+
service.process()

ai_django_core/templatetags/ai_string_tags.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def get_first_char(value):
1717
@register.filter(name='concat')
1818
def concat(obj, str):
1919
"""
20-
Concats the the two given strings
20+
Concats the two given strings
2121
2222
:param obj:
2323
:param str:

ai_django_core/tests/structure_validator/__init__.py

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.conf import settings
2+
3+
# Test validator
4+
TEST_STRUCTURE_VALIDATOR_FILE_WHITELIST = []
5+
try:
6+
TEST_STRUCTURE_VALIDATOR_BASE_DIR = settings.BASE_DIR
7+
except AttributeError:
8+
TEST_STRUCTURE_VALIDATOR_BASE_DIR = ''
9+
TEST_STRUCTURE_VALIDATOR_BASE_APP_NAME = 'apps'
10+
TEST_STRUCTURE_VALIDATOR_APP_LIST = settings.INSTALLED_APPS
11+
TEST_STRUCTURE_VALIDATOR_IGNORED_DIRECTORY_LIST = []
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import os
2+
import sys
3+
from pathlib import Path
4+
from typing import Union
5+
6+
from django.conf import settings
7+
8+
from ai_django_core.tests.structure_validator import settings as toolbox_settings
9+
10+
11+
class TestStructureValidator:
12+
file_whitelist: list
13+
issue_list: list
14+
15+
def __init__(self):
16+
self.file_whitelist = self._get_file_whitelist()
17+
self.issue_list = []
18+
19+
@staticmethod
20+
def _get_file_whitelist() -> list:
21+
default_whitelist = ['__init__']
22+
try:
23+
return default_whitelist + settings.TEST_STRUCTURE_VALIDATOR_FILE_WHITELIST
24+
except AttributeError:
25+
return default_whitelist + toolbox_settings.TEST_STRUCTURE_VALIDATOR_FILE_WHITELIST
26+
27+
@staticmethod
28+
def _get_base_dir() -> Union[Path, str]:
29+
try:
30+
return settings.TEST_STRUCTURE_VALIDATOR_BASE_DIR
31+
except AttributeError:
32+
return toolbox_settings.TEST_STRUCTURE_VALIDATOR_BASE_DIR
33+
34+
@staticmethod
35+
def _get_base_app_name() -> str:
36+
try:
37+
return settings.TEST_STRUCTURE_VALIDATOR_BASE_APP_NAME
38+
except AttributeError:
39+
return toolbox_settings.TEST_STRUCTURE_VALIDATOR_BASE_APP_NAME
40+
41+
@staticmethod
42+
def _get_ignored_directory_list() -> list:
43+
default_dir_list = ['__pycache__']
44+
try:
45+
return default_dir_list + settings.TEST_STRUCTURE_VALIDATOR_IGNORED_DIRECTORY_LIST
46+
except AttributeError:
47+
return default_dir_list + toolbox_settings.TEST_STRUCTURE_VALIDATOR_IGNORED_DIRECTORY_LIST
48+
49+
@staticmethod
50+
def _get_app_list() -> Union[list, tuple]:
51+
try:
52+
return settings.TEST_STRUCTURE_VALIDATOR_APP_LIST
53+
except AttributeError:
54+
return toolbox_settings.TEST_STRUCTURE_VALIDATOR_APP_LIST
55+
56+
def _check_missing_test_prefix(self, *, root: str, file: str, filename: str, extension: str) -> bool:
57+
if extension == '.py' and not filename[0:5] == "test_" and filename not in self.file_whitelist:
58+
file_path = f"{root}\\{file}".replace('\\', '/')
59+
self.issue_list.append(f'Python file without "test_" prefix found: {file_path!r}.')
60+
return False
61+
return True
62+
63+
def _check_missing_init(self, *, root: str, init_found: bool, number_of_py_files: int) -> bool:
64+
if not init_found and number_of_py_files > 0:
65+
path = root.replace('\\', '/')
66+
self.issue_list.append(f"__init__.py missing in {path!r}.")
67+
return False
68+
return True
69+
70+
def _build_path_to_test_package(self, app: str) -> Path:
71+
return self._get_base_dir() / Path(app.replace('.', '/')) / 'tests'
72+
73+
def process(self) -> None:
74+
backend_package = self._get_base_app_name()
75+
app_list = self._get_app_list()
76+
77+
for app in app_list:
78+
if not app.startswith(backend_package):
79+
continue
80+
app_path = self._build_path_to_test_package(app=app)
81+
for root, dirs, files in os.walk(app_path):
82+
cleaned_root = root.replace('\\', '/')
83+
print(f"Inspecting {cleaned_root!r}...")
84+
init_found = False
85+
number_of_py_files = 0
86+
87+
for excluded_dir in self._get_ignored_directory_list():
88+
try:
89+
dirs.remove(excluded_dir)
90+
except ValueError:
91+
pass
92+
93+
for file in files:
94+
filename = file[:-3]
95+
extension = file[-3:]
96+
97+
if filename == "__init__":
98+
init_found = True
99+
100+
if extension == ".py":
101+
number_of_py_files += 1
102+
103+
# Check for missing test prefix
104+
self._check_missing_test_prefix(root=root, file=file, filename=filename, extension=extension)
105+
106+
# Check for missing init file
107+
self._check_missing_init(root=root, init_found=init_found, number_of_py_files=number_of_py_files)
108+
109+
number_of_issues = len(self.issue_list)
110+
111+
if number_of_issues:
112+
print("=======================")
113+
print("Errors found:")
114+
115+
for issue in self.issue_list:
116+
print(f"- {issue}")
117+
118+
print("=======================")
119+
120+
if number_of_issues:
121+
print(f'Checking test structure failed with {number_of_issues} issue(s).')
122+
sys.exit(1)
123+
else:
124+
print("0 issues detected. Yeah!")

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
# -- Project information -----------------------------------------------------
3838

3939
project = 'ai-django-core'
40-
copyright = '2022, Ambient Innovation: GmbH'
40+
copyright = '2022, Ambient Innovation: GmbH' # noqa
4141
author = 'Ambient Innovation: GmbH <[email protected]>'
4242
version = __version__
4343
release = __version__

docs/features/tests.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,11 +234,37 @@ class MyViewTest(DjangoMessagingFrameworkTestMixin, TestCase):
234234
# The view creates a message: "It's sunny on Sundays."
235235
view = function_to_instantiate_your_view()
236236
self.assert_full_message_in_request(
237-
view.request, 'It's sunny on Sundays.')
237+
view.request, "It's sunny on Sundays.")
238238

239239
def test_my_message_partial_case(self):
240240
# The view creates a message: "I have added *n* new records" with "n" being a variable
241241
view = function_to_instantiate_your_view()
242242
self.assert_partial_message_in_request(
243243
view.request, 'I have added')
244244
````
245+
246+
## Test structure validator
247+
248+
### Motivation
249+
250+
When working in a Django project, it can happen very easily that you create unit-tests in a way that they won't be
251+
auto-discovered. The mean thing about this is that you can still run those tests - so it's hard to find those issues.
252+
253+
The most common mistakes are forgetting the `__init__.py` in the directory or not prefixing your python files
254+
with `test_`. To tackle this problem, we created a handy management command you can run manually or integrate in your
255+
CI pipeline.
256+
257+
python manage.py validate_test_structure
258+
259+
260+
### Configuration
261+
262+
You can define all of those settings variables in your main Django settings file.
263+
264+
| Variable | Type | Default | Explanation |
265+
|-------------------------------------------------|------|-------------------------|---------------------------------------------------------------------|
266+
| TEST_STRUCTURE_VALIDATOR_FILE_WHITELIST | list | [] | Filenames which will be ignored, will always ignore `__init__` |
267+
| TEST_STRUCTURE_VALIDATOR_BASE_DIR | Path | settings.BASE_DIR | Root path to your application (BASE_DIR in a vanilla Django setup) |
268+
| TEST_STRUCTURE_VALIDATOR_BASE_APP_NAME | str | "apps" | Directory where all your Django apps live in, can be set to "". |
269+
| TEST_STRUCTURE_VALIDATOR_APP_LIST | list | settings.INSTALLED_APPS | List of all your Django apps you want to validate |
270+
| TEST_STRUCTURE_VALIDATOR_IGNORED_DIRECTORY_LIST | list | [] | Directories which will be ignored, will always ignore `__pycache__` |

testapp/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
class CommonInfoBasedModelTestForm(forms.ModelForm):
77
class Meta:
88
model = CommonInfoBasedModel
9-
exclude = ()
9+
fields = ('value',)

testapp/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ class MySingleSignalModel(models.Model):
1616

1717
objects = GloballyVisibleQuerySet.as_manager()
1818

19+
def __str__(self):
20+
return str(self.value)
21+
1922

2023
class ForeignKeyRelatedModel(models.Model):
2124
single_signal = models.ForeignKey(
@@ -24,6 +27,9 @@ class ForeignKeyRelatedModel(models.Model):
2427

2528
objects = GloballyVisibleQuerySet.as_manager()
2629

30+
def __str__(self):
31+
return str(self.id)
32+
2733

2834
@receiver(pre_save, sender=MySingleSignalModel)
2935
def increase_value_no_dispatch_uid(sender, instance, **kwargs):
@@ -33,6 +39,9 @@ def increase_value_no_dispatch_uid(sender, instance, **kwargs):
3339
class MyMultipleSignalModel(models.Model):
3440
value = models.PositiveIntegerField(default=0)
3541

42+
def __str__(self):
43+
return str(self.value)
44+
3645

3746
@receiver(pre_save, sender=MyMultipleSignalModel, dispatch_uid='test.mysinglesignalmodel.increase_value_with_uuid')
3847
def increase_value_with_dispatch_uid(sender, instance, **kwargs):
@@ -50,34 +59,55 @@ def send_email(sender, instance, **kwargs):
5059
class CommonInfoBasedModel(CommonInfo):
5160
value = models.PositiveIntegerField(default=0)
5261

62+
def __str__(self):
63+
return str(self.value)
64+
5365

5466
class ModelWithSelector(models.Model):
5567
value = models.PositiveIntegerField(default=0)
5668

5769
objects = ModelWithSelectorQuerySet.as_manager()
5870
selectors = ModelWithSelectorGloballyVisibleSelector()
5971

72+
def __str__(self):
73+
return str(self.value)
74+
6075

6176
class ModelWithFkToSelf(models.Model):
6277
parent = models.ForeignKey('self', blank=True, null=True, related_name='children', on_delete=models.CASCADE)
6378

79+
def __str__(self):
80+
return str(self.id)
81+
6482

6583
class ModelWithOneToOneToSelf(models.Model):
6684
peer = models.OneToOneField('self', blank=True, null=True, related_name='related_peer', on_delete=models.CASCADE)
6785

86+
def __str__(self):
87+
return str(self.id)
88+
6889

6990
class ModelWithCleanMixin(CleanOnSaveMixin, models.Model):
7091
def clean(self):
7192
return True
7293

94+
def __str__(self):
95+
return str(self.id)
96+
7397

7498
class MyPermissionModelMixin(PermissionModelMixin, models.Model):
7599
pass
76100

101+
def __str__(self):
102+
return str(self.id)
103+
77104

78105
class ModelWithSaveWithoutSignalsMixin(SaveWithoutSignalsMixin, models.Model):
79106
value = models.PositiveIntegerField(default=0)
80107

108+
def __str__(self):
109+
return str(self.value)
110+
81111

82112
@receiver(pre_save, sender=ModelWithSaveWithoutSignalsMixin)
83113
def increase_value_on_pre_save(sender, instance, **kwargs):

testapp/tests/__init__.py

Whitespace-only changes.

testapp/tests/missing_init/test_ok.py

Whitespace-only changes.

testapp/tests/subdirectory/__init__.py

Whitespace-only changes.

testapp/tests/subdirectory/missing_test_prefix.py

Whitespace-only changes.

testapp/tests/subdirectory/test_ok.py

Whitespace-only changes.

tests/admin/model_admin_mixins/test_admin_common_info_mixin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
class CommonInfoBasedModelForm(forms.ModelForm):
1414
class Meta:
1515
model = CommonInfoBasedModel
16-
exclude = ()
16+
fields = ('value',)
1717

1818

1919
class TestCommonInfoAdminMixinAdmin(CommonInfoAdminMixin, admin.ModelAdmin):

tests/ambient_toolbox/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)