From 4be7fcafb67d7c6c4cb5342f0386ef1007eefa58 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 11:54:54 -0400 Subject: [PATCH 01/19] Increase test coverage. --- Makefile | 4 ++-- django_clone/settings.py | 4 ++++ model_clone/mixins/clone.py | 2 +- model_clone/tests/test_clone_mixin.py | 23 ++++++++++++++++++- .../tests/test_create_copy_of_instance.py | 19 ++++++++++++++- model_clone/utils.py | 17 ++++++++------ 6 files changed, 57 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index a65fe99e..1f21c65e 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,7 @@ test: # ---------------------------------------------------------- # ---------- Upgrade project version (bumpversion) -------- # ---------------------------------------------------------- -increase-version: clean-build guard-PART ## Bump the project version (using the $PART env: defaults to 'patch'). +increase-version: clean-build makemessages compilemessages guard-PART ## Bump the project version (using the $PART env: defaults to 'patch'). @git checkout main @echo "Increasing project '$(PART)' version..." @$(PYTHON_PIP) install -q -e .'[deploy]' @@ -91,7 +91,7 @@ increase-version: clean-build guard-PART ## Bump the project version (using the @git add . @[ -z "`git status --porcelain`" ] && echo "No changes found." || git commit -am "Updated CHANGELOG.md." -release-to-pypi: makemessages compilemessages increase-version ## Release project to pypi +release: increase-version ## Release project to pypi @$(PYTHON_PIP) install -U twine @$(PYTHON) setup.py sdist bdist_wheel @twine upload -r pypi dist/* diff --git a/django_clone/settings.py b/django_clone/settings.py index d090bd7c..54d57bc9 100644 --- a/django_clone/settings.py +++ b/django_clone/settings.py @@ -83,6 +83,10 @@ "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + }, + "replica": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "replica.sqlite3"), } } diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index edd759f3..5896914e 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -197,7 +197,7 @@ def make_clone(self, attrs=None, sub_clone=False, using=None): :type using: str :return: The model instance that has been cloned. """ - using = self._state.db or self.__class__._default_manager.db + using = using or self._state.db or self.__class__._default_manager.db attrs = attrs or {} if not self.pk: raise ValidationError( diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py index c3a484cd..7c170bc0 100644 --- a/model_clone/tests/test_clone_mixin.py +++ b/model_clone/tests/test_clone_mixin.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db.transaction import TransactionManagementError -from django.db.utils import IntegrityError +from django.db.utils import IntegrityError, DEFAULT_DB_ALIAS from django.test import TestCase, TransactionTestCase from django.utils.text import slugify from django.utils.timezone import make_naive @@ -32,6 +32,12 @@ class CloneMixinTestCase(TestCase): + REPLICA_DB_ALIAS = 'replica' + databases = { + 'default', + 'replica', + } + @classmethod def setUpTestData(cls): cls.user1 = User.objects.create(username="user 1") @@ -124,6 +130,21 @@ def test_cloning_related_unique_o2o_field_without_a_fallback_value_is_valid(self self.assertNotEqual(backcover.pk, clone.backcover.pk) self.assertEqual(backcover.content, clone.backcover.content) + def test_cloning_model_with_a_different_db_alias_is_valid(self): + name = "New Library" + instance = Library(name=name, user=self.user1) + instance.save(using=DEFAULT_DB_ALIAS) + new_user = User(username="new user") + new_user.save(using=self.REPLICA_DB_ALIAS) + clone = instance.make_clone( + attrs={"user": new_user, "name": "New name"}, + using=self.REPLICA_DB_ALIAS, + ) + + self.assertEqual(instance.name, name) + self.assertNotEqual(instance.pk, clone.pk) + self.assertNotEqual(instance.name, clone.name) + def test_cloning_with_field_overridden(self): name = "New Library" instance = Library.objects.create(name=name, user=self.user1) diff --git a/model_clone/tests/test_create_copy_of_instance.py b/model_clone/tests/test_create_copy_of_instance.py index 2da9b245..ba76abfc 100644 --- a/model_clone/tests/test_create_copy_of_instance.py +++ b/model_clone/tests/test_create_copy_of_instance.py @@ -1,6 +1,6 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from django.db import IntegrityError +from django.db import IntegrityError, DEFAULT_DB_ALIAS from django.test import TestCase from django.utils.text import slugify @@ -11,6 +11,12 @@ class CreateCopyOfInstanceTestCase(TestCase): + REPLICA_DB_ALIAS = 'replica' + databases = { + 'default', + 'replica', + } + @classmethod def setUpTestData(cls): cls.user1 = User.objects.create(username="user 1") @@ -23,6 +29,17 @@ def test_cloning_model_with_custom_id(self): self.assertNotEqual(instance.pk, clone.pk) self.assertEqual(clone.user, self.user2) + def test_cloning_model_with_a_different_db_alias_is_valid(self): + new_user = User(username="new user") + new_user.save(using=self.REPLICA_DB_ALIAS) + instance = Library(name="First library", user=self.user1) + instance.save(using=DEFAULT_DB_ALIAS) + clone = create_copy_of_instance(instance, attrs={"user": new_user}, using=self.REPLICA_DB_ALIAS) + + self.assertNotEqual(instance.pk, clone.pk) + self.assertEqual(clone.user, new_user) + self.assertNotEqual(instance._state.db, clone._state.db) + def test_cloning_unique_fk_field_without_a_fallback_value_is_invalid(self): name = "New Library" instance = Library.objects.create(name=name, user=self.user1) diff --git a/model_clone/utils.py b/model_clone/utils.py index 64a1a855..e38ac914 100644 --- a/model_clone/utils.py +++ b/model_clone/utils.py @@ -7,7 +7,7 @@ from django.db.transaction import TransactionManagementError -def create_copy_of_instance(instance, exclude=(), save_new=True, attrs=None): +def create_copy_of_instance(instance, attrs=None, exclude=(), save_new=True, using=None): """ Clone an instance of `django.db.models.Model`. @@ -17,6 +17,8 @@ def create_copy_of_instance(instance, exclude=(), save_new=True, attrs=None): :type exclude: list|set :param save_new: Save the model instance after duplication calling .save(). :type save_new: bool + :param using: The database alias used to save the created instances. + :type using: str :param attrs: Kwargs of field and value to set on the duplicated instance. :type attrs: dict :return: The new duplicated instance. @@ -42,13 +44,15 @@ def create_copy_of_instance(instance, exclude=(), save_new=True, attrs=None): defaults = {} attrs = attrs or {} + default_db_alias = instance._state.db or instance.__class__._default_manager.db + using = using or default_db_alias fields = instance.__class__._meta.concrete_fields if not isinstance(attrs, dict): try: attrs = dict(attrs) except (TypeError, ValueError): - raise ValueError("Invalid: Expected attrs to be a dict or iterable.") + raise ValueError("Invalid: Expected attrs to be a dict or iterable of key and value tuples.") for f in fields: if all( @@ -81,14 +85,13 @@ def create_copy_of_instance(instance, exclude=(), save_new=True, attrs=None): ) ] - try: - # Run the unique validation before creating the instance. + # Bug with django using full_clean on a different db + if using == default_db_alias: + # Validate the new instance on the same database new_obj.full_clean(exclude=exclude) - except ValidationError as e: - raise ValidationError(", ".join(e.messages)) if save_new: - new_obj.save() + new_obj.save(using=using) return new_obj From c947d1bc89477ea08370fe543725a0a595f2f847 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jun 2021 15:55:15 +0000 Subject: [PATCH 02/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- django_clone/settings.py | 2 +- model_clone/tests/test_clone_mixin.py | 8 ++++---- model_clone/tests/test_create_copy_of_instance.py | 12 +++++++----- model_clone/utils.py | 9 ++++++--- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/django_clone/settings.py b/django_clone/settings.py index 54d57bc9..b9c4734b 100644 --- a/django_clone/settings.py +++ b/django_clone/settings.py @@ -87,7 +87,7 @@ "replica": { "ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "replica.sqlite3"), - } + }, } # Password validation diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py index 7c170bc0..8fcccd54 100644 --- a/model_clone/tests/test_clone_mixin.py +++ b/model_clone/tests/test_clone_mixin.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db.transaction import TransactionManagementError -from django.db.utils import IntegrityError, DEFAULT_DB_ALIAS +from django.db.utils import DEFAULT_DB_ALIAS, IntegrityError from django.test import TestCase, TransactionTestCase from django.utils.text import slugify from django.utils.timezone import make_naive @@ -32,10 +32,10 @@ class CloneMixinTestCase(TestCase): - REPLICA_DB_ALIAS = 'replica' + REPLICA_DB_ALIAS = "replica" databases = { - 'default', - 'replica', + "default", + "replica", } @classmethod diff --git a/model_clone/tests/test_create_copy_of_instance.py b/model_clone/tests/test_create_copy_of_instance.py index ba76abfc..06739843 100644 --- a/model_clone/tests/test_create_copy_of_instance.py +++ b/model_clone/tests/test_create_copy_of_instance.py @@ -1,6 +1,6 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from django.db import IntegrityError, DEFAULT_DB_ALIAS +from django.db import DEFAULT_DB_ALIAS, IntegrityError from django.test import TestCase from django.utils.text import slugify @@ -11,10 +11,10 @@ class CreateCopyOfInstanceTestCase(TestCase): - REPLICA_DB_ALIAS = 'replica' + REPLICA_DB_ALIAS = "replica" databases = { - 'default', - 'replica', + "default", + "replica", } @classmethod @@ -34,7 +34,9 @@ def test_cloning_model_with_a_different_db_alias_is_valid(self): new_user.save(using=self.REPLICA_DB_ALIAS) instance = Library(name="First library", user=self.user1) instance.save(using=DEFAULT_DB_ALIAS) - clone = create_copy_of_instance(instance, attrs={"user": new_user}, using=self.REPLICA_DB_ALIAS) + clone = create_copy_of_instance( + instance, attrs={"user": new_user}, using=self.REPLICA_DB_ALIAS + ) self.assertNotEqual(instance.pk, clone.pk) self.assertEqual(clone.user, new_user) diff --git a/model_clone/utils.py b/model_clone/utils.py index e38ac914..ea252d4e 100644 --- a/model_clone/utils.py +++ b/model_clone/utils.py @@ -2,12 +2,13 @@ import re import six -from django.core.exceptions import ValidationError from django.db import models, transaction from django.db.transaction import TransactionManagementError -def create_copy_of_instance(instance, attrs=None, exclude=(), save_new=True, using=None): +def create_copy_of_instance( + instance, attrs=None, exclude=(), save_new=True, using=None +): """ Clone an instance of `django.db.models.Model`. @@ -52,7 +53,9 @@ def create_copy_of_instance(instance, attrs=None, exclude=(), save_new=True, usi try: attrs = dict(attrs) except (TypeError, ValueError): - raise ValueError("Invalid: Expected attrs to be a dict or iterable of key and value tuples.") + raise ValueError( + "Invalid: Expected attrs to be a dict or iterable of key and value tuples." + ) for f in fields: if all( From 8e8fc7751045de1a5e508e9f13c833c23187b1f6 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 11:56:46 -0400 Subject: [PATCH 03/19] Updated bulk clone to include using kwargs. --- model_clone/mixins/clone.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index 5896914e..cbe96501 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -223,8 +223,8 @@ def make_clone(self, attrs=None, sub_clone=False, using=None): return duplicate - def bulk_clone(self, count, attrs=None, batch_size=None, auto_commit=False): - using = self._state.db or self.__class__._default_manager.db + def bulk_clone(self, count, attrs=None, batch_size=None, using=None, auto_commit=False): + using = using or self._state.db or self.__class__._default_manager.db ops = connections[using].ops objs = range(count) clones = [] From 9fdcef9ab292be4add402e10d23352d2ffa6210b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jun 2021 15:57:16 +0000 Subject: [PATCH 04/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- model_clone/mixins/clone.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index cbe96501..eb7ae6d2 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -223,7 +223,9 @@ def make_clone(self, attrs=None, sub_clone=False, using=None): return duplicate - def bulk_clone(self, count, attrs=None, batch_size=None, using=None, auto_commit=False): + def bulk_clone( + self, count, attrs=None, batch_size=None, using=None, auto_commit=False + ): using = using or self._state.db or self.__class__._default_manager.db ops = connections[using].ops objs = range(count) From 506883d86729472e7747605f9f68de3aded82b12 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 12:11:31 -0400 Subject: [PATCH 05/19] Updated deployment script. --- .github/workflows/deploy.yml | 54 ++++++++++++++++++++++++++ Makefile | 75 +++++++++++++++++++++--------------- 2 files changed, 98 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..0bd2534c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,54 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Run semver-diff + id: semver-diff + uses: tj-actions/semver-diff@v1.2.0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.6.x' + + - name: Upgrade pip + run: | + pip install -U pip + + - name: Install dependencies + run: make install-deploy + + - name: Setup git + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + - name: bumpversion + run: | + make increase-version PART="${{ steps.semver-diff.outputs.release_type }}" + + - name: Build and publish + run: make release + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v3 + with: + base: "main" + title: "Upgraded ${{ steps.semver-diff.outputs.old_version }} → ${{ steps.semver-diff.outputs.new_version }}" + branch: "chore/upgrade-${{ steps.semver-diff.outputs.old_version }}-to-${{ steps.semver-diff.outputs.new_version }}" + commit-message: "Upgraded from ${{ steps.semver-diff.outputs.old_version }} → ${{ steps.semver-diff.outputs.new_version }}" + body: "View [CHANGES](https://github.com/${{ github.repository }}/compare/${{ steps.semver-diff.outputs.old_version }}...${{ steps.semver-diff.outputs.new_version }})" + token: ${{ secrets.PAT_TOKEN }} diff --git a/Makefile b/Makefile index 1f21c65e..252e4f73 100644 --- a/Makefile +++ b/Makefile @@ -22,26 +22,48 @@ guard-%: ## Checks that env var is set else exits with non 0 mainly used in CI; # -------------------------------------------------------- # ------- Python package (pip) management commands ------- # -------------------------------------------------------- -clean-build: ## Clean project build artifacts. - @echo "Removing build assets..." - @$(PYTHON) setup.py clean - @rm -rf build/ - @rm -rf dist/ - @rm -rf *.egg-info - -install: clean-build ## Install project dependencies. +clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts + +clean-build: ## remove build artifacts + @rm -fr build/ + @rm -fr dist/ + @rm -fr .eggs/ + @find . -name '*.egg-info' -exec rm -fr {} + + @find . -name '*.egg' -exec rm -f {} + + +clean-pyc: ## remove Python file artifacts + @find . -name '*.pyc' -exec rm -f {} + + @find . -name '*.pyo' -exec rm -f {} + + @find . -name '*~' -exec rm -f {} + + @find . -name '__pycache__' -exec rm -fr {} + + +clean-test: ## remove test and coverage artifacts + @rm -fr .tox/ + @rm -f .coverage + @rm -fr htmlcov/ + @rm -fr .pytest_cache + +install-wheel: ## Install wheel + @echo "Installing wheel..." + @pip install wheel + +install: clean requirements.txt install-wheel ## Install project dependencies. @echo "Installing project in dependencies..." @$(PYTHON_PIP) install -r requirements.txt -install-lint: clean-build ## Install lint extra dependencies. +install-lint: clean setup.py install-wheel ## Install lint extra dependencies. @echo "Installing lint extra requirements..." @$(PYTHON_PIP) install -e .'[lint]' -install-test: clean-build clean-test-all ## Install test extra dependencies. +install-test: clean setup.py install-wheel ## Install test extra dependencies. @echo "Installing test extra requirements..." @$(PYTHON_PIP) install -e .'[test]' -install-dev: clean-build ## Install development extra dependencies. +install-deploy: clean setup.py install-wheel ## Install deploy extra dependencies. + @echo "Installing deploy extra requirements..." + @$(PYTHON_PIP) install -e .'[deploy]' + +install-dev: clean setup.py install-wheel ## Install development extra dependencies. @echo "Installing development requirements..." @$(PYTHON_PIP) install -e .'[development]' -r requirements.txt @@ -80,27 +102,18 @@ test: @$(MANAGE_PY) test # ---------------------------------------------------------- -# ---------- Upgrade project version (bumpversion) -------- +# ---------- Release the project to PyPI ------------------- # ---------------------------------------------------------- -increase-version: clean-build makemessages compilemessages guard-PART ## Bump the project version (using the $PART env: defaults to 'patch'). - @git checkout main - @echo "Increasing project '$(PART)' version..." - @$(PYTHON_PIP) install -q -e .'[deploy]' - @bumpversion --verbose $(PART) - @git-changelog . > CHANGELOG.md - @git add . - @[ -z "`git status --porcelain`" ] && echo "No changes found." || git commit -am "Updated CHANGELOG.md." - -release: increase-version ## Release project to pypi - @$(PYTHON_PIP) install -U twine - @$(PYTHON) setup.py sdist bdist_wheel - @twine upload -r pypi dist/* - @git-changelog . > CHANGELOG.md - @git add . - @[ -z "`git status --porcelain`" ] && echo "No changes found." || git commit -am "Updated CHANGELOG.md." - @git pull - @git push - @git push --tags +increase-version: guard-PART ## Increase project version + @bump2version $(PART) + @git switch -c main + +dist: clean install-deploy ## builds source and wheel package + @pip install twine==3.4.1 + @python setup.py sdist bdist_wheel + +release: dist ## package and upload a release + @twine upload dist/* # ---------------------------------------------------------- # --------- Run project Test ------------------------------- From 7b17f91040baa2199bee0bda0e4aa3df6749b843 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 12:16:40 -0400 Subject: [PATCH 06/19] Fixed test. --- model_clone/tests/test_clone_mixin.py | 2 +- model_clone/tests/test_create_copy_of_instance.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py index 8fcccd54..917d462b 100644 --- a/model_clone/tests/test_clone_mixin.py +++ b/model_clone/tests/test_clone_mixin.py @@ -134,7 +134,7 @@ def test_cloning_model_with_a_different_db_alias_is_valid(self): name = "New Library" instance = Library(name=name, user=self.user1) instance.save(using=DEFAULT_DB_ALIAS) - new_user = User(username="new user") + new_user = User(username="new user 2") new_user.save(using=self.REPLICA_DB_ALIAS) clone = instance.make_clone( attrs={"user": new_user, "name": "New name"}, diff --git a/model_clone/tests/test_create_copy_of_instance.py b/model_clone/tests/test_create_copy_of_instance.py index 06739843..7d67a098 100644 --- a/model_clone/tests/test_create_copy_of_instance.py +++ b/model_clone/tests/test_create_copy_of_instance.py @@ -30,7 +30,7 @@ def test_cloning_model_with_custom_id(self): self.assertEqual(clone.user, self.user2) def test_cloning_model_with_a_different_db_alias_is_valid(self): - new_user = User(username="new user") + new_user = User(username="new user 1") new_user.save(using=self.REPLICA_DB_ALIAS) instance = Library(name="First library", user=self.user1) instance.save(using=DEFAULT_DB_ALIAS) From db03a4efcedbf49f6f438a306c3b73f969b2f989 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 12:34:45 -0400 Subject: [PATCH 07/19] Updated context_mutable_attribute. --- .coveragerc | 1 + model_clone/tests/test_clone_mixin.py | 28 +++++++++++++++++++++++++++ model_clone/utils.py | 11 ++++------- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.coveragerc b/.coveragerc index 68b4cb33..4507d997 100644 --- a/.coveragerc +++ b/.coveragerc @@ -14,6 +14,7 @@ exclude_lines = if settings.DEBUG # Don't complain if tests don't hit defensive assertion code: raise AssertionError + raise StopIteration raise NotImplementedError except ImportError except IntegrityError diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py index 917d462b..c88907e7 100644 --- a/model_clone/tests/test_clone_mixin.py +++ b/model_clone/tests/test_clone_mixin.py @@ -552,6 +552,34 @@ def test_cloning_instances_in_an_atomic_transaction_with_auto_commit_off_is_vali r"{}\s[\d]".format(Author.UNIQUE_DUPLICATE_SUFFIX), ) + def test_cloning_instances_in_an_atomic_transaction_with_auto_commit_off_is_valid( + self, + ): + first_name = ( + "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, " + "sed diam nonumy eirmod tempor invidunt ut labore et dolore " + "magna aliquyam erat, sed diam voluptua. At vero eos et accusam " + "et justo duo dolores " + ) + author = Author.objects.create( + first_name=first_name, + last_name="Jack", + age=26, + sex="F", + created_by=self.user1, + ) + + clones = author.bulk_clone(1000) + + self.assertEqual(len(clones), 1000) + + for clone in clones: + self.assertNotEqual(author.pk, clone.pk) + self.assertRegexpMatches( + clone.first_name, + r"{}\s[\d]".format(Author.UNIQUE_DUPLICATE_SUFFIX), + ) + @patch( "sample.models.Book._clone_m2o_or_o2m_fields", new_callable=PropertyMock, diff --git a/model_clone/utils.py b/model_clone/utils.py index ea252d4e..078df675 100644 --- a/model_clone/utils.py +++ b/model_clone/utils.py @@ -146,17 +146,14 @@ def context_mutable_attribute(obj, key, value): """ Context manager that modifies an obj temporarily. """ - default = None - is_set = hasattr(obj, key) - if is_set: - default = getattr(obj, key) + attribute_exists = hasattr(obj, key) + default = getattr(obj, key, None) try: setattr(obj, key, value) yield finally: - if not is_set and hasattr(obj, key): - del obj[key] - else: + del obj[key] + if attribute_exists: setattr(obj, key, default) From 93d43e1e0ac5ec1af105bab44f3cbb090b4b3260 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 12:37:58 -0400 Subject: [PATCH 08/19] Removed unused code. --- model_clone/tests/test_clone_mixin.py | 28 --------------------------- 1 file changed, 28 deletions(-) diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py index c88907e7..917d462b 100644 --- a/model_clone/tests/test_clone_mixin.py +++ b/model_clone/tests/test_clone_mixin.py @@ -552,34 +552,6 @@ def test_cloning_instances_in_an_atomic_transaction_with_auto_commit_off_is_vali r"{}\s[\d]".format(Author.UNIQUE_DUPLICATE_SUFFIX), ) - def test_cloning_instances_in_an_atomic_transaction_with_auto_commit_off_is_valid( - self, - ): - first_name = ( - "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, " - "sed diam nonumy eirmod tempor invidunt ut labore et dolore " - "magna aliquyam erat, sed diam voluptua. At vero eos et accusam " - "et justo duo dolores " - ) - author = Author.objects.create( - first_name=first_name, - last_name="Jack", - age=26, - sex="F", - created_by=self.user1, - ) - - clones = author.bulk_clone(1000) - - self.assertEqual(len(clones), 1000) - - for clone in clones: - self.assertNotEqual(author.pk, clone.pk) - self.assertRegexpMatches( - clone.first_name, - r"{}\s[\d]".format(Author.UNIQUE_DUPLICATE_SUFFIX), - ) - @patch( "sample.models.Book._clone_m2o_or_o2m_fields", new_callable=PropertyMock, From 07a080153fb6161ddc8008e32a4c3d22cea2006f Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 15:12:56 -0400 Subject: [PATCH 09/19] Updated usage of using kwargs. --- model_clone/mixins/clone.py | 123 ++++++++++++++++---------- model_clone/tests/test_clone_mixin.py | 1 + model_clone/utils.py | 3 +- sample/models.py | 2 +- 4 files changed, 81 insertions(+), 48 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index eb7ae6d2..33add9b6 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -1,3 +1,4 @@ +import itertools from itertools import repeat from typing import Dict, List, Optional @@ -185,13 +186,15 @@ def bulk_clone_multi(cls, objs, attrs=None, batch_size=None): pass @transaction.atomic - def make_clone(self, attrs=None, sub_clone=False, using=None): + def make_clone(self, attrs=None, sub_clone=False, using=None, save_new=True): """Creates a clone of the django model instance. :param attrs: Dictionary of attributes to be replaced on the cloned object. :type attrs: dict :param sub_clone: Internal boolean used to detect cloning sub objects. :type sub_clone: bool + :param save_new: Boolean used to determine if the new instance should be saved. + :type sub_clone: bool :rtype: :obj:`django.db.models.Model` :param using: The database alias used to save the created instances. :type using: str @@ -214,11 +217,23 @@ def make_clone(self, attrs=None, sub_clone=False, using=None): for name, value in attrs.items(): setattr(duplicate, name, value) - duplicate.save(using=using) + duplicate, o2o_instances = self.__duplicate_o2o_fields(duplicate) + duplicate, m2o_instances = self.__duplicate_m2o_fields(duplicate) + + if any([ + save_new, + o2o_instances, + m2o_instances, + ]): + for instance in m2o_instances: + instance.save(using=using) + + duplicate.save(using=using) + + for instance in o2o_instances: + instance.save(using=using) - duplicate = self.__duplicate_o2o_fields(duplicate, using=using) - duplicate = self.__duplicate_o2m_fields(duplicate) - duplicate = self.__duplicate_m2o_fields(duplicate, using=using) + duplicate = self.__duplicate_o2m_fields(duplicate, using=using) duplicate = self.__duplicate_m2m_fields(duplicate, using=using) return duplicate @@ -366,16 +381,16 @@ def _create_copy_of_instance(instance, using=None, force=False, sub_clone=False) return new_instance - def __duplicate_o2o_fields(self, duplicate, using=None): + def __duplicate_o2o_fields(self, duplicate): """Duplicate one to one fields. :param duplicate: The transient instance that should be duplicated. :type duplicate: `django.db.models.Model` - :param using: The database alias used to save the created instances. - :type using: str :return: The duplicate instance with all the one to one fields duplicated. """ - for f in self._meta.related_objects: + o2o_instances = [] + + for f in itertools.chain(self._meta.related_objects, self._meta.concrete_fields): if f.one_to_one: if any( [ @@ -394,20 +409,22 @@ def __duplicate_o2o_fields(self, duplicate, using=None): sub_clone=True, ) setattr(new_rel_object, f.remote_field.name, duplicate) - new_rel_object.save(using=using) - return duplicate + o2o_instances.append(new_rel_object) + + return duplicate, o2o_instances - def __duplicate_o2m_fields(self, duplicate): + def __duplicate_o2m_fields(self, duplicate, using=None): """Duplicate one to many fields. :param duplicate: The transient instance that should be duplicated. :type duplicate: `django.db.models.Model` - :return: The duplicate instance with all the one to many fields duplicated. + :param using: The database alias used to save the created instances. + :type using: str + :return: The duplicate instance with all the transcient one to many duplicated instances. """ - fields = set() - for f in self._meta.related_objects: + for f in itertools.chain(self._meta.related_objects, self._meta.concrete_fields): if f.one_to_many: if any( [ @@ -417,32 +434,43 @@ def __duplicate_o2m_fields(self, duplicate): not in self._clone_excluded_m2o_or_o2m_fields, ] ): - fields.add(f) + for item in getattr(self, f.get_accessor_name()).all(): + if hasattr(item, "make_clone"): + try: + item.make_clone( + attrs={f.remote_field.name: duplicate}, + using=using, + ) + except IntegrityError: + item.make_clone( + attrs={f.remote_field.name: duplicate}, + save_new=False, + sub_clone=True, + using=using, + ) + else: + new_item = CloneMixin._create_copy_of_instance( + item, + force=True, + sub_clone=True, + using=using, + ) + setattr(new_item, f.remote_field.name, duplicate) - # Clone one to many fields - for field in fields: - for item in getattr(self, field.get_accessor_name()).all(): - try: - item.make_clone(attrs={field.remote_field.name: duplicate}) - except IntegrityError: - item.make_clone( - attrs={field.remote_field.name: duplicate}, sub_clone=True - ) + new_item.save(using=using) return duplicate - def __duplicate_m2o_fields(self, duplicate, using=None): + def __duplicate_m2o_fields(self, duplicate): """Duplicate many to one fields. :param duplicate: The transient instance that should be duplicated. :type duplicate: `django.db.models.Model` - :param using: The database alias used to save the created instances. - :type using: str :return: The duplicate instance with all the many to one fields duplicated. """ - fields = set() + m2o_instances = [] - for f in self._meta.concrete_fields: + for f in itertools.chain(self._meta.related_objects, self._meta.concrete_fields): if f.many_to_one: if any( [ @@ -451,23 +479,21 @@ def __duplicate_m2o_fields(self, duplicate, using=None): and f.name not in self._clone_excluded_m2o_or_o2m_fields, ] ): - fields.add(f) + item = getattr(self, f.name) + if hasattr(item, "make_clone"): + try: + item_clone = item.make_clone(save_new=False) + except IntegrityError: + item_clone = item.make_clone(save_new=False, sub_clone=True) + else: + item.pk = None + item_clone = item - # Clone many to one fields - for field in fields: - item = getattr(self, field.name) - if hasattr(item, "make_clone"): - try: - item_clone = item.make_clone() - except IntegrityError: - item_clone = item.make_clone(sub_clone=True) - else: - item.pk = None - item_clone = item.save(using=using) + setattr(duplicate, f.name, item_clone) - setattr(duplicate, field.name, item_clone) + m2o_instances.append(item_clone) - return duplicate + return duplicate, m2o_instances def __duplicate_m2m_fields(self, duplicate, using=None): """Duplicate many to many fields. @@ -525,10 +551,15 @@ def __duplicate_m2m_fields(self, duplicate, using=None): for item in objs: if hasattr(through, "make_clone"): try: - item.make_clone(attrs={field_name: duplicate}) + item.make_clone( + attrs={field_name: duplicate}, + using=using, + ) except IntegrityError: item.make_clone( - attrs={field_name: duplicate}, sub_clone=True + attrs={field_name: duplicate}, + sub_clone=True, + using=using ) else: item.pk = None diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py index 917d462b..e292ce71 100644 --- a/model_clone/tests/test_clone_mixin.py +++ b/model_clone/tests/test_clone_mixin.py @@ -127,6 +127,7 @@ def test_cloning_related_unique_o2o_field_without_a_fallback_value_is_valid(self clone = book.make_clone() self.assertNotEqual(book.pk, clone.pk) + self.assertIsNotNone(clone.backcover.pk) self.assertNotEqual(backcover.pk, clone.backcover.pk) self.assertEqual(backcover.content, clone.backcover.content) diff --git a/model_clone/utils.py b/model_clone/utils.py index 078df675..d295d67b 100644 --- a/model_clone/utils.py +++ b/model_clone/utils.py @@ -152,9 +152,10 @@ def context_mutable_attribute(obj, key, value): setattr(obj, key, value) yield finally: - del obj[key] if attribute_exists: setattr(obj, key, default) + else: + delattr(obj, key) def get_value(value, suffix, transform, max_length, index): diff --git a/sample/models.py b/sample/models.py index 6d861c4b..6b3af068 100644 --- a/sample/models.py +++ b/sample/models.py @@ -85,7 +85,7 @@ class Meta: ] -class Page(CloneModel): +class Page(models.Model): content = models.CharField(max_length=20000) book = models.ForeignKey(Book, on_delete=models.CASCADE) created_at = models.DateTimeField(default=timezone.now) From 7a087ad45f08de5f2eaef00fb85ad719c272db7c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jun 2021 19:13:16 +0000 Subject: [PATCH 10/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- model_clone/mixins/clone.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index 33add9b6..5090eae0 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -220,11 +220,13 @@ def make_clone(self, attrs=None, sub_clone=False, using=None, save_new=True): duplicate, o2o_instances = self.__duplicate_o2o_fields(duplicate) duplicate, m2o_instances = self.__duplicate_m2o_fields(duplicate) - if any([ - save_new, - o2o_instances, - m2o_instances, - ]): + if any( + [ + save_new, + o2o_instances, + m2o_instances, + ] + ): for instance in m2o_instances: instance.save(using=using) @@ -390,7 +392,9 @@ def __duplicate_o2o_fields(self, duplicate): """ o2o_instances = [] - for f in itertools.chain(self._meta.related_objects, self._meta.concrete_fields): + for f in itertools.chain( + self._meta.related_objects, self._meta.concrete_fields + ): if f.one_to_one: if any( [ @@ -424,7 +428,9 @@ def __duplicate_o2m_fields(self, duplicate, using=None): :return: The duplicate instance with all the transcient one to many duplicated instances. """ - for f in itertools.chain(self._meta.related_objects, self._meta.concrete_fields): + for f in itertools.chain( + self._meta.related_objects, self._meta.concrete_fields + ): if f.one_to_many: if any( [ @@ -470,7 +476,9 @@ def __duplicate_m2o_fields(self, duplicate): """ m2o_instances = [] - for f in itertools.chain(self._meta.related_objects, self._meta.concrete_fields): + for f in itertools.chain( + self._meta.related_objects, self._meta.concrete_fields + ): if f.many_to_one: if any( [ @@ -559,7 +567,7 @@ def __duplicate_m2m_fields(self, duplicate, using=None): item.make_clone( attrs={field_name: duplicate}, sub_clone=True, - using=using + using=using, ) else: item.pk = None From b2c9429cd95a8e5eed511b1630e43e95ffbebf38 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 15:18:00 -0400 Subject: [PATCH 11/19] Updated test. --- model_clone/mixins/clone.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index 33add9b6..4384809a 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -225,14 +225,11 @@ def make_clone(self, attrs=None, sub_clone=False, using=None, save_new=True): o2o_instances, m2o_instances, ]): - for instance in m2o_instances: + for instance in itertools.chain(m2o_instances, o2o_instances): instance.save(using=using) duplicate.save(using=using) - for instance in o2o_instances: - instance.save(using=using) - duplicate = self.__duplicate_o2m_fields(duplicate, using=using) duplicate = self.__duplicate_m2m_fields(duplicate, using=using) From f38cfbd282c4a305a834e45d32b82c813f6e230d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jun 2021 19:29:01 +0000 Subject: [PATCH 12/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- model_clone/mixins/clone.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index bb234374..f6080da7 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -219,10 +219,12 @@ def make_clone(self, attrs=None, sub_clone=False, using=None, save_new=True): duplicate, m2o_instances = self.__duplicate_m2o_fields(duplicate) - if any([ - save_new, - m2o_instances, - ]): + if any( + [ + save_new, + m2o_instances, + ] + ): for instance in m2o_instances: instance.save(using=using) From f4c4320d974eb52931e4db118c50ac56346a36d7 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 15:40:21 -0400 Subject: [PATCH 13/19] Fixed test --- model_clone/mixins/clone.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index bb234374..47e1a254 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -217,17 +217,9 @@ def make_clone(self, attrs=None, sub_clone=False, using=None, save_new=True): for name, value in attrs.items(): setattr(duplicate, name, value) - duplicate, m2o_instances = self.__duplicate_m2o_fields(duplicate) - - if any([ - save_new, - m2o_instances, - ]): - for instance in m2o_instances: - instance.save(using=using) - - duplicate.save(using=using) + duplicate.save(using=using) + duplicate = self.__duplicate_m2o_fields(duplicate, using=using) duplicate = self.__duplicate_o2o_fields(duplicate, using=using) duplicate = self.__duplicate_o2m_fields(duplicate, using=using) duplicate = self.__duplicate_m2m_fields(duplicate, using=using) @@ -457,18 +449,16 @@ def __duplicate_o2m_fields(self, duplicate, using=None): return duplicate - def __duplicate_m2o_fields(self, duplicate): + def __duplicate_m2o_fields(self, duplicate, using=None): """Duplicate many to one fields. :param duplicate: The transient instance that should be duplicated. :type duplicate: `django.db.models.Model` + :param using: The database alias used to save the created instances. + :type using: str :return: The duplicate instance with all the many to one fields duplicated. """ - m2o_instances = [] - - for f in itertools.chain( - self._meta.related_objects, self._meta.concrete_fields - ): + for f in self._meta.concrete_fields: if f.many_to_one: if any( [ @@ -480,18 +470,16 @@ def __duplicate_m2o_fields(self, duplicate): item = getattr(self, f.name) if hasattr(item, "make_clone"): try: - item_clone = item.make_clone(save_new=False) + item_clone = item.make_clone(using=using) except IntegrityError: - item_clone = item.make_clone(save_new=False, sub_clone=True) + item_clone = item.make_clone(sub_clone=True) else: item.pk = None - item_clone = item + item_clone = item.save(using=using) setattr(duplicate, f.name, item_clone) - m2o_instances.append(item_clone) - - return duplicate, m2o_instances + return duplicate def __duplicate_m2m_fields(self, duplicate, using=None): """Duplicate many to many fields. From 284755b9b62f8e1f37a325db9972cdadb3cf5e81 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 15:43:48 -0400 Subject: [PATCH 14/19] Removed unused code --- model_clone/mixins/clone.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index 47e1a254..3d199044 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -186,15 +186,13 @@ def bulk_clone_multi(cls, objs, attrs=None, batch_size=None): pass @transaction.atomic - def make_clone(self, attrs=None, sub_clone=False, using=None, save_new=True): + def make_clone(self, attrs=None, sub_clone=False, using=None): """Creates a clone of the django model instance. :param attrs: Dictionary of attributes to be replaced on the cloned object. :type attrs: dict :param sub_clone: Internal boolean used to detect cloning sub objects. :type sub_clone: bool - :param save_new: Boolean used to determine if the new instance should be saved. - :type sub_clone: bool :rtype: :obj:`django.db.models.Model` :param using: The database alias used to save the created instances. :type using: str From 8bcd47f8071fd1dea55467861b5978d28e99fc07 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 16:36:32 -0400 Subject: [PATCH 15/19] Skip lines from coverage report. --- model_clone/mixins/clone.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index 3d199044..d36c51b8 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -207,8 +207,8 @@ def make_clone(self, attrs=None, sub_clone=False, using=None): ) ) if sub_clone: - duplicate = self - duplicate.pk = None + duplicate = self # pragma: no cover + duplicate.pk = None # pragma: no cover else: duplicate = self._create_copy_of_instance(self, using=using) @@ -472,8 +472,8 @@ def __duplicate_m2o_fields(self, duplicate, using=None): except IntegrityError: item_clone = item.make_clone(sub_clone=True) else: - item.pk = None - item_clone = item.save(using=using) + item.pk = None # pragma: no cover + item_clone = item.save(using=using) # pragma: no cover setattr(duplicate, f.name, item_clone) From f5f11a4c415598194fc83757d34f0cc22778f709 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 17:20:37 -0400 Subject: [PATCH 16/19] Updated test. --- model_clone/mixins/clone.py | 4 +- model_clone/tests/test_clone_mixin.py | 142 ++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index d36c51b8..139db69e 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -155,7 +155,7 @@ def check(cls, **kwargs): "Please provide either " + '"_clone_m2o_or_o2m_fields"' + " or " - + '"_clone_excluded_m2o_or_o2m_fields" for {}'.format( + + '"_clone_excluded_m2o_or_o2m_fields" for model {}'.format( cls.__name__ ) ), @@ -170,7 +170,7 @@ def check(cls, **kwargs): "Conflicting configuration.", hint=( 'Please provide either "_clone_o2o_fields"' - + ' or "_clone_excluded_o2o_fields" for {}'.format(cls.__name__) + + ' or "_clone_excluded_o2o_fields" for model {}'.format(cls.__name__) ), obj=cls, id="{}.E002".format(ModelCloneConfig.name), diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py index e292ce71..cea857dd 100644 --- a/model_clone/tests/test_clone_mixin.py +++ b/model_clone/tests/test_clone_mixin.py @@ -2,6 +2,7 @@ import time from django.contrib.auth import get_user_model +from django.core.checks import Error from django.core.exceptions import ValidationError from django.db.transaction import TransactionManagementError from django.db.utils import DEFAULT_DB_ALIAS, IntegrityError @@ -10,6 +11,7 @@ from django.utils.timezone import make_naive from mock import PropertyMock, patch +from model_clone.apps import ModelCloneConfig from sample.models import ( Author, BackCover, @@ -625,6 +627,146 @@ def test_cloning_complex_model_relationships(self): self.assertEqual(house.name, clone_house.name) self.assertEqual(house.rooms.count(), clone_house.rooms.count()) + @patch( + "sample.models.Edition.USE_UNIQUE_DUPLICATE_SUFFIX", + new_callable=PropertyMock, + ) + @patch( + "sample.models.Edition.UNIQUE_DUPLICATE_SUFFIX", + new_callable=PropertyMock, + ) + def test_unique_duplicate_suffix_check(self, unique_duplicate_suffix_mock, use_unique_duplicate_suffix_mock): + use_unique_duplicate_suffix_mock.return_value = True + unique_duplicate_suffix_mock.return_value = '' + + errors = Edition.check() + expected_errors = [ + Error( + "UNIQUE_DUPLICATE_SUFFIX is required.", + hint=( + "Please provide UNIQUE_DUPLICATE_SUFFIX" + + " for {} or set USE_UNIQUE_DUPLICATE_SUFFIX=False".format( + Edition.__name__, + ) + ), + obj=Edition, + id="{}.E001".format(ModelCloneConfig.name), + ) + ] + self.assertEqual(errors, expected_errors) + + @patch( + "sample.models.Edition._clone_fields", + new_callable=PropertyMock, + ) + @patch( + "sample.models.Edition._clone_excluded_fields", + new_callable=PropertyMock, + ) + def test_clone_fields_check(self, _clone_excluded_fields_mock, _clone_fields_mock): + _clone_excluded_fields_mock.return_value = ['test'] + _clone_fields_mock.return_value = ['test'] + + errors = Edition.check() + expected_errors = [ + Error( + "Conflicting configuration.", + hint=( + 'Please provide either "_clone_fields"' + + ' or "_clone_excluded_fields" for model {}'.format( + Edition.__name__, + ) + ), + obj=Edition, + id="{}.E002".format(ModelCloneConfig.name), + ) + ] + self.assertEqual(errors, expected_errors) + + @patch( + "sample.models.Edition._clone_m2m_fields", + new_callable=PropertyMock, + ) + @patch( + "sample.models.Edition._clone_excluded_m2m_fields", + new_callable=PropertyMock, + ) + def test_clone_m2m_fields_check(self, _clone_m2m_fields_mock, _clone_excluded_m2m_fields_mock): + _clone_m2m_fields_mock.return_value = ['test'] + _clone_excluded_m2m_fields_mock.return_value = ['test'] + + errors = Edition.check() + expected_errors = [ + Error( + "Conflicting configuration.", + hint=( + 'Please provide either "_clone_m2m_fields"' + + ' or "_clone_excluded_m2m_fields" for model {}'.format( + Edition.__name__, + ) + ), + obj=Edition, + id="{}.E002".format(ModelCloneConfig.name), + ) + ] + self.assertEqual(errors, expected_errors) + + @patch( + "sample.models.Edition._clone_m2o_or_o2m_fields", + new_callable=PropertyMock, + ) + @patch( + "sample.models.Edition._clone_excluded_m2o_or_o2m_fields", + new_callable=PropertyMock, + ) + def test_clone_m2o_or_o2m_fields_check(self, _clone_m2o_or_o2m_fields_mock, _clone_excluded_m2o_or_o2m_fields_mock): + _clone_m2o_or_o2m_fields_mock.return_value = ['test'] + _clone_excluded_m2o_or_o2m_fields_mock.return_value = ['test'] + + errors = Edition.check() + expected_errors = [ + Error( + "Conflicting configuration.", + hint=( + 'Please provide either "_clone_m2o_or_o2m_fields"' + + ' or "_clone_excluded_m2o_or_o2m_fields" for model {}'.format( + Edition.__name__, + ) + ), + obj=Edition, + id="{}.E002".format(ModelCloneConfig.name), + ) + ] + self.assertEqual(errors, expected_errors) + + @patch( + "sample.models.Edition._clone_o2o_fields", + new_callable=PropertyMock, + ) + @patch( + "sample.models.Edition._clone_excluded_o2o_fields", + new_callable=PropertyMock, + ) + def test_clone_o2o_fields_check(self, _clone_o2o_fields_mock, _clone_excluded_o2o_fields_mock): + _clone_o2o_fields_mock.return_value = ['test'] + _clone_excluded_o2o_fields_mock.return_value = ['test'] + + errors = Edition.check() + expected_errors = [ + Error( + "Conflicting configuration.", + hint=( + 'Please provide either "_clone_o2o_fields"' + + ' or "_clone_excluded_o2o_fields" for model {}'.format( + Edition.__name__, + ) + ), + obj=Edition, + id="{}.E002".format(ModelCloneConfig.name), + ) + ] + self.assertEqual(errors, expected_errors) + class CloneMixinTransactionTestCase(TransactionTestCase): def test_cloning_multiple_instances_doesnt_exceed_the_max_length(self): From 9545c1bdec6ef24d4758400f64fde66ba2e3a4c8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jun 2021 21:20:52 +0000 Subject: [PATCH 17/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- model_clone/mixins/clone.py | 4 ++- model_clone/tests/test_clone_mixin.py | 50 ++++++++++++++++----------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/model_clone/mixins/clone.py b/model_clone/mixins/clone.py index 139db69e..7a14b945 100644 --- a/model_clone/mixins/clone.py +++ b/model_clone/mixins/clone.py @@ -170,7 +170,9 @@ def check(cls, **kwargs): "Conflicting configuration.", hint=( 'Please provide either "_clone_o2o_fields"' - + ' or "_clone_excluded_o2o_fields" for model {}'.format(cls.__name__) + + ' or "_clone_excluded_o2o_fields" for model {}'.format( + cls.__name__ + ) ), obj=cls, id="{}.E002".format(ModelCloneConfig.name), diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py index cea857dd..643dda9c 100644 --- a/model_clone/tests/test_clone_mixin.py +++ b/model_clone/tests/test_clone_mixin.py @@ -635,9 +635,11 @@ def test_cloning_complex_model_relationships(self): "sample.models.Edition.UNIQUE_DUPLICATE_SUFFIX", new_callable=PropertyMock, ) - def test_unique_duplicate_suffix_check(self, unique_duplicate_suffix_mock, use_unique_duplicate_suffix_mock): + def test_unique_duplicate_suffix_check( + self, unique_duplicate_suffix_mock, use_unique_duplicate_suffix_mock + ): use_unique_duplicate_suffix_mock.return_value = True - unique_duplicate_suffix_mock.return_value = '' + unique_duplicate_suffix_mock.return_value = "" errors = Edition.check() expected_errors = [ @@ -664,8 +666,8 @@ def test_unique_duplicate_suffix_check(self, unique_duplicate_suffix_mock, use_u new_callable=PropertyMock, ) def test_clone_fields_check(self, _clone_excluded_fields_mock, _clone_fields_mock): - _clone_excluded_fields_mock.return_value = ['test'] - _clone_fields_mock.return_value = ['test'] + _clone_excluded_fields_mock.return_value = ["test"] + _clone_fields_mock.return_value = ["test"] errors = Edition.check() expected_errors = [ @@ -674,8 +676,8 @@ def test_clone_fields_check(self, _clone_excluded_fields_mock, _clone_fields_moc hint=( 'Please provide either "_clone_fields"' + ' or "_clone_excluded_fields" for model {}'.format( - Edition.__name__, - ) + Edition.__name__, + ) ), obj=Edition, id="{}.E002".format(ModelCloneConfig.name), @@ -691,9 +693,11 @@ def test_clone_fields_check(self, _clone_excluded_fields_mock, _clone_fields_moc "sample.models.Edition._clone_excluded_m2m_fields", new_callable=PropertyMock, ) - def test_clone_m2m_fields_check(self, _clone_m2m_fields_mock, _clone_excluded_m2m_fields_mock): - _clone_m2m_fields_mock.return_value = ['test'] - _clone_excluded_m2m_fields_mock.return_value = ['test'] + def test_clone_m2m_fields_check( + self, _clone_m2m_fields_mock, _clone_excluded_m2m_fields_mock + ): + _clone_m2m_fields_mock.return_value = ["test"] + _clone_excluded_m2m_fields_mock.return_value = ["test"] errors = Edition.check() expected_errors = [ @@ -702,8 +706,8 @@ def test_clone_m2m_fields_check(self, _clone_m2m_fields_mock, _clone_excluded_m2 hint=( 'Please provide either "_clone_m2m_fields"' + ' or "_clone_excluded_m2m_fields" for model {}'.format( - Edition.__name__, - ) + Edition.__name__, + ) ), obj=Edition, id="{}.E002".format(ModelCloneConfig.name), @@ -719,9 +723,11 @@ def test_clone_m2m_fields_check(self, _clone_m2m_fields_mock, _clone_excluded_m2 "sample.models.Edition._clone_excluded_m2o_or_o2m_fields", new_callable=PropertyMock, ) - def test_clone_m2o_or_o2m_fields_check(self, _clone_m2o_or_o2m_fields_mock, _clone_excluded_m2o_or_o2m_fields_mock): - _clone_m2o_or_o2m_fields_mock.return_value = ['test'] - _clone_excluded_m2o_or_o2m_fields_mock.return_value = ['test'] + def test_clone_m2o_or_o2m_fields_check( + self, _clone_m2o_or_o2m_fields_mock, _clone_excluded_m2o_or_o2m_fields_mock + ): + _clone_m2o_or_o2m_fields_mock.return_value = ["test"] + _clone_excluded_m2o_or_o2m_fields_mock.return_value = ["test"] errors = Edition.check() expected_errors = [ @@ -730,8 +736,8 @@ def test_clone_m2o_or_o2m_fields_check(self, _clone_m2o_or_o2m_fields_mock, _clo hint=( 'Please provide either "_clone_m2o_or_o2m_fields"' + ' or "_clone_excluded_m2o_or_o2m_fields" for model {}'.format( - Edition.__name__, - ) + Edition.__name__, + ) ), obj=Edition, id="{}.E002".format(ModelCloneConfig.name), @@ -747,9 +753,11 @@ def test_clone_m2o_or_o2m_fields_check(self, _clone_m2o_or_o2m_fields_mock, _clo "sample.models.Edition._clone_excluded_o2o_fields", new_callable=PropertyMock, ) - def test_clone_o2o_fields_check(self, _clone_o2o_fields_mock, _clone_excluded_o2o_fields_mock): - _clone_o2o_fields_mock.return_value = ['test'] - _clone_excluded_o2o_fields_mock.return_value = ['test'] + def test_clone_o2o_fields_check( + self, _clone_o2o_fields_mock, _clone_excluded_o2o_fields_mock + ): + _clone_o2o_fields_mock.return_value = ["test"] + _clone_excluded_o2o_fields_mock.return_value = ["test"] errors = Edition.check() expected_errors = [ @@ -758,8 +766,8 @@ def test_clone_o2o_fields_check(self, _clone_o2o_fields_mock, _clone_excluded_o2 hint=( 'Please provide either "_clone_o2o_fields"' + ' or "_clone_excluded_o2o_fields" for model {}'.format( - Edition.__name__, - ) + Edition.__name__, + ) ), obj=Edition, id="{}.E002".format(ModelCloneConfig.name), From 38ce0429cd2d79552c9c4e971630a41325a1da6d Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 17:25:05 -0400 Subject: [PATCH 18/19] Updated test. --- model_clone/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/model_clone/utils.py b/model_clone/utils.py index d295d67b..81ef7ab1 100644 --- a/model_clone/utils.py +++ b/model_clone/utils.py @@ -155,7 +155,7 @@ def context_mutable_attribute(obj, key, value): if attribute_exists: setattr(obj, key, default) else: - delattr(obj, key) + delattr(obj, key) # pragma: no cover def get_value(value, suffix, transform, max_length, index): @@ -239,7 +239,7 @@ def get_fields_and_unique_fields_from_cls( and not force and getattr(f, "one_to_one", False) ): - valid = f.name not in clone_excluded_o2o_fields + valid = f.name not in clone_excluded_o2o_fields # pragma: no cover else: valid = True From 2be8a6d2d1ccd176b590794d5bd3018bb424b2c2 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Sat, 19 Jun 2021 17:31:23 -0400 Subject: [PATCH 19/19] Updated .bumpversion.cfg --- .bumpversion.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 499365e7..83933058 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] current_version = 2.5.3 commit = True -tag = True +tag = False [bumpversion:file:setup.py] search = version="{current_version}"