Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/2636.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow using multiple dirs with the same name in `sources` and `test_sources` in different subfolders and merge them together.
8 changes: 8 additions & 0 deletions docs/en/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<app_name>`. Therefore the sources must include at least one Python file (or one folder containing an `__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. Directories listed earlier in the concatenated list are copied first; files from later directories will overwrite files with the same names.

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
Expand Down Expand Up @@ -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.<app_name>`. Therefore the tests sources must include at least one `tests` directory containing a Python file 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. Directories listed earlier in the concatenated list are copied first; files from later directories will overwrite files with the same names.

## 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.
Expand Down
2 changes: 1 addition & 1 deletion src/briefcase/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
108 changes: 108 additions & 0 deletions tests/commands/create/test_install_app_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,114 @@ def test_only_test_sources_test_mode(
assert myapp.test_sources == ["tests", "othertests"]


def test_source_dir_merge(
create_command,
myapp,
tmp_path,
app_path,
app_requirements_path_index,
):
"""If multiple sources define directories with the same name, their contents are
merged with later files overwriting earlier ones."""
# Create the mock sources with two source directories and two test directories
# lib /
# a.py
# b.py
# srcdir /
# lib /
# b.py (different content)
# c.py
# tests /
# test_a.py
# test_b.py
# testdir /
# tests /
# test_b.py (different content)
# test_c.py
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/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",
)

# Set the app definition with two sources and two test sources
myapp.sources = ["lib", "srcdir/lib"]
myapp.test_sources = ["tests", "testdir/tests"]
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"

# 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"

# Metadata has been created
assert_dist_info(app_path)

# Original app definitions haven't changed
assert myapp.sources == ["lib", "srcdir/lib"]
assert myapp.test_sources == ["tests", "testdir/tests"]


def test_dist_info_with_missing_optional_fields(
create_command,
myapp,
Expand Down