diff --git a/anfema_django_testutils/contrib/__init__.py b/anfema_django_testutils/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/anfema_django_testutils/contrib/fixtures/__init__.py b/anfema_django_testutils/contrib/fixtures/__init__.py new file mode 100644 index 0000000..93e9f43 --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/__init__.py @@ -0,0 +1,36 @@ +"""The :mod:`anfema_django_testutils.contrib.fixtures` app provides to find fixture and fixture media files from each +of your applications and any other specified places. + +Settings +======== + +``FIXTURE_FINDERS`` + +``FIXTURE_DIRS`` + +``FIXTURE_MEDIAFILE_FINDERS`` + +``FIXTURE_MEDIAFILE_DIRS`` + +Management Commands +=================== +:mod:`anfema_django_testutils.contrib.fixtures` exposes three management commands. + +findfixture +----------- +*django-admin findfixture FILE [FILE ...]* + +Finds the absolute paths for given fixture file(s). + +findfixturemedia +---------------- +*django-admin findfixturemedia FILE [FILE ...]* + +Finds the absolute paths for given fixture media file(s). + +collectfixturemedia +------------------- +*django-admin collectfixturemedia* + +Collect fixture media files in a single location. +""" diff --git a/anfema_django_testutils/contrib/fixtures/apps.py b/anfema_django_testutils/contrib/fixtures/apps.py new file mode 100644 index 0000000..05dbff5 --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/apps.py @@ -0,0 +1,22 @@ +import os + +from django.apps import AppConfig +from django.core.checks import register +from django.utils.translation import gettext_lazy as _ + +from .checks import check_config + + +class FixturesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "anfema_django_testutils.contrib.fixtures" + label = "fixtures" + verbose_name = _("fixtures") + + fixture_source_dir = "fixtures" + fixture_suffixes = [".json", ".yaml", ".yml", ".xml"] + + fixturemedia_source_dir = os.path.join(fixture_source_dir, "media") + + def ready(self): + register(check_config, self.name) diff --git a/anfema_django_testutils/contrib/fixtures/checks.py b/anfema_django_testutils/contrib/fixtures/checks.py new file mode 100644 index 0000000..c292d6a --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/checks.py @@ -0,0 +1,86 @@ +from pathlib import Path + +from django.core.checks import Error, Warning + +from .settings import get_config + + +def check_config(app_configs=None, **kwargs): + """Check the configuration settings for correctness.""" + from .apps import FixturesConfig + + errors = [] + + app_label = FixturesConfig.label + config = get_config() + + if not isinstance(config["FIXTURE_FINDERS"], (tuple, list)): + errors.append( + Error( + "The FIXTURE_FINDERS setting must be a list or tuple.", + id=f"{app_label}.E001", + obj="Improper Configuration", + ), + ) + if not isinstance(config["FIXTURE_DIRS"], (tuple, list)): + errors.append( + Error( + "The FIXTURE_DIRS setting must be a a list or tuple.", + id=f"{app_label}.E001", + obj="Improper Configuration", + ), + ) + elif not all([isinstance(i, str) for i in config["FIXTURE_DIRS"]]): + errors.append( + Error( + f"At least one item of FIXTURE_DIRS settings is not a string.", + id=f"{app_label}.E001", + obj="Improper Configuration", + ), + ) + else: + for path in filter(lambda p: not Path(p).exists(), config["FIXTURE_DIRS"]): + errors.append( + Warning( + f"The FIXTURE_DIRS settings contains a non-existing path: {path!r}.", + id=f"{app_label}.W001", + obj="Improper Configuration", + ), + ) + + if not isinstance(config["FIXTURE_MEDIAFILE_FINDERS"], (tuple, list)): + errors.append( + Error( + "The FIXTURE_MEDIAFILE_FINDERS setting must be a list or tuple.", + id=f"{app_label}.E001", + obj="Improper Configuration", + ), + ) + + if not isinstance(config["FIXTURE_MEDIAFILE_DIRS"], (tuple, list)): + errors.append( + Error( + "The FIXTURE_MEDIAFILE_DIRS setting must be a a list or tuple.", + id=f"{app_label}.E001", + obj="Improper Configuration", + ), + ) + elif not all([isinstance(i, str) for i in config["FIXTURE_MEDIAFILE_DIRS"]]): + errors.append( + Error( + f"At least one item of FIXTURE_MEDIAFILE_DIRS settings is not a string.", + id=f"{app_label}.E001", + obj="Improper Configuration", + ), + ) + else: + for path in filter(lambda p: not Path(p).exists(), config["FIXTURE_MEDIAFILE_DIRS"]): + errors.append( + Warning( + f"The FIXTURE_MEDIAFILE_DIRS settings contains a non-existing path: {path!r}.", + id=f"{app_label}.W001", + obj="Improper Configuration", + ), + ) + + return errors diff --git a/anfema_django_testutils/contrib/fixtures/finders/__init__.py b/anfema_django_testutils/contrib/fixtures/finders/__init__.py new file mode 100644 index 0000000..4f7d0e9 --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/finders/__init__.py @@ -0,0 +1 @@ +from . import fixture, media diff --git a/anfema_django_testutils/contrib/fixtures/finders/base.py b/anfema_django_testutils/contrib/fixtures/finders/base.py new file mode 100644 index 0000000..c59f8e6 --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/finders/base.py @@ -0,0 +1,59 @@ +from typing import Generator, Union + +from django.contrib.staticfiles.finders import ( + AppDirectoriesFinder, + BaseFinder, + FileSystemFinder, + FileSystemStorage, + get_finder, +) + + +def find_file(finders, path: str, all: bool = False) -> Union[str, list[str], None]: + """Find a fixture media file with the given path using all enabled fixture media files finders. + + :param str path: Path to search for. + :param bool all: Defines whether to return only the first match or search for all matches. + :return: If ``all`` is ``False`` (default), return the first matching absolute path (or ``None`` + if no match). Otherwise return a list. + """ + matches = [] + for finder in get_finders(finders): + result = finder.find(path, all=all) + if not all and result: + return result + matches.extend([result] if not isinstance(result, (list, tuple)) else result) + return matches or ([] if all else None) + + +def get_finders(finders: list[str]) -> Generator[BaseFinder, None, None]: + """Yield enabled finder classes listed in *finders*.""" + yield from map(get_finder, finders) + + +class BaseFileSystemFinder(FileSystemFinder): + search_dirs: list[str] + + def __init__(self, app_names=None, *args, **kwargs): + self.locations = [] + self.storages = {} + for root in self.search_dirs: + if isinstance(root, (list, tuple)): + prefix, root = root + else: + prefix = "" + if (prefix, root) not in self.locations: + self.locations.append((prefix, root)) + for prefix, root in self.locations: + filesystem_storage = FileSystemStorage(location=root) + filesystem_storage.prefix = prefix + self.storages[root] = filesystem_storage + super(BaseFinder, self).__init__(*args, **kwargs) + + def check(self): + raise NotImplementedError + + +class BaseAppDirectoriesFinder(AppDirectoriesFinder): + def check(self): + raise NotImplementedError diff --git a/anfema_django_testutils/contrib/fixtures/finders/fixture.py b/anfema_django_testutils/contrib/fixtures/finders/fixture.py new file mode 100644 index 0000000..6e9f29e --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/finders/fixture.py @@ -0,0 +1,85 @@ +from typing import Generator, Union + +import django.contrib.staticfiles.finders +from django.apps import apps +from django.contrib.staticfiles.finders import BaseFinder +from django.contrib.staticfiles.utils import matches_patterns +from django.core.files.storage import FileSystemStorage + +from ..settings import get_config +from .base import BaseAppDirectoriesFinder, BaseFileSystemFinder + + +searched_locations = django.contrib.staticfiles.finders.searched_locations + + +class FileSystemFinder(BaseFileSystemFinder): + """A fixture files finder that that uses the ``FIXTURES_DIRS`` setting to locate fixture files.""" + + search_dirs = get_config()["FIXTURE_DIRS"] + + def list( + self, fixture_suffixes: list[str] = None, ignore_patterns: list[str] = None + ) -> Generator[tuple[str, FileSystemStorage], None, None]: + """Yields all fixture files found in the folder defined by the ``FIXTURES_DIRS`` setting. + + :param list fixture_suffixes: A list of file suffixes to match for fixture files. + :param list ignore_patterns: A list of patterns to ignore when searching for fixtures. + """ + patterns = [f"*{suffix}" for suffix in (fixture_suffixes or apps.get_app_config("fixtures").fixture_suffixes)] + yield from filter(lambda p: matches_patterns(p[0], patterns), super().list(ignore_patterns)) + + +class AppDirectoriesFinder(BaseAppDirectoriesFinder): + """A fixture files finder that looks in the :file:`fixtures` folder of each app.""" + + def __init__(self, app_names=None, *args, **kwargs): + self.source_dir = apps.get_app_config("fixtures").fixture_source_dir + super().__init__(app_names=None, *args, **kwargs) + + def list( + self, fixture_suffixes: list[str] = None, ignore_patterns: list[str] = None + ) -> Generator[tuple[str, FileSystemStorage], None, None]: + """Yields all fixture files found in the :file:`fixtures` folder of each app. + + :param list fixture_suffixes: A list of file suffixes to match for fixture files. + :param list ignore_patterns: A list of patterns to ignore when searching for fixtures. + """ + + patterns = [f"*{suffix}" for suffix in (fixture_suffixes or apps.get_app_config("fixtures").fixture_suffixes)] + yield from filter(lambda p: matches_patterns(p[0], patterns), super().list(ignore_patterns)) + + +def find(path: str, all: bool = False) -> Union[str, list[str], None]: + """Find a fixture file with the given path using all enabled fixture files finders. + + :param str path: Path to search for. + :param bool all: Defines whether to return only the first match or search for all matches. + :return: If ``all`` is ``False`` (default), return the first matching absolute path (or ``None`` + if no match). Otherwise return a list. + """ + searched_locations[:] = [] + matches = [] + for finder in get_finders(): + result = finder.find(path, all=all) + + if not all and result: + return result + if not isinstance(result, (list, tuple)): + result = [result] + matches.extend(result) + return matches or ([] if all else None) + + +def get_finders() -> Generator[BaseFinder, None, None]: + """Yield enabled fixture files finder classes.""" + finders = get_config()["FIXTURE_FINDERS"] + yield from map(get_finder, finders) + + +def get_finder(import_path) -> BaseFinder: + """Import a given fixture files finder class. + + :param str import_path: The full Python path to the class to be imported + """ + return django.contrib.staticfiles.finders.get_finder(import_path) diff --git a/anfema_django_testutils/contrib/fixtures/finders/media.py b/anfema_django_testutils/contrib/fixtures/finders/media.py new file mode 100644 index 0000000..72f393e --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/finders/media.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Union + +from django.apps import apps + +from ..settings import get_config +from .base import BaseAppDirectoriesFinder, BaseFileSystemFinder, find_file + + +class FileSystemFinder(BaseFileSystemFinder): + """A media files finder that uses the ``FIXTURE_MEDIAFILE_DIRS`` setting to locate files.""" + + search_dirs = get_config()["FIXTURE_MEDIAFILE_DIRS"] + + +class AppDirectoriesFinder(BaseAppDirectoriesFinder): + """A media files finder that looks in the :file:`fixtures/media` folder of each app.""" + + def __init__(self, app_names=None, *args, **kwargs): + self.source_dir = apps.get_app_config("fixtures").fixturemedia_source_dir + super().__init__(app_names=None, *args, **kwargs) + + +def find(path: str, all: bool = False) -> Union[str, list[str], None]: + """Find a fixture media file with the given path using all enabled fixture media files finders. + + :param str path: Path to search for. + :param bool all: Defines whether to return only the first match or search for all matches. + :return: If ``all`` is ``False`` (default), return the first matching absolute path (or ``None`` + if no match). Otherwise return a list. + """ + return find_file(get_config()["FIXTURE_MEDIAFILE_FINDERS"], path, all) diff --git a/anfema_django_testutils/contrib/fixtures/management/__init__.py b/anfema_django_testutils/contrib/fixtures/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/anfema_django_testutils/contrib/fixtures/management/commands/__init__.py b/anfema_django_testutils/contrib/fixtures/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/anfema_django_testutils/contrib/fixtures/management/commands/collectfixturemedia.py b/anfema_django_testutils/contrib/fixtures/management/commands/collectfixturemedia.py new file mode 100644 index 0000000..4375db8 --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/management/commands/collectfixturemedia.py @@ -0,0 +1,38 @@ +from django.core.files.storage import FileSystemStorage +from django.core.management.base import BaseCommand + +from anfema_django_testutils.contrib.fixtures.finders.base import get_finders +from anfema_django_testutils.contrib.fixtures.settings import get_config + + +class Command(BaseCommand): + """Copies fixture media files from different locations to the settings.MEDIA_ROOT.""" + + help = "Collect fixture media files in a single location." + verbosity: int + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.media_storage = FileSystemStorage() + self.ignore_patterns = [] + + def log(self, msg: str, *, level: int = 1, style=None) -> None: + """Small log helper""" + if self.verbosity >= level: + self.stdout.write((style or str)(msg)) + + def set_options(self, **options) -> None: + self.verbosity = options["verbosity"] + + def handle(self, **options): + self.set_options(**options) + + finders = get_config()["FIXTURE_MEDIAFILE_FINDERS"] + for finder_class in get_finders(finders): + for path, storage in finder_class.list(self.ignore_patterns): + source_path = storage.path(path) + + self.media_storage.delete(path) + with storage.open(source_path) as fp: + self.log(f"Copy {source_path} -> {self.media_storage.path(path)}") + self.media_storage.save(path, fp) diff --git a/anfema_django_testutils/contrib/fixtures/management/commands/findfixture.py b/anfema_django_testutils/contrib/fixtures/management/commands/findfixture.py new file mode 100644 index 0000000..a173543 --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/management/commands/findfixture.py @@ -0,0 +1,45 @@ +from pathlib import Path + +from django.core.management.base import LabelCommand + +from anfema_django_testutils.contrib.fixtures import finders + + +class Command(LabelCommand): + help = "Finds the absolute paths for the given fixture file(s)." + label = "FILE" + + def add_arguments(self, parser) -> None: + super().add_arguments(parser) + parser.add_argument( + "--first", + action="store_false", + dest="all", + help="Only return the first match for each fixture file.", + ) + + def log(self, msg: str, *, level: int = 1, style=None) -> None: + """Small log helper""" + if self.verbosity >= level: + self.stdout.write((style or str)(msg)) + + def set_options(self, **options) -> None: + self.verbosity: int = options["verbosity"] + self.all: bool = options["all"] + + def handle(self, *labels, **options): + self.set_options(**options) + return super().handle(*labels, **options) + + def handle_label(self, path, **options): + result = finders.fixture.find(path, all=self.all) + + if result: + if not isinstance(result, (list, tuple)): + result = [result] + self.log(f"Found {path!r} here:", level=1) + for found_path in map(Path, result): + self.log(f" {found_path.resolve()}", level=1) + else: + self.log(f"No matching file found for {path!r}", level=1, style=self.style.ERROR) + self.log("") diff --git a/anfema_django_testutils/contrib/fixtures/management/commands/findfixturemedia.py b/anfema_django_testutils/contrib/fixtures/management/commands/findfixturemedia.py new file mode 100644 index 0000000..235434c --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/management/commands/findfixturemedia.py @@ -0,0 +1,45 @@ +from pathlib import Path + +from django.core.management.base import LabelCommand + +from anfema_django_testutils.contrib.fixtures import finders + + +class Command(LabelCommand): + help = "Finds the absolute paths for the given media file(s)." + label = "FILE" + + def add_arguments(self, parser) -> None: + super().add_arguments(parser) + parser.add_argument( + "--first", + action="store_false", + dest="all", + help="Only return the first match for each fixture media file.", + ) + + def log(self, msg: str, *, level: int = 2, style=None) -> None: + """Small log helper""" + if self.verbosity >= level: + self.stdout.write((style or str)(msg)) + + def set_options(self, **options) -> None: + self.verbosity: int = options["verbosity"] + self.all: bool = options["all"] + + def handle(self, *labels, **options): + self.set_options(**options) + return super().handle(*labels, **options) + + def handle_label(self, path, **options): + result = finders.media.find(path, all=self.all) + + if result: + if not isinstance(result, (list, tuple)): + result = [result] + self.log(f"Found {path!r} here:", level=1) + for found_path in map(Path, result): + self.log(f" {found_path.resolve()}", level=1) + else: + self.log(f"No matching file found for {path!r}", level=1, style=self.style.ERROR) + self.log("") diff --git a/anfema_django_testutils/contrib/fixtures/settings.py b/anfema_django_testutils/contrib/fixtures/settings.py new file mode 100644 index 0000000..707d131 --- /dev/null +++ b/anfema_django_testutils/contrib/fixtures/settings.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import TYPE_CHECKING + +from django.conf import settings +from django.dispatch import receiver +from django.test.signals import setting_changed + + +if TYPE_CHECKING: + from typing import Any + + +CONFIG_DEFAULTS = { + "FIXTURE_FINDERS": ["anfema_django_testutils.contrib.fixtures.finders.fixture.AppDirectoriesFinder"], + "FIXTURE_DIRS": [], + "FIXTURE_MEDIAFILE_FINDERS": ["anfema_django_testutils.contrib.fixtures.finders.media.AppDirectoriesFinder"], + "FIXTURE_MEDIAFILE_DIRS": [], +} + + +@lru_cache() +def get_config() -> dict[str, Any]: + """Returns the current configuration""" + return {conf: getattr(settings, conf, default) for conf, default in CONFIG_DEFAULTS.items()} + + +@receiver(setting_changed) +def update_testrunner_config(*, setting, **kwargs) -> None: + """Refresh configuration when overriding settings.""" + if setting in CONFIG_DEFAULTS: + get_config.cache_clear() diff --git a/docs/source/contrib.rst b/docs/source/contrib.rst new file mode 100644 index 0000000..8aec93b --- /dev/null +++ b/docs/source/contrib.rst @@ -0,0 +1,8 @@ +Contrib +~~~~~~~ + +anfema_django_testutils.contrib.fixtures +---------------------------------------- + +.. automodule:: anfema_django_testutils.contrib.fixtures + :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index d81bed4..908e447 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -2,6 +2,7 @@ :hidden: configuration + contrib api license