diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fd1b248c..d436b840 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,4 +3,4 @@ A clear and concise description of what the change is. **PR Checklist** - [ ] Change is covered with tests -- [ ] [CHANGELOG.md](CHANGELOG.md) is updated +- [ ] [CHANGELOG.md](CHANGELOG.md) is updated if needed diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml deleted file mode 100644 index 076e8afb..00000000 --- a/.github/workflows/changelog.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Changelog Reminder - -on: pull_request - -jobs: - remind: - runs-on: ubuntu-22.04 - if: | - !contains(github.event.pull_request.body, '[skip changelog]') && - (github.actor != 'dependabot[bot]') - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: ${{ github.event.pull_request.commits }} + 1 - - name: Check that CHANGELOG is updated - run: git diff origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00c1ffd0..f2f9109d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,14 +16,14 @@ jobs: strategy: matrix: lint-command: - - ruff --output-format=github . + - ruff check --output-format=github . - black --check --diff . - mypy model_bakery steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" cache: pip - run: python -m pip install .[test] - run: ${{ matrix.lint-command }} @@ -65,7 +65,7 @@ jobs: psql template1 -c "CREATE EXTENSION postgis;" -U postgres -h localhost -p 5432 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -79,10 +79,11 @@ jobs: run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage-data + name: coverage-data-${{ matrix.python-version }} path: '.coverage.*' + if-no-files-found: ignore coverage: name: Coverage @@ -91,17 +92,18 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Install dependencies run: python -m pip install --upgrade coverage[toml] - name: Download data - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: coverage-data + pattern: coverage-data-* + merge-multiple: true - name: Combine coverage and fail if it's <95% run: | @@ -111,7 +113,7 @@ jobs: - name: Upload HTML report if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: html-report path: htmlcov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06cc00b4..e0d80a12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,9 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" cache: pip - name: Build package diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d13ffbc..c9862b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,37 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed +## [1.19.0](https://pypi.org/project/model-bakery/1.19.0/) + +### Added +- Add Django 5.1 support + +## [1.18.3](https://pypi.org/project/model-bakery/1.18.3/) + +### Changed +- Fix support of `GenericForeignKey` fields in combination with `_fill_optional` + +## [1.18.2](https://pypi.org/project/model-bakery/1.18.2/) + +### Changed +- Fix `make_recipe` to work with `_quantity` (#28) + +## [1.18.1](https://pypi.org/project/model-bakery/1.18.1/) + +### Changed +- Replace expensive `count()` with cheap `exists()` + +## [1.18.0](https://pypi.org/project/model-bakery/1.18.0/) + +### Added +- Add Django 5.0 support + +### Changed +- Allow baking without `contenttypes` framework + +### Removed +- Drop Django 3.2 and 4.1 support (reached end of life) + ## [1.17.0](https://pypi.org/project/model-bakery/1.17.0/) ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2d93332..487416e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ To run `postgresql` and `postgis` specific tests: 1. [Install `docker`](https://docs.docker.com/get-docker/). 2. Install the `postgis` dependencies. Follow the -[instructions from the Django docs](https://docs.djangoproject.com/en/3.1/ref/contrib/gis/install/geolibs/): +[instructions from the Django docs](https://docs.djangoproject.com/en/stable/ref/contrib/gis/install/geolibs/): If you are on Ubuntu/Debian you run the following: diff --git a/docs/conf.py b/docs/conf.py index 9150181b..7b185bc8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,9 +1,9 @@ import os import sys -sys.path.insert(0, os.path.abspath("..")) +from model_bakery import __about__ -from model_bakery import __about__ # noqa +sys.path.insert(0, os.path.abspath("..")) project = "Model Bakery" copyright = "2023, Rust Saiargaliev" diff --git a/docs/how_bakery_behaves.md b/docs/how_bakery_behaves.md index fc7fc5aa..3bfbd355 100644 --- a/docs/how_bakery_behaves.md +++ b/docs/how_bakery_behaves.md @@ -37,7 +37,7 @@ Model Bakery should handle fields that: ## Currently supported fields -- `BooleanField`, `NullBooleanField`, `IntegerField`, `BigIntegerField`, `SmallIntegerField`, `PositiveIntegerField`, `PositiveSmallIntegerField`, `FloatField`, `DecimalField` +- `BooleanField`, `IntegerField`, `BigIntegerField`, `SmallIntegerField`, `PositiveIntegerField`, `PositiveBigIntegerField`, `PositiveSmallIntegerField`, `FloatField`, `DecimalField` - `CharField`, `TextField`, `BinaryField`, `SlugField`, `URLField`, `EmailField`, `IPAddressField`, `GenericIPAddressField`, `ContentType` - `ForeignKey`, `OneToOneField`, `ManyToManyField` (even with through model) - `DateField`, `DateTimeField`, `TimeField`, `DurationField` @@ -93,7 +93,7 @@ class CustomBaker(baker.Baker): return [ field for field in super(CustomBaker, self).get_fields() - if not field isinstance CustomField + if not isinstance(field, CustomField) ] # in your settings.py file: @@ -111,7 +111,7 @@ movie = baker.make(Movie, title='Old Boys', _from_manager='availables') # This If you have overwritten the `save` method for a model, you can pass custom parameters to it using Model Bakery. Example: ```python -class ProjectWithCustomSave(models.Model) +class ProjectWithCustomSave(models.Model): # some model fields created_by = models.ForeignKey(settings.AUTH_USER_MODEL) diff --git a/docs/index.md b/docs/index.md index 3312422c..9ef66e90 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ Model Bakery is a rename of the legacy [model_mommy\'s project](https://pypi.org # Compatibility -Model Bakery supports Django \>= 3.2. +Model Bakery supports Django \>= 4.2. # Install diff --git a/model_bakery/__about__.py b/model_bakery/__about__.py index 30244104..d84d79d4 100644 --- a/model_bakery/__about__.py +++ b/model_bakery/__about__.py @@ -1 +1 @@ -__version__ = "1.17.0" +__version__ = "1.19.0" diff --git a/model_bakery/baker.py b/model_bakery/baker.py index 4fee1a06..7dd24da7 100644 --- a/model_bakery/baker.py +++ b/model_bakery/baker.py @@ -15,10 +15,8 @@ overload, ) -from django import VERSION as DJANGO_VERSION from django.apps import apps from django.conf import settings -from django.contrib import contenttypes from django.db.models import ( AutoField, BooleanField, @@ -37,6 +35,7 @@ from . import generators, random_gen from ._types import M, NewM +from .content_types import BAKER_CONTENTTYPES from .exceptions import ( AmbiguousModelName, CustomBakerNotFound, @@ -50,6 +49,13 @@ seq, # noqa: F401 - Enable seq to be imported from recipes ) +if BAKER_CONTENTTYPES: + from django.contrib.contenttypes import models as contenttypes_models + from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +else: + contenttypes_models = None + GenericRelation = None + recipes = None # FIXME: use pkg_resource @@ -78,8 +84,7 @@ def make( _using: str = "", _bulk_create: bool = False, **attrs: Any, -) -> M: - ... +) -> M: ... @overload @@ -94,8 +99,7 @@ def make( _bulk_create: bool = False, _fill_optional: Union[List[str], bool] = False, **attrs: Any, -) -> List[M]: - ... +) -> List[M]: ... def make( @@ -147,8 +151,7 @@ def prepare( _save_related: bool = False, _using: str = "", **attrs, -) -> M: - ... +) -> M: ... @overload @@ -159,8 +162,7 @@ def prepare( _using: str = "", _fill_optional: Union[List[str], bool] = False, **attrs, -) -> List[M]: - ... +) -> List[M]: ... def prepare( @@ -499,6 +501,7 @@ def instance( ) -> M: one_to_many_keys = {} auto_now_keys = {} + generic_foreign_keys = {} for k in tuple(attrs.keys()): field = getattr(self.model, k, None) @@ -515,12 +518,15 @@ def instance( ): auto_now_keys[k] = attrs[k] + if BAKER_CONTENTTYPES and isinstance(field, GenericForeignKey): + generic_foreign_keys[k] = attrs.pop(k) + instance = self.model(**attrs) - # m2m only works for persisted instances if _commit: instance.save(**_save_kwargs) self._handle_one_to_many(instance, one_to_many_keys) self._handle_m2m(instance) + self._handle_generic_foreign_keys(instance, generic_foreign_keys) self._handle_auto_now(instance, auto_now_keys) if _from_manager: @@ -569,9 +575,7 @@ def is_rel_field(x: str): self.rel_attrs = {k: v for k, v in attrs.items() if is_rel_field(k)} self.rel_fields = [x.split("__")[0] for x in self.rel_attrs if is_rel_field(x)] - def _skip_field(self, field: Field) -> bool: - from django.contrib.contenttypes.fields import GenericRelation - + def _skip_field(self, field: Field) -> bool: # noqa: C901 # check for fill optional argument if isinstance(self.fill_in_optional, bool): field.fill_optional = self.fill_in_optional @@ -593,7 +597,15 @@ def _skip_field(self, field: Field) -> bool: if isinstance(field, OneToOneField) and self._remote_field(field).parent_link: return True - if isinstance(field, (AutoField, GenericRelation, OrderWrt)): + other_fields_to_skip = [ + AutoField, + OrderWrt, + ] + + if BAKER_CONTENTTYPES: + other_fields_to_skip.append(GenericRelation) + + if isinstance(field, tuple(other_fields_to_skip)): return True if all( # noqa: SIM102 @@ -614,7 +626,7 @@ def _skip_field(self, field: Field) -> bool: if field.name not in self.model_attrs: # noqa: SIM102 if field.name not in self.rel_fields and ( - field.null and not field.fill_optional + not field.fill_optional and field.null ): return True @@ -630,6 +642,9 @@ def _handle_one_to_many(self, instance: Model, attrs: Dict[str, Any]): for key, values in attrs.items(): manager = getattr(instance, key) + if callable(values): + values = values() + for value in values: # Django will handle any operation to persist nested non-persisted FK because # save doesn't do so and, thus, raises constraint errors. That's why save() @@ -651,6 +666,9 @@ def _handle_one_to_many(self, instance: Model, attrs: Dict[str, Any]): def _handle_m2m(self, instance: Model): for key, values in self.m2m_dict.items(): + if callable(values): + values = values() + for value in values: if not value.pk: value.save() @@ -668,6 +686,10 @@ def _handle_m2m(self, instance: Model): } make(through_model, _using=self._using, **base_kwargs) + def _handle_generic_foreign_keys(self, instance: Model, attrs: Dict[str, Any]): + for key, value in attrs.items(): + setattr(instance, key, value) + def _remote_field( self, field: Union[ForeignKey, OneToOneField] ) -> Union[OneToOneRel, ManyToOneRel]: @@ -687,11 +709,17 @@ def generate_value(self, field: Field, commit: bool = True) -> Any: # noqa: C90 `attr_mapping` and `type_mapping` can be defined easily overwriting the model. """ - is_content_type_fk = isinstance(field, ForeignKey) and issubclass( - self._remote_field(field).model, contenttypes.models.ContentType - ) + is_content_type_fk = False + is_generic_fk = False + if BAKER_CONTENTTYPES: + is_content_type_fk = isinstance(field, ForeignKey) and issubclass( + self._remote_field(field).model, contenttypes_models.ContentType + ) + is_generic_fk = isinstance(field, GenericForeignKey) + if is_generic_fk: + generator = self.type_mapping[GenericForeignKey] # we only use default unless the field is overwritten in `self.rel_fields` - if field.has_default() and field.name not in self.rel_fields: + elif field.has_default() and field.name not in self.rel_fields: if callable(field.default): return field.default() return field.default @@ -700,7 +728,7 @@ def generate_value(self, field: Field, commit: bool = True) -> Any: # noqa: C90 elif field.choices: generator = random_gen.gen_from_choices(field.choices) elif is_content_type_fk: - generator = self.type_mapping[contenttypes.models.ContentType] + generator = self.type_mapping[contenttypes_models.ContentType] elif generators.get(field.__class__): generator = generators.get(field.__class__) elif field.__class__ in self.type_mapping: @@ -818,12 +846,7 @@ def bulk_create(baker: Baker[M], quantity: int, **kwargs) -> List[M]: else: manager = baker.model._base_manager - existing_entries = list(manager.values_list("pk", flat=True)) created_entries = manager.bulk_create(entries) - # bulk_create in Django < 4.0 does not return ids of created objects. - # drop this after 01 Apr 2024 (Django 3.2 LTS end of life) - if DJANGO_VERSION < (4, 0): - created_entries = manager.exclude(pk__in=existing_entries) # set many-to-many relations from kwargs for entry in created_entries: diff --git a/model_bakery/content_types.py b/model_bakery/content_types.py new file mode 100644 index 00000000..aced00be --- /dev/null +++ b/model_bakery/content_types.py @@ -0,0 +1,17 @@ +from django.apps import apps + +BAKER_CONTENTTYPES = apps.is_installed("django.contrib.contenttypes") + +default_contenttypes_mapping = {} + +__all__ = ["BAKER_CONTENTTYPES", "default_contenttypes_mapping"] + +if BAKER_CONTENTTYPES: + from django.contrib.contenttypes.fields import GenericForeignKey + from django.contrib.contenttypes.models import ContentType + + from . import random_gen + + default_contenttypes_mapping[ContentType] = random_gen.gen_content_type + # a small hack to generate random object for GenericForeignKey + default_contenttypes_mapping[GenericForeignKey] = random_gen.gen_content_type diff --git a/model_bakery/generators.py b/model_bakery/generators.py index 43ea5238..d609be0d 100644 --- a/model_bakery/generators.py +++ b/model_bakery/generators.py @@ -3,6 +3,8 @@ from django.db.backends.base.operations import BaseDatabaseOperations from django.db.models import ( + AutoField, + BigAutoField, BigIntegerField, BinaryField, BooleanField, @@ -19,11 +21,14 @@ ImageField, IntegerField, IPAddressField, + JSONField, ManyToManyField, OneToOneField, + PositiveBigIntegerField, PositiveIntegerField, PositiveSmallIntegerField, SlugField, + SmallAutoField, SmallIntegerField, TextField, TimeField, @@ -34,40 +39,12 @@ from . import random_gen from .utils import import_from_str -try: - # Proper support starts with Django 3.0 - # (as it uses `django/db/backends/base/operations.py` for matching ranges) - from django.db.models import AutoField, BigAutoField, SmallAutoField -except ImportError: - AutoField = None - BigAutoField = None - SmallAutoField = None - -try: - # added in Django 3.1 - from django.db.models import PositiveBigIntegerField -except ImportError: - PositiveBigIntegerField = None - -try: - # Replaced `django.contrib.postgres.fields.JSONField` in Django 3.1 - from django.db.models import JSONField -except ImportError: - JSONField = None - try: # PostgreSQL-specific field (only available when psycopg is installed) from django.contrib.postgres.fields import ArrayField except ImportError: ArrayField = None -try: - # Deprecated since Django 3.1, removed in Django 4.0 - # PostgreSQL-specific field (only available when psycopg is installed) - from django.contrib.postgres.fields import JSONField as PostgresJSONField -except ImportError: - PostgresJSONField = None - try: # PostgreSQL-specific field (only available when psycopg is installed) from django.contrib.postgres.fields import HStoreField @@ -86,12 +63,6 @@ CIEmailField = None CITextField = None -try: - # Deprecated since Django 3.1, removed in Django 4.0 - from django.db.models import NullBooleanField -except ImportError: - NullBooleanField = None - try: # PostgreSQL-specific fields (only available when psycopg is installed) @@ -124,9 +95,13 @@ def gen_integer(): OneToOneField: random_gen.gen_related, ManyToManyField: random_gen.gen_m2m, BooleanField: random_gen.gen_boolean, + AutoField: _make_integer_gen_by_range(AutoField), + BigAutoField: _make_integer_gen_by_range(BigAutoField), IntegerField: _make_integer_gen_by_range(IntegerField), + SmallAutoField: _make_integer_gen_by_range(SmallAutoField), BigIntegerField: _make_integer_gen_by_range(BigIntegerField), SmallIntegerField: _make_integer_gen_by_range(SmallIntegerField), + PositiveBigIntegerField: _make_integer_gen_by_range(PositiveBigIntegerField), PositiveIntegerField: _make_integer_gen_by_range(PositiveIntegerField), PositiveSmallIntegerField: _make_integer_gen_by_range(PositiveSmallIntegerField), FloatField: random_gen.gen_float, @@ -146,14 +121,11 @@ def gen_integer(): FileField: random_gen.gen_file_field, ImageField: random_gen.gen_image_field, DurationField: random_gen.gen_interval, + JSONField: random_gen.gen_json, } # type: Dict[Type, Callable] if ArrayField: default_mapping[ArrayField] = random_gen.gen_array -if JSONField: - default_mapping[JSONField] = random_gen.gen_json -if PostgresJSONField: - default_mapping[PostgresJSONField] = random_gen.gen_json if HStoreField: default_mapping[HStoreField] = random_gen.gen_hstore if CICharField: @@ -162,16 +134,6 @@ def gen_integer(): default_mapping[CIEmailField] = random_gen.gen_email if CITextField: default_mapping[CITextField] = random_gen.gen_text -if AutoField: - default_mapping[AutoField] = _make_integer_gen_by_range(AutoField) -if BigAutoField: - default_mapping[BigAutoField] = _make_integer_gen_by_range(BigAutoField) -if SmallAutoField: - default_mapping[SmallAutoField] = _make_integer_gen_by_range(SmallAutoField) -if PositiveBigIntegerField: - default_mapping[PositiveBigIntegerField] = _make_integer_gen_by_range( - PositiveBigIntegerField - ) if DecimalRangeField: default_mapping[DecimalRangeField] = random_gen.gen_pg_numbers_range(Decimal) if IntegerRangeField: @@ -182,22 +144,18 @@ def gen_integer(): default_mapping[DateRangeField] = random_gen.gen_date_range if DateTimeRangeField: default_mapping[DateTimeRangeField] = random_gen.gen_datetime_range -if NullBooleanField: - default_mapping[NullBooleanField] = random_gen.gen_boolean # Add GIS fields def get_type_mapping() -> Dict[Type, Callable]: - from django.contrib.contenttypes.models import ContentType - + from .content_types import default_contenttypes_mapping from .gis import default_gis_mapping mapping = default_mapping.copy() - mapping[ContentType] = random_gen.gen_content_type + mapping.update(default_contenttypes_mapping) mapping.update(default_gis_mapping) - return mapping.copy() diff --git a/model_bakery/random_gen.py b/model_bakery/random_gen.py index ce740623..a54516a6 100644 --- a/model_bakery/random_gen.py +++ b/model_bakery/random_gen.py @@ -27,7 +27,7 @@ # Postgres database. MAX_INT = 100000000000 -baker_random = Random() +baker_random = Random() # noqa: S311 def get_content_file(content: bytes, name: str) -> ContentFile: diff --git a/model_bakery/recipe.py b/model_bakery/recipe.py index 13058b7c..d2f11e7e 100644 --- a/model_bakery/recipe.py +++ b/model_bakery/recipe.py @@ -52,7 +52,7 @@ def _mapping( # noqa: C901 m = finder.get_model(self._model) else: m = self._model - if k not in self._iterator_backups or m.objects.count() == 0: + if k not in self._iterator_backups or not m.objects.exists(): self._iterator_backups[k] = itertools.tee( self._iterator_backups.get(k, [v])[0] ) @@ -78,7 +78,8 @@ def _mapping( # noqa: C901 else: mapping[k] = v.recipe.prepare(_using=_using, **recipe_attrs) elif isinstance(v, related): - mapping[k] = v.make() + mapping[k] = v.make + mapping.update(new_attrs) mapping.update(rel_fields_attrs) return mapping @@ -94,8 +95,7 @@ def make( _bulk_create: bool = False, _save_kwargs: Optional[Dict[str, Any]] = None, **attrs: Any, - ) -> M: - ... + ) -> M: ... @overload def make( @@ -108,8 +108,7 @@ def make( _bulk_create: bool = False, _save_kwargs: Optional[Dict[str, Any]] = None, **attrs: Any, - ) -> List[M]: - ... + ) -> List[M]: ... def make( self, @@ -146,8 +145,7 @@ def prepare( _save_related: bool = False, _using: str = "", **attrs: Any, - ) -> M: - ... + ) -> M: ... @overload def prepare( @@ -156,8 +154,7 @@ def prepare( _save_related: bool = False, _using: str = "", **attrs: Any, - ) -> List[M]: - ... + ) -> List[M]: ... def prepare( self, diff --git a/pyproject.toml b/pyproject.toml index 3d651e22..92de76a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,9 +22,9 @@ keywords = [ classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", @@ -38,7 +38,7 @@ classifiers = [ "Topic :: Software Development", ] dependencies = [ - "django>=3.2", + "django>=4.2", ] [project.optional-dependencies] @@ -95,9 +95,7 @@ disallow_untyped_calls = true [tool.pytest.ini_options] addopts = "--tb=short -rxs --nomigrations" -[tool.ruff] -target-version = "py38" - +[tool.ruff.lint] select = [ "S", # flake8-bandit "B", # flake8-bugbear @@ -114,19 +112,19 @@ select = [ ignore = ["B904", "E501", "S101", "D1", "D212"] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/test_*.py" = [ "S", ] -[tool.ruff.isort] +[tool.ruff.lint.isort] combine-as-imports=true split-on-trailing-comma=true section-order = ["future", "standard-library", "django", "third-party", "first-party", "local-folder"] force-wrap-aliases=true -[tool.ruff.isort.sections] +[tool.ruff.lint.isort.sections] django = ["django"] -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "google" diff --git a/tests/conftest.py b/tests/conftest.py index eb0af4f4..f2870662 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,14 +6,18 @@ def pytest_configure(): test_db = os.environ.get("TEST_DB", "sqlite") + use_contenttypes = os.environ.get("USE_CONTENTTYPES", False) installed_apps = [ - "django.contrib.contenttypes", - "django.contrib.auth", "tests.generic", "tests.ambiguous", "tests.ambiguous2", ] + if use_contenttypes: + installed_apps.append("django.contrib.contenttypes") + # auth app depends on contenttypes + installed_apps.append("django.contrib.auth") + using_postgres_flag = False postgis_version = () if test_db == "sqlite": @@ -76,11 +80,11 @@ def pytest_configure(): POSTGIS_VERSION=postgis_version, ) + django.setup() + from model_bakery import baker def gen_same_text(): return "always the same text" baker.generators.add("tests.generic.fields.CustomFieldViaSettings", gen_same_text) - - django.setup() diff --git a/tests/generic/models.py b/tests/generic/models.py index 5be99ccf..834e20ef 100755 --- a/tests/generic/models.py +++ b/tests/generic/models.py @@ -8,11 +8,10 @@ import django from django.conf import settings -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.files.storage import FileSystemStorage from django.utils.timezone import now +from model_bakery.baker import BAKER_CONTENTTYPES from model_bakery.gis import BAKER_GIS from model_bakery.timezone import tz_aware @@ -37,6 +36,16 @@ else: from django.db import models + +# check if the contenttypes app is installed +if BAKER_CONTENTTYPES: + from django.contrib.contenttypes import models as contenttypes + from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +else: + contenttypes = None + GenericRelation = None + GenericForeignKey = None + GENDER_CHOICES = [ ("M", "male"), ("F", "female"), @@ -87,24 +96,17 @@ class Person(models.Model): occupation = models.CharField(max_length=10, choices=OCCUPATION_CHOICES) uuid = models.UUIDField(primary_key=False) name_hash = models.BinaryField(max_length=16) - days_since_last_login = models.BigIntegerField() + days_since_last_login = models.SmallIntegerField() + days_since_account_creation = models.BigIntegerField() duration_of_sleep = models.DurationField() email = models.EmailField() id_document = models.CharField(unique=True, max_length=10) - - try: - from django.db.models import JSONField - - data = JSONField() - except ImportError: - # Skip JSONField-related fields - pass + data = models.JSONField() try: from django.contrib.postgres.fields import ( ArrayField, HStoreField, - JSONField as PostgresJSONField, ) from django.contrib.postgres.fields.citext import ( CICharField, @@ -123,7 +125,6 @@ class Person(models.Model): if django.VERSION >= (4, 2): long_name = models.CharField() acquaintances = ArrayField(models.IntegerField()) - postgres_data = PostgresJSONField() hstore_data = HStoreField() ci_char = CICharField(max_length=30) ci_email = CIEmailField() @@ -258,6 +259,7 @@ class DummyIntModel(models.Model): class DummyPositiveIntModel(models.Model): positive_small_int_field = models.PositiveSmallIntegerField() positive_int_field = models.PositiveIntegerField() + positive_big_int_field = models.PositiveBigIntegerField() class DummyNumbersModel(models.Model): @@ -279,14 +281,17 @@ class UnsupportedModel(models.Model): unsupported_field = UnsupportedField() -class DummyGenericForeignKeyModel(models.Model): - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey("content_type", "object_id") +if BAKER_CONTENTTYPES: + class DummyGenericForeignKeyModel(models.Model): + content_type = models.ForeignKey( + contenttypes.ContentType, on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") -class DummyGenericRelationModel(models.Model): - relation = GenericRelation(DummyGenericForeignKeyModel) + class DummyGenericRelationModel(models.Model): + relation = GenericRelation(DummyGenericForeignKeyModel) class DummyNullFieldsModel(models.Model): diff --git a/tests/test_baker.py b/tests/test_baker.py index acae607d..07917765 100644 --- a/tests/test_baker.py +++ b/tests/test_baker.py @@ -3,9 +3,8 @@ from decimal import Decimal from unittest.mock import patch -from django import VERSION as DJANGO_VERSION +from django.apps import apps from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.db.models import Manager from django.db.models.signals import m2m_changed from django.test import TestCase, override_settings @@ -13,7 +12,7 @@ import pytest from model_bakery import baker, random_gen -from model_bakery.baker import MAX_MANY_QUANTITY +from model_bakery.baker import BAKER_CONTENTTYPES, MAX_MANY_QUANTITY from model_bakery.exceptions import ( AmbiguousModelName, InvalidQuantityException, @@ -153,7 +152,7 @@ def test_make_should_create_objects_respecting_quantity_parameter(self): assert all(p.name == "George Washington" for p in people) def test_make_quantity_respecting_bulk_create_parameter(self): - query_count = 2 if DJANGO_VERSION >= (4, 0) else 3 + query_count = 1 with self.assertNumQueries(query_count): baker.make(models.Person, _quantity=5, _bulk_create=True) assert models.Person.objects.count() == 5 @@ -233,6 +232,11 @@ def test_accepts_generators_with_quantity_for_unique_fields(self): assert num_2.value == 2 assert num_3.value == 3 + # skip if auth app is not installed + @pytest.mark.skipif( + not apps.is_installed("django.contrib.auth"), + reason="Django auth app is not installed", + ) def test_generators_work_with_user_model(self): from django.contrib.auth import get_user_model @@ -365,7 +369,7 @@ def test_create_multiple_one_to_one(self): assert models.Person.objects.all().count() == 5 def test_bulk_create_multiple_one_to_one(self): - query_count = 7 if DJANGO_VERSION >= (4, 0) else 8 + query_count = 6 with self.assertNumQueries(query_count): baker.make(models.LonelyPerson, _quantity=5, _bulk_create=True) @@ -373,7 +377,7 @@ def test_bulk_create_multiple_one_to_one(self): assert models.Person.objects.all().count() == 5 def test_chaining_bulk_create_reduces_query_count(self): - query_count = 5 if DJANGO_VERSION >= (4, 0) else 7 + query_count = 3 with self.assertNumQueries(query_count): baker.make(models.Person, _quantity=5, _bulk_create=True) person_iter = models.Person.objects.all().iterator() @@ -389,7 +393,7 @@ def test_chaining_bulk_create_reduces_query_count(self): assert models.Person.objects.all().count() == 5 def test_bulk_create_multiple_fk(self): - query_count = 7 if DJANGO_VERSION >= (4, 0) else 8 + query_count = 6 with self.assertNumQueries(query_count): baker.make(models.PaymentBill, _quantity=5, _bulk_create=True) @@ -603,6 +607,9 @@ def test_unsupported_model_raises_an_explanatory_exception(self): assert "field unsupported_field" in repr(e) +@pytest.mark.skipif( + not BAKER_CONTENTTYPES, reason="Django contenttypes framework is not installed" +) @pytest.mark.django_db class TestHandlingModelsWithGenericRelationFields: def test_create_model_with_generic_relation(self): @@ -610,16 +617,26 @@ def test_create_model_with_generic_relation(self): assert isinstance(dummy, models.DummyGenericRelationModel) +@pytest.mark.skipif( + not BAKER_CONTENTTYPES, reason="Django contenttypes framework is not installed" +) @pytest.mark.django_db class TestHandlingContentTypeField: def test_create_model_with_contenttype_field(self): + from django.contrib.contenttypes.models import ContentType + dummy = baker.make(models.DummyGenericForeignKeyModel) assert isinstance(dummy, models.DummyGenericForeignKeyModel) assert isinstance(dummy.content_type, ContentType) +@pytest.mark.skipif( + not BAKER_CONTENTTYPES, reason="Django contenttypes framework is not installed" +) class TestHandlingContentTypeFieldNoQueries: def test_create_model_with_contenttype_field(self): + from django.contrib.contenttypes.models import ContentType + # Clear ContentType's internal cache so that it *will* try to connect to # the database to fetch the corresponding ContentType model for # a randomly chosen model. @@ -797,7 +814,7 @@ def test_creates_instance_for_model_with_list(self): instance = baker.make(models.BaseModelForList, fk=["foo"]) assert instance.id - assert ["foo"] == instance.fk + assert instance.fk == ["foo"] @pytest.mark.django_db @@ -1042,7 +1059,7 @@ def test_annotation_within_manager_get_queryset_are_run_on_make(self): @pytest.mark.django_db class TestCreateM2MWhenBulkCreate(TestCase): def test_create(self): - query_count = 13 if DJANGO_VERSION >= (4, 0) else 14 + query_count = 12 with self.assertNumQueries(query_count): person = baker.make(models.Person) baker.make( diff --git a/tests/test_filling_fields.py b/tests/test_filling_fields.py index 68b5e12f..d4297a0c 100644 --- a/tests/test_filling_fields.py +++ b/tests/test_filling_fields.py @@ -5,7 +5,6 @@ from tempfile import gettempdir from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.core.validators import ( validate_ipv4_address, validate_ipv6_address, @@ -17,6 +16,7 @@ import pytest from model_bakery import baker +from model_bakery.content_types import BAKER_CONTENTTYPES from model_bakery.gis import BAKER_GIS from model_bakery.random_gen import gen_related from tests.generic import generators, models @@ -214,6 +214,15 @@ def test_fill_PositiveIntegerField_with_a_random_number(self): assert isinstance(dummy_positive_int_model.positive_int_field, int) assert dummy_positive_int_model.positive_int_field > 0 + def test_fill_PositiveBigIntegerField_with_a_random_number(self): + dummy_positive_int_model = baker.make(models.DummyPositiveIntModel) + positive_big_int_field = models.DummyPositiveIntModel._meta.get_field( + "positive_big_int_field" + ) + assert isinstance(positive_big_int_field, fields.PositiveBigIntegerField) + assert isinstance(dummy_positive_int_model.positive_big_int_field, int) + assert dummy_positive_int_model.positive_big_int_field > 0 + @pytest.mark.django_db class TestFillingOthersNumericFields: @@ -262,9 +271,15 @@ def test_filling_IPAddressField(self): validate_ipv46_address(obj.ipv46_field) +# skipif +@pytest.mark.skipif( + not BAKER_CONTENTTYPES, reason="Django contenttypes framework is not installed" +) @pytest.mark.django_db class TestFillingGenericForeignKeyField: def test_filling_content_type_field(self): + from django.contrib.contenttypes.models import ContentType + dummy = baker.make(models.DummyGenericForeignKeyModel) assert isinstance(dummy.content_type, ContentType) assert dummy.content_type.model_class() is not None @@ -276,6 +291,8 @@ def test_iteratively_filling_generic_foreign_key_field(self): Otherwise, calling ``next()`` when a GFK is in ``iterator_attrs`` would be bypassed. """ + from django.contrib.contenttypes.models import ContentType + objects = baker.make(models.Profile, _quantity=2) dummies = baker.make( models.DummyGenericForeignKeyModel, @@ -293,6 +310,13 @@ def test_iteratively_filling_generic_foreign_key_field(self): assert dummies[1].content_type == expected_content_type assert dummies[1].object_id == objects[1].pk + def test_with_fill_optional(self): + from django.contrib.contenttypes.models import ContentType + + dummy = baker.make(models.DummyGenericForeignKeyModel, _fill_optional=True) + assert isinstance(dummy.content_type, ContentType) + assert dummy.content_type.model_class() is not None + @pytest.mark.django_db class TestFillingForeignKeyFieldWithDefaultFunctionReturningId: @@ -560,9 +584,6 @@ class TestPostgreSQLFieldsFilling: def test_fill_arrayfield_with_empty_array(self, person): assert person.acquaintances == [] - def test_fill_jsonfield_with_empty_dict(self, person): - assert person.postgres_data == {} - def test_fill_hstorefield_with_empty_dict(self, person): assert person.hstore_data == {} @@ -573,7 +594,6 @@ def assertGeomValid(self, geom): assert geom.valid is True, geom.valid_reason def test_fill_PointField_valid(self, person): - print(BAKER_GIS) self.assertGeomValid(person.point) def test_fill_LineStringField_valid(self, person): diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 2cf531a5..077fe850 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -406,6 +406,11 @@ def test_related_models_recipes(self): assert lady.dog_set.all()[0].breed == "Pug" assert lady.dog_set.all()[1].breed == "Basset" + def test_related_models_recipes_make_mutiple(self): + ladies = baker.make_recipe("tests.generic.dog_lady", _quantity=2) + assert ladies[0].dog_set.count() == 2 + assert ladies[1].dog_set.count() == 2 + def test_nullable_related(self): nullable = baker.make_recipe("tests.generic.nullable_related") assert nullable.dummynullfieldsmodel_set.count() == 1 diff --git a/tox.ini b/tox.ini index 6ba27d10..b9f0e90f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,9 @@ [tox] env_list = - py38-django{32,41,42}-{postgresql,sqlite} - py39-django{32,41,42}-{postgresql,sqlite} - py310-django{32,41,42}-{postgresql,sqlite} - py311-django{32,41,42}-{postgresql,sqlite} - py311-django{42}-{postgresql-psycopg3} - py312-django{42}-{postgresql-psycopg3} + py{38,39}-django{42}-{postgresql,sqlite} + py{310,311}-django{42,50,51}-{postgresql,sqlite} + py{311,312}-django{42,50,51}-{postgresql-psycopg3} + py312-django{50,51}-{postgresql-contenttypes} [testenv] package = wheel @@ -17,6 +15,7 @@ setenv = postgresql-psycopg3: TEST_DB=postgis postgresql-psycopg3: PGUSER=postgres postgresql-psycopg3: PGPASSWORD=postgres + postgresql-contenttypes: USE_CONTENTTYPES=True sqlite: TEST_DB=sqlite sqlite: USE_TZ=True deps = @@ -24,9 +23,9 @@ deps = pillow pytest pytest-django - django32: Django==3.2 - django41: Django>=4.1,<4.2 django42: Django>=4.2,<5 + django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 postgresql: psycopg2-binary postgresql-psycopg3: psycopg commands =