Repository: beeware/briefcase

Issue:
Remove previous install artefacts when updating app_packages
**Describe the bug**
When running `briefcase update -d`, all `app_packages` are reinstalled. However, old metadata folders (dist-info) and .so files will linger on in the `app_packages` directory. They will eventually accumulate.

**To Reproduce**
Run `briefcase update -d` after one of the dependencies has had a new release.

**Expected behavior**
It would be nice to prune such unwanted files.

**Additional context**
I can see two obvious solutions to this:

1. Completely clear the `app_packages` folder before installing updated dependencies.
2. Explicitly run a `pip uninstall` on the current dependencies first. This has the disadvantage of not removing any dependencies which are no longer required in `pyproject.toml`.

Maybe there are other options as well.


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/beeware/briefcase/pull/645?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) Report
> Merging [#645](https://codecov.io/gh/beeware/briefcase/pull/645?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (aa2256a) into [master](https://codecov.io/gh/beeware/briefcase/commit/0b204c07dc3905f5b835a218c501189067404192?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) (0b204c0) will **decrease** coverage by `0.02%`.
> The diff coverage is `83.33%`.

| [Impacted Files](https://codecov.io/gh/beeware/briefcase/pull/645?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware) | Coverage Δ | |
|---|---|---|
| [src/briefcase/commands/create.py](https://codecov.io/gh/beeware/briefcase/pull/645/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=beeware#diff-c3JjL2JyaWVmY2FzZS9jb21tYW5kcy9jcmVhdGUucHk=) | `96.75% <83.33%> (-0.30%)` | :arrow_down: |


Patch:
diff --git a/changes/644.bugfix.rst b/changes/644.bugfix.rst
new file mode 100644
index 00000000..3301f098
--- /dev/null
+++ b/changes/644.bugfix.rst
@@ -0,0 +1,1 @@
+Existing app packages are now cleared before reinstalling dependencies.
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index 41f33cf2..2cdd8912 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -315,7 +315,7 @@ class CreateCommand(BaseCommand):
 
     def install_app_support_package(self, app: BaseConfig):
         """
-        Install the application support packge.
+        Install the application support package.
 
         :param app: The config object for the app
         """
@@ -385,6 +385,15 @@ class CreateCommand(BaseCommand):
 
         :param app: The config object for the app
         """
+
+        target = self.app_packages_path(app)
+
+        # Clear existing dependency directory
+        if target.is_dir():
+            self.shutil.rmtree(target)
+            self.os.mkdir(target)
+
+        # Install  dependencies
         if app.requires:
             try:
                 self.subprocess.run(
@@ -393,7 +402,7 @@ class CreateCommand(BaseCommand):
                         "pip", "install",
                         "--upgrade",
                         "--no-user",
-                        "--target={}".format(self.app_packages_path(app)),
+                        "--target={}".format(target),
                     ] + app.requires,
                     check=True,
                 )


Test patch:
diff --git a/tests/commands/create/test_install_app_dependencies.py b/tests/commands/create/test_install_app_dependencies.py
index e7b50564..5c550a2d 100644
--- a/tests/commands/create/test_install_app_dependencies.py
+++ b/tests/commands/create/test_install_app_dependencies.py
@@ -6,6 +6,26 @@ import pytest
 from briefcase.commands.create import DependencyInstallError
 
 
+def create_installation_artefacts(app_packages_path, packages):
+    """Utility method for generating a function that will mock installation artefacts.
+
+    Creates a function that when invoked, creates a dummy ``__init__.py``
+    and ``__main__.py`` for each package named in ``packages``.
+
+    :param app_packages_path: The pathlib object where app packages will be installed
+    :param packages: A list of package names to mock.
+    :returns: A function that will create files to mock the named installed packages.
+    """
+    def _create_installation_artefacts(*args, **kwargs):
+        for package in packages:
+            (app_packages_path / package).mkdir(parents=True)
+            with (app_packages_path / package / '__init__.py').open('w') as f:
+                f.write('')
+            with (app_packages_path / package / '__main__.py').open('w') as f:
+                f.write('print("I am {package}")')
+    return _create_installation_artefacts
+
+
 def test_no_requires(create_command, myapp, app_packages_path):
     "If an app has no requirements, install_app_dependencies is a no-op."
     myapp.requires = None
@@ -109,3 +129,83 @@ def test_offline(create_command, myapp, app_packages_path):
         ],
         check=True,
     )
+
+
+def test_install_dependencies(create_command, myapp, app_packages_path):
+    "Dependencies can be installed."
+
+    # Set up the app requirements
+    myapp.requires = ['first', 'second', 'third']
+
+    # The side effect of calling pip is creating installation artefacts
+    create_command.subprocess.run.side_effect = create_installation_artefacts(app_packages_path, myapp.requires)
+
+    # Install the dependencies
+    create_command.install_app_dependencies(myapp)
+
+    # The request to install was made
+    create_command.subprocess.run.assert_called_with(
+        [
+            sys.executable,
+            "-m",
+            "pip", "install",
+            "--upgrade",
+            "--no-user",
+            '--target={}'.format(app_packages_path),
+            'first',
+            'second',
+            'third',
+        ],
+        check=True,
+    )
+
+    # The new app packages have installation artefacts created
+    assert (app_packages_path / 'first').exists()
+    assert (app_packages_path / 'first' / '__main__.py').exists()
+    assert (app_packages_path / 'second').exists()
+    assert (app_packages_path / 'second' / '__main__.py').exists()
+    assert (app_packages_path / 'third').exists()
+    assert (app_packages_path / 'third' / '__main__.py').exists()
+
+
+def test_replace_existing_dependencies(create_command, myapp, app_packages_path):
+    "If the app has already had dependencies installed, they are removed first."
+    # Create some existing dependencies
+    create_installation_artefacts(app_packages_path, ['old', 'ancient'])()
+
+    # Set up the app requirements
+    myapp.requires = ['first', 'second', 'third']
+
+    # The side effect of calling pip is creating installation artefacts
+    create_command.subprocess.run.side_effect = create_installation_artefacts(app_packages_path, myapp.requires)
+
+    # Install the dependencies
+    create_command.install_app_dependencies(myapp)
+
+    # The request to install was still made
+    create_command.subprocess.run.assert_called_with(
+        [
+            sys.executable,
+            "-m",
+            "pip", "install",
+            "--upgrade",
+            "--no-user",
+            '--target={}'.format(app_packages_path),
+            'first',
+            'second',
+            'third',
+        ],
+        check=True,
+    )
+
+    # The new app packages have installation artefacts created
+    assert (app_packages_path / 'first').exists()
+    assert (app_packages_path / 'first' / '__main__.py').exists()
+    assert (app_packages_path / 'second').exists()
+    assert (app_packages_path / 'second' / '__main__.py').exists()
+    assert (app_packages_path / 'third').exists()
+    assert (app_packages_path / 'third' / '__main__.py').exists()
+
+    # The old app packages no longer exist.
+    assert not (app_packages_path / 'old').exists()
+    assert not (app_packages_path / 'ancient').exists()