diff --git a/changes/2636.feature.md b/changes/2636.feature.md new file mode 100644 index 000000000..b2a2cb2ca --- /dev/null +++ b/changes/2636.feature.md @@ -0,0 +1 @@ +Allow using multiple dirs with the same name in `sources` and `test_sources` in different subfolders and merge them together. diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index 6ea437cb9..9b7169c88 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -124,8 +124,12 @@ A short, one-line description of the purpose of the application. A list of paths, relative to the `pyproject.toml` file, where source code for the application can be found. The contents of any named files or folders will be copied into the application bundle. Parent directories in any named path will not be included. For example, if you specify `src/myapp` as a source, the contents of the `myapp` folder will be copied into the application bundle; the `src` directory will not be reproduced. +App startup invokes the module ``. Therefore the sources must include at least one Python file (or one folder containing a `__main__.py`) with the same name as the app. + Unlike most other keys in a configuration file, [`sources`][] is a *cumulative* setting. If an application defines sources at the global level, application level, *and* platform level, the final set of sources will be the *concatenation* of sources from all levels, starting from least to most specific. +If directories with the same name are present, their contents are merged. If files with the same name are present, those from later entries in the concatenated list will take priority over earlier ones. + The only time `sources` is *not* required is if you are is [packaging an external application][packaging-external-apps]. If you are packaging an external application, `external_package_path` must be defined, and `sources` *must not* be defined. ### Optional values @@ -385,8 +389,12 @@ See [`requires`][] for examples. A list of paths, relative to the `pyproject.toml` file, where test code for the application can be found. The contents of any named files or folders will be copied into the application bundle. Parent directories in any named path will not be included. For example, if you specify `src/myapp` as a source, the contents of the `myapp` folder will be copied into the application bundle; the `src` directory will not be reproduced. +Test startup invokes the module `tests.`. Therefore the tests sources must include at least one `tests` directory containing a Python file (or a folder containing a `__main__.py`) with the same name as the app. + As with [`sources`][], [`test_sources`][] is a *cumulative* setting. If an application defines sources at the global level, application level, *and* platform level, the final set of sources will be the *concatenation* of test sources from all levels, starting from least to most specific. +If directories with the same name are present, their contents are merged. If files with the same name are present, those from later entries in the concatenated list will take priority over earlier ones. + ## Permissions Applications may also need to declare the permissions they require. Permissions are specified as sub-attributes of a `permission` property, defined at the level of an project, app, or platform. Permission declarations are *cumulative*; if an application defines permissions at the global level, application level, *and* platform level, the final set of permissions will be the *merged* set of all permissions from all levels, starting from least to most specific, with the most specific taking priority. diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index 5c42cd03d..9f9ecedc9 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -748,7 +748,7 @@ def install_app_code(self, app: AppConfig): if not original.exists(): raise MissingAppSources(src) elif original.is_dir(): - self.tools.shutil.copytree(original, target) + self.tools.shutil.copytree(original, target, dirs_exist_ok=True) else: self.tools.shutil.copy(original, target) else: diff --git a/tests/commands/create/test_install_app_code.py b/tests/commands/create/test_install_app_code.py index 28d2f444f..029c8b487 100644 --- a/tests/commands/create/test_install_app_code.py +++ b/tests/commands/create/test_install_app_code.py @@ -640,6 +640,154 @@ def test_only_test_sources_test_mode( assert myapp.test_sources == ["tests", "othertests"] +def test_source_dir_merge_and_file_overwrite( + create_command, + myapp, + tmp_path, + app_path, + app_requirements_path_index, +): + """If multiple sources define directories or files with the same name, directories + are merged and files are overwritten, with later files overwriting earlier ones.""" + # Create the mock sources with two source directories and two test directories + # top.py + # test_top.py + # lib / + # a.py + # b.py + # srcdir / + # lib / + # b.py (different content) + # c.py + # top.py (different content) + # tests / + # test_a.py + # test_b.py + # testdir / + # tests / + # test_b.py (different content) + # test_c.py + # test_top.py (different content) + create_file( + tmp_path / "base_path/top.py", + "# top\n", + ) + create_file( + tmp_path / "base_path/test_top.py", + "# test_top\n", + ) + create_file( + tmp_path / "base_path/lib/a.py", + "# a from lib\n", + ) + create_file( + tmp_path / "base_path/lib/b.py", + "# b from lib\n", + ) + create_file( + tmp_path / "base_path/srcdir/lib/b.py", + "# b from srcdir\n", + ) + create_file( + tmp_path / "base_path/srcdir/lib/c.py", + "# c from srcdir\n", + ) + create_file( + tmp_path / "base_path/srcdir/top.py", + "# top from srcdir\n", + ) + create_file( + tmp_path / "base_path/tests/test_a.py", + "# test_a from tests\n", + ) + create_file( + tmp_path / "base_path/tests/test_b.py", + "# test_b from tests\n", + ) + create_file( + tmp_path / "base_path/testdir/tests/test_b.py", + "# test_b from testdir\n", + ) + create_file( + tmp_path / "base_path/testdir/tests/test_c.py", + "# test_c from testdir\n", + ) + create_file( + tmp_path / "base_path/testdir/test_top.py", + "# test_top from testdir\n", + ) + + # Set the app definition with two sources and two test sources, and two top-level files with the same name + myapp.sources = ["lib", "srcdir/lib", "top.py", "srcdir/top.py"] + myapp.test_sources = [ + "tests", + "testdir/tests", + "test_top.py", + "testdir/test_top.py", + ] + myapp.test_mode = True + + create_command.install_app_code(myapp) + + # The lib directory exists + assert (app_path / "lib").exists() + + # a.py from lib (not overwritten) + assert (app_path / "lib/a.py").exists() + with (app_path / "lib/a.py").open(encoding="utf-8") as f: + assert f.read() == "# a from lib\n" + + # b.py from srcdir (overwrote lib) + assert (app_path / "lib/b.py").exists() + with (app_path / "lib/b.py").open(encoding="utf-8") as f: + assert f.read() == "# b from srcdir\n" + + # c.py from srcdir + assert (app_path / "lib/c.py").exists() + with (app_path / "lib/c.py").open(encoding="utf-8") as f: + assert f.read() == "# c from srcdir\n" + + # top.py from srcdir (overwrites file from root) + assert (app_path / "top.py").exists() + with (app_path / "top.py").open(encoding="utf-8") as f: + assert f.read() == "# top from srcdir\n" + + # The tests directory exists + assert (app_path / "tests").exists() + + # test_a.py from tests (not overwritten) + assert (app_path / "tests/test_a.py").exists() + with (app_path / "tests/test_a.py").open(encoding="utf-8") as f: + assert f.read() == "# test_a from tests\n" + + # test_b.py from testdir (overwrote tests) + assert (app_path / "tests/test_b.py").exists() + with (app_path / "tests/test_b.py").open(encoding="utf-8") as f: + assert f.read() == "# test_b from testdir\n" + + # test_c.py from testdir + assert (app_path / "tests/test_c.py").exists() + with (app_path / "tests/test_c.py").open(encoding="utf-8") as f: + assert f.read() == "# test_c from testdir\n" + + # test_top.py from testdir (overwrites file from root) + assert (app_path / "test_top.py").exists() + with (app_path / "test_top.py").open(encoding="utf-8") as f: + assert f.read() == "# test_top from testdir\n" + + # Metadata has been created + assert_dist_info(app_path) + + # Original app definitions haven't changed + assert myapp.sources == ["lib", "srcdir/lib", "top.py", "srcdir/top.py"] + assert myapp.test_sources == [ + "tests", + "testdir/tests", + "test_top.py", + "testdir/test_top.py", + ] + + def test_dist_info_with_missing_optional_fields( create_command, myapp,