diff --git a/changes/2442.feature.rst b/changes/2442.feature.rst new file mode 100644 index 000000000..36974d860 --- /dev/null +++ b/changes/2442.feature.rst @@ -0,0 +1 @@ +Update Briefcase’s build system to support GUI toolkit-owned pyscript/backend configuration and insertion content. diff --git a/docs/reference/platforms/web/static.rst b/docs/reference/platforms/web/static.rst index 7bc9cdc7e..df81cd2cf 100644 --- a/docs/reference/platforms/web/static.rst +++ b/docs/reference/platforms/web/static.rst @@ -97,3 +97,45 @@ and change the default PyScript app name, you could use:: [[runtimes]] src = "https://example.com/custom/pyodide.js" """ + +Deployment Configuration +======================== + +Web packages can include deployment configuration to control how Briefcase builds +and deploys web applications. The deployment process relies on a ``config.toml`` +file and associated deployment files included in Python packages. + +config.toml +----------- + +A package can specify deployment settings through a ``config.toml`` file located +at ``/deploy/config.toml``. The deployment configuration file accepts the +following information: + +* ``implementation``: The web implementation to use (currently only ``"pyscript"`` + is supported). If not specified, defaults to ``"pyscript"``. +* ``pyscript.version``: The PyScript version to use (e.g., ``"2024.11.1"``). If not + specified, Briefcase will use its default PyScript version. + +Example ``config.toml``:: + + implementation = "pyscript" + + [pyscript] + version = "2024.11.1" + +pyscript.toml +------------- + +A package can include a base ``pyscript.toml`` file located at +``/deploy/pyscript.toml``. The base configuration in this file serves as +the foundation for Briefcase to generate the final ``pyscript.toml`` file. +Briefcase performs the following operations when processing this file: + +1. The system reads the base ``pyscript.toml`` file from the package directory. +2. The system adds the ``packages`` list which contains all wheel files to the configuration. +3. Merge in any :attr:`extra_pyscript_toml_content` from ``pyproject.toml`` + +A Briefcase project must have only one package that defines deployment configuration +through a ``config.toml`` file. The system will produce an error when multiple packages +in the dependencies attempt to define deployment settings. diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py index 88af70717..a7ee828a4 100644 --- a/src/briefcase/platforms/web/static.py +++ b/src/briefcase/platforms/web/static.py @@ -1,9 +1,11 @@ import errno +import re import subprocess import sys import webbrowser from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from textwrap import dedent, indent from typing import Any from zipfile import ZipFile @@ -33,6 +35,21 @@ ) from briefcase.config import AppConfig +# Banner templates (Constants used in write_inserts) +HTML_BANNER = ( + "\n" + "{content}" +) + +CSS_BANNER = ( + "/**************************************************\n" + " * {package}\n" + " *************************************************/\n" + "{content}" +) + class StaticWebMixin: output_format = "static" @@ -45,8 +62,11 @@ def project_path(self, app): def binary_path(self, app): return self.bundle_path(app) / "www/index.html" + def static_path(self, app): + return self.project_path(app) / "static" + def wheel_path(self, app): - return self.project_path(app) / "static/wheels" + return self.static_path(app) / "wheels" def distribution_path(self, app): return self.dist_path / f"{app.formal_name}-{app.version}.web.zip" @@ -73,56 +93,359 @@ class StaticWebOpenCommand(StaticWebMixin, OpenCommand): class StaticWebBuildCommand(StaticWebMixin, BuildCommand): description = "Build a static web project." - def _trim_file(self, path, sentinel): - """Re-write a file to strip any content after a sentinel line. + def write_inserts( + self, + app: AppConfig, + filename: Path, + inserts: dict[str, dict[str, str]], + ): + """Write inserts into an existing file. + + This function looks for start and end markers in the named file and + replaces the content inside the markers with the inserted content. + + Multiple formats of insert marker are inspected to accommodate HTML + and CSS/JS comment conventions: + * HTML: ` and ` + * CSS/JS: `/*@@ insert:start @@*/ and /*@@ insert:end @@*/` - The file is stored in-memory, so it shouldn't be used on files with a *lot* of - content before the sentinel. + Inserts and package contributions are processed in sorted order to ensure + deterministic builds. - :param path: The path to the file to be trimmed - :param sentinel: The content of the sentinel line. This will become the last - line in the trimmed file. + :param app: The application whose `pyscript.toml` is being written. + :param filename: The file whose insert is to be written. + :param inserts: The inserts for the file. A 2 level dictionary, keyed by + the name of the insert to add, and then package that contributed the + insert. """ - content = [] - with path.open("r", encoding="utf-8") as f: - for line in f: - if line.rstrip("\n") == sentinel: - content.append(line) - break - else: - content.append(line) + # Load file content, skip if file not found + target_path = self.project_path(app) / filename + try: + file_text = target_path.read_text(encoding="utf-8") + except FileNotFoundError: + self.console.warning(f" Target {filename} not found; skipping inserts.") + return + + # Each insert slot and its package contributions are processed in sorted order + for insert, pkg_contribs in sorted(inserts.items()): + # Build bodies from the same contributions + html_body = "\n".join( + HTML_BANNER.format(package=pkg, content=text) + for pkg, text in sorted(pkg_contribs.items()) + if text + ) + css_body = "\n".join( + CSS_BANNER.format(package=pkg, content=text) + for pkg, text in sorted(pkg_contribs.items()) + if text + ) + body_map = {"html": html_body, "css": css_body} + + slot = re.escape(insert) + + # Build the compiled patterns directly once per slot + compiled_markers = [ + ( + re.compile( + rf"(^[ \t]*).*?", + flags=re.MULTILINE | re.DOTALL, + ), + r"{indent}\n{content}{indent}", + "html", + ), + ( + re.compile( + rf"(^[ \t]*)/\*@@ {slot}:start @@\*/.*?/\*@@ {slot}:end @@\*/", + flags=re.MULTILINE | re.DOTALL, + ), + r"{indent}/*@@ {insert}:start @@*/\n{content}{indent}/*@@ {insert}:end @@*/", + "css", + ), + ] + + # Apply all matching marker styles + any_match = False + for pattern, repl_tmpl, kind in compiled_markers: + # Search for pattern within file. + pattern_found = pattern.search(file_text) + if pattern_found: + # Indent content to align with markers. + indented_content = indent( + body_map.get(kind, "").strip("\n") + "\n", + pattern_found.group(1), + ) + file_text = pattern.sub( + repl_tmpl.format( + indent=pattern_found.group(1), + insert=insert, + content=indented_content, + ), + file_text, + ) + any_match = True + + if not any_match: + self.console.warning( + f" Slot '{insert}' markers not found in {filename}; skipping." + ) + + # Save modified content + target_path.write_text(file_text, encoding="utf-8") - with path.open("w", encoding="utf-8") as f: - for line in content: - f.write(line) + def _append_pyscript_insert( + self, + app: AppConfig, + filename: Path, + pyscript_version: str, + inserts: dict[str, dict[str, dict[str, str]]], + ): + """Write pyscript version into an existing html file. - def _process_wheel(self, wheelfile, css_file): - """Process a wheel, extracting any content that needs to be compiled into the - final project. + This function creates an insert for PyScript. - :param wheelfile: The path to the wheel file to be processed. - :param css_file: A file handle, opened for write/append, to which any extracted - CSS content will be appended. + :param app: The application whose html file is being written. + :param filename: The html file whose pyscript version is to be written. + :param pyscript_version: The pyscript version number to be inserted. + :param inserts: Nested dict of inserts keyed by target - insert - package. """ - package = " ".join(wheelfile.name.split("-")[:2]) + package_key = "briefcase" + target = filename + head_insert = "head-python" + body_insert = "body-python" + + # PyScript definitions for insertion: + head_content = dedent( + f"""\ + + + + + """ + ) + body_content = dedent( + f"""\ + + """ + ) + + pkg_map_head = inserts.setdefault(target, {}).setdefault(head_insert, {}) + pkg_map_head[package_key] = head_content + + pkg_map_body = inserts.setdefault(target, {}).setdefault(body_insert, {}) + pkg_map_body[package_key] = body_content + + def _handle_legacy_css( + self, + wheel: ZipFile, + path: Path, + filename: str, + package_key: str, + inserts: dict[str, dict[str, dict[str, str]]], + ) -> None: + """Handle legacy CSS under /static/*.css and add to briefcase.css. + + Emits a deprecation warning for every legacy CSS file discovered. + + :param wheel: Open wheel ZipFile being processed. + :param path: Path object of the file inside the wheel. + :param filename: Filename string inside the wheel. + :param package_key: Provenance label (e.g. "name version"). + :param inserts: Nested dict of inserts keyed by target - insert - package. + """ + # Warn on every legacy usage + self.console.warning( + f" {Path(wheel.filename).name}: legacy '/static' CSS file {filename} detected.\n" + " Static file handling has been deprecated; this file should be " + "converted into an insert." + ) + + css_text = wheel.read(filename).decode("utf-8") + + rel_inside = "/".join(path.parts[2:]) + contrib_key = f"{package_key} (legacy static CSS: {rel_inside})" + + # Add CSS content to briefcase.css insert slot + target = "static/css/briefcase.css" + insert = "css" + pkg_map = inserts.setdefault(target, {}).setdefault(insert, {}) + if contrib_key in pkg_map and pkg_map[contrib_key]: + pkg_map[contrib_key] += "\n" + css_text + else: + pkg_map[contrib_key] = css_text + + def _handle_insert( + self, + wheel: ZipFile, + parts: tuple[str, ...], + filename: str, + package_key: str, + inserts: dict[str, dict[str, dict[str, str]]], + ) -> None: + """Handle deploy/inserts/~ files and register inserts. + + :param wheel: Open wheel ZipFile being processed. + :param parts: Path parts of the filename inside the wheel. + :param filename: Filename string inside the wheel. + :param package_key: Provenance label (e.g. "name version"). + :param inserts: Nested dict of inserts keyed by target - insert - package. + """ + self.console.info(f" Found {filename}") + + rel_inside = "/".join(parts[3:]) + + if not rel_inside or rel_inside.endswith("/"): + self.console.warning( + f" {filename}: skipping; not a valid insert file (empty path or directory)." + ) + return + + if "~" not in rel_inside: + self.console.warning( + f" {filename}: skipping; filename must match '~'." + ) + return + + # Preserve any '~' that might exist in the target path by splitting from the right + target, insert = rel_inside.rsplit("~", 1) + self.console.info(f" {filename}: Adding {insert} insert for {target}") + + text = wheel.read(filename).decode("utf-8") + + contrib_key = f"{package_key} (deploy insert: {rel_inside} from {filename})" + pkg_map = inserts.setdefault(target, {}).setdefault(insert, {}) + if contrib_key in pkg_map and pkg_map[contrib_key]: + pkg_map[contrib_key] += "\n" + text + else: + pkg_map[contrib_key] = text + + def _process_wheel( + self, + wheelfile, + inserts: dict[str, dict[str, dict[str, str]]], + ) -> None: + """Process a wheel to collect insert and style content. + + Scans the wheel for: + * Legacy CSS - .css files under /static/ added to briefcase.css. + * Deploy inserts - files under /deploy/inserts/~. + + :param wheelfile: Path to the wheel file. + :param inserts: Nested dict of inserts keyed by target - insert - package. + """ + name_parts = wheelfile.name.split("-") + package_key = f"{name_parts[0]} {name_parts[1]}" + with ZipFile(wheelfile) as wheel: - for filename in wheel.namelist(): + for filename in sorted(wheel.namelist()): + # Skip directories and shallow paths path = Path(filename) - # Any CSS file in a `static` folder is appended + parts = path.parts + + # Legacy CSS handling if ( - len(path.parts) > 1 - and path.parts[1] == "static" - and path.suffix == ".css" + len(parts) > 1 + and parts[1] == "static" + and path.suffix.lower() == ".css" ): - self.console.info(f" Found {filename}") - css_file.write( - "\n/*******************************************************\n" + self._handle_legacy_css(wheel, path, filename, package_key, inserts) + + # New deploy/inserts handling + elif len(parts) >= 3 and parts[1] == "deploy" and parts[2] == "inserts": + self._handle_insert(wheel, parts, filename, package_key, inserts) + + def extract_pyscript_config(self, wheels): + """Process multiple wheels to gather a config.toml and a base pyscript.toml + file. + + :param wheels: A list of wheel files to be scanned. + """ + config_package = None + config_package_list = [] + config_filename = None + pyscript_version = "2024.11.1" + pyscript_config = None + + # Find packages containing a config.toml file. + for wheelfile in wheels: + with ZipFile(wheelfile) as wheel: + for filename in wheel.namelist(): + path = Path(filename) + if ( + len(path.parts) == 3 + and path.parts[1] == "deploy" + and path.name == "config.toml" + ): + self.console.info(f" Found {filename}") + config_package_list.append(wheelfile) + config_filename = filename + + # Return a blank pyscript config if no configuration file is found. + if len(config_package_list) == 0: + pyscript_config = {} + # Raise an error if more than one configuration file is supplied. + elif len(config_package_list) > 1: + raise BriefcaseConfigError( + "Only one deployment configuration file can be supplied. " + f"Initial config.toml found in package: {config_package}; " + f"Duplicate config.toml found in package: {wheel.filename}" + ) + # Gather a deployment configuration file from the package. For now, this is a + # pyscript.toml as no other backend is currently supported. + else: + with ( + ZipFile(config_package_list[0]) as wheel, + wheel.open(config_filename) as config_file, + ): + config_data = tomllib.load(config_file) + + # Gather implementation from config.toml + implementation = config_data.get("implementation", None) + + # Currently, only pyscript is supported. Warn if another implementation is found, + # but fail safe to using pyscript with a warning. + if implementation is None: + self.console.warning( + "No web implementation specified. Defaulting to 'pyscript'." ) - css_file.write(f" * {package}::{'/'.join(path.parts[2:])}\n") - css_file.write( - " *******************************************************/\n\n" + elif implementation != "pyscript": + self.console.warning( + "At present, 'pyscript' is the only supported web implementation. " + "This project may not work correctly." ) - css_file.write(wheel.read(filename).decode("utf-8")) + + # Get pyscript version from config.toml. Use default if not present. + pyscript_version = config_data.get("pyscript", {}).get( + "version", pyscript_version + ) + + # Extract pyscript.toml + pyscript_path = config_filename.replace("config.toml", "pyscript.toml") + try: + with wheel.open(pyscript_path) as pyscript_file: + pyscript_config = tomllib.load(pyscript_file) + except tomllib.TOMLDecodeError as e: + raise BriefcaseConfigError( + f"pyscript.toml content isn't valid TOML: {e}" + ) from e + except (KeyError, FileNotFoundError): + self.console.info( + f"Pyscript configuration file not found in {config_package_list[0]}. " + "Using default configuration." + ) + pyscript_config = {} + + return pyscript_config, pyscript_version def build_app(self, app: AppConfig, **kwargs): """Build the static web deployment for the application. @@ -186,17 +509,11 @@ def build_app(self, app: AppConfig, **kwargs): ) from e with self.console.wait_bar("Writing Pyscript configuration file..."): - # Load any pre-existing pyscript.toml provided by the template. If the file + # Load any pre-existing pyscript.toml provided by a GUI Toolkit. If the file # doesn't exist, assume an empty pyscript.toml as a starting point. - try: - with (self.project_path(app) / "pyscript.toml").open("rb") as f: - config = tomllib.load(f) - except tomllib.TOMLDecodeError as e: - raise BriefcaseConfigError( - f"pyscript.toml content isn't valid TOML: {e}" - ) from e - except FileNotFoundError: - config = {} + config, pyscript_version = self.extract_pyscript_config( + self.wheel_path(app).glob("*.whl") + ) # Add the packages declaration to the existing pyscript.toml. # Ensure that we're using Unix path separators, as the content @@ -218,24 +535,26 @@ def build_app(self, app: AppConfig, **kwargs): except AttributeError: pass - # Write the final configuration. + # Write the final configuration with (self.project_path(app) / "pyscript.toml").open("wb") as f: tomli_w.dump(config, f) - self.console.info("Compile static web content from wheels") with self.console.wait_bar("Compiling static web content from wheels..."): - # Trim previously compiled content out of briefcase.css - briefcase_css_path = self.project_path(app) / "static/css/briefcase.css" - self._trim_file( - briefcase_css_path, - sentinel=" ******************* Wheel contributed styles **********************/", - ) + inserts: dict[str, dict[str, dict[str, str]]] = {} - # Extract static resources from packaged wheels for wheelfile in sorted(self.wheel_path(app).glob("*.whl")): self.console.info(f" Processing {wheelfile.name}...") - with briefcase_css_path.open("a", encoding="utf-8") as css_file: - self._process_wheel(wheelfile, css_file=css_file) + self._process_wheel( + wheelfile=wheelfile, + inserts=inserts, + ) + + # Add pyscript insertion to inserts + self._append_pyscript_insert(app, "index.html", pyscript_version, inserts) + + # Write inserts per target + for target, target_inserts in sorted(inserts.items()): + self.write_inserts(app, Path(target), target_inserts) return {} diff --git a/tests/platforms/web/static/conftest.py b/tests/platforms/web/static/conftest.py index d71fd25d4..fa2d14153 100644 --- a/tests/platforms/web/static/conftest.py +++ b/tests/platforms/web/static/conftest.py @@ -16,19 +16,34 @@ def first_app_generated(first_app_config, tmp_path): """, ) - # Create index.html - create_file(bundle_path / "www/index.html", "") - - # Create the initial pyscript.toml + # Create index.html with insert markers create_file( - bundle_path / "www/pyscript.toml", - """ -existing-key-1 = "value-1" -existing-key-2 = 2 + bundle_path / "www/index.html", + """ + + + + + + + + +
+ + + + + + + + + + + """, ) - # Create the initial briefcase.css + # Create the initial briefcase.css with CSS insert markers create_file( bundle_path / "www/static/css/briefcase.css", """ @@ -36,7 +51,10 @@ def first_app_generated(first_app_config, tmp_path): display: None; } /******************************************************************* - ******************** Wheel contributed styles ********************/ +******************** Wheel contributed styles ********************/ +/*@@ css:start @@*/ +/*@@ css:end @@*/ + """, ) @@ -50,12 +68,6 @@ def first_app_generated(first_app_config, tmp_path): def first_app_built(first_app_generated, tmp_path): bundle_path = tmp_path / "base_path/build/first-app/web/static" - # Create pyscript.toml - create_file( - bundle_path / "www/pyscript.toml", - 'packages = ["dummy-1.2.3-py3-none-all.whl"]', - ) - # Create an app wheel create_wheel(bundle_path / "www/static/wheels") diff --git a/tests/platforms/web/static/test_build.py b/tests/platforms/web/static/test_build.py index 3838d92c9..f34b0a696 100644 --- a/tests/platforms/web/static/test_build.py +++ b/tests/platforms/web/static/test_build.py @@ -15,7 +15,7 @@ from briefcase.integrations.subprocess import Subprocess from briefcase.platforms.web.static import StaticWebBuildCommand -from ....utils import create_file, create_wheel +from ....utils import create_wheel @pytest.fixture @@ -47,6 +47,22 @@ def mock_run(*args, **kwargs): "first_app", extra_content=[ ("dependency/static/style.css", "span { margin: 10px; }\n"), + ( + "dependency/deploy/config.toml", + """ +implementation = "pyscript" + +[pyscript] +version = "2024.11.1" +""", + ), + ( + "dependency/deploy/pyscript.toml", + """ +existing-key-1 = "value-1" +existing-key-2 = 2 +""", + ), ], ) elif args[0][5] == "pip": @@ -132,6 +148,60 @@ def mock_run(*args, **kwargs): ], } + # index.html has insertions + with (bundle_path / "www/index.html").open(encoding="utf-8") as f: + assert ( + f.read() + == "\n".join( + [ + "", + "", + " ", + " ", + " ", + " ", + " ", + ' ", + "", + ' ', + ' ', + " ", + " ", + " ", + '
', + "", + " ", + " ", + "", + " ", + " ", + ' ", + " ", + "", + " ", + " ", + " ", + "", + ] + ) + + "\n" + ) + # briefcase.css has been appended with (bundle_path / "www/static/css/briefcase.css").open(encoding="utf-8") as f: assert ( @@ -143,25 +213,24 @@ def mock_run(*args, **kwargs): " display: None;", "}", "/*******************************************************************", - " ******************** Wheel contributed styles ********************/", - "", - "/*******************************************************", - " * dependency 1.2.3::style.css", - " *******************************************************/", - "", + "******************** Wheel contributed styles ********************/", + "/*@@ css:start @@*/", + "/**************************************************", + " * dependency 1.2.3 (legacy static CSS: style.css)", + " *************************************************/", "div { margin: 10px; }", "", - "/*******************************************************", - " * first_app 1.2.3::style.css", - " *******************************************************/", - "", + "/**************************************************", + " * first_app 1.2.3 (legacy static CSS: style.css)", + " *************************************************/", "span { margin: 10px; }", "", - "/*******************************************************", - " * other 1.2.3::style.css", - " *******************************************************/", - "", + "/**************************************************", + " * other 1.2.3 (legacy static CSS: style.css)", + " *************************************************/", "div { padding: 10px; }", + "/*@@ css:end @@*/", + "", ] ) + "\n" @@ -195,6 +264,12 @@ def test_build_app_custom_pyscript_toml(build_command, first_app_generated, tmp_ bundle_path / "www/static/wheels" ) + # Mock extracting pyscript.toml from a wheel. + build_command.extract_pyscript_config = lambda _: ( + {"existing-key-1": "value-1", "existing-key-2": 2}, + "2024.11.1", + ) + # Build the web app. build_command.build_app(first_app_generated) @@ -212,56 +287,6 @@ def test_build_app_custom_pyscript_toml(build_command, first_app_generated, tmp_ } -def test_build_app_no_template_pyscript_toml( - build_command, first_app_generated, tmp_path -): - """An app whose template doesn't provide pyscript.toml gets a basic config.""" - # Remove the templated pyscript.toml - bundle_path = tmp_path / "base_path/build/first-app/web/static" - (bundle_path / "www/pyscript.toml").unlink() - - # Mock the side effect of invoking shutil - build_command.tools.shutil.rmtree.side_effect = lambda *args: shutil.rmtree( - bundle_path / "www/static/wheels" - ) - - # Build the web app. - build_command.build_app(first_app_generated) - - # Pyscript.toml has been written with only the packages content - with (bundle_path / "www/pyscript.toml").open("rb") as f: - assert tomllib.load(f) == { - "packages": [], - } - - -def test_build_app_invalid_template_pyscript_toml( - build_command, first_app_generated, tmp_path -): - """An app with an invalid pyscript.toml raises an error.""" - # Re-write an invalid templated pyscript.toml - bundle_path = tmp_path / "base_path/build/first-app/web/static" - (bundle_path / "www/pyscript.toml").unlink() - create_file( - bundle_path / "www/pyscript.toml", - """ -This is not valid toml. -""", - ) - - # Mock the side effect of invoking shutil - build_command.tools.shutil.rmtree.side_effect = lambda *args: shutil.rmtree( - bundle_path / "www/static/wheels" - ) - - # Building the web app raises an error - with pytest.raises( - BriefcaseConfigError, - match=r"Briefcase configuration error: pyscript.toml content isn't valid TOML: Expected", - ): - build_command.build_app(first_app_generated) - - def test_build_app_invalid_extra_pyscript_toml_content( build_command, first_app_generated, tmp_path ): @@ -370,6 +395,12 @@ def mock_run(*args, **kwargs): bundle_path / "www/static/wheels" ) + # Mock extracting pyscript.toml from a wheel. + build_command.extract_pyscript_config = lambda _: ( + {"existing-key-1": "value-1", "existing-key-2": 2}, + "2024.11.1", + ) + # Build the web app. build_command.build_app(first_app_generated) @@ -436,13 +467,14 @@ def mock_run(*args, **kwargs): " display: None;", "}", "/*******************************************************************", - " ******************** Wheel contributed styles ********************/", - "", - "/*******************************************************", - " * first_app 1.2.3::style.css", - " *******************************************************/", - "", + "******************** Wheel contributed styles ********************/", + "/*@@ css:start @@*/", + "/**************************************************", + " * first_app 1.2.3 (legacy static CSS: style.css)", + " *************************************************/", "span { margin: 10px; }", + "/*@@ css:end @@*/", + "", ] ) + "\n" @@ -499,13 +531,6 @@ def test_app_package_fail(build_command, first_app_generated, tmp_path): # Wheels folder still exists assert (bundle_path / "www/static/wheels").is_dir() - # Pyscript.toml content has not changed - with (bundle_path / "www/pyscript.toml").open("rb") as f: - assert tomllib.load(f) == { - "existing-key-1": "value-1", - "existing-key-2": 2, - } - def test_dependency_fail(build_command, first_app_generated, tmp_path): """If dependencies can't be downloaded, an error is raised.""" @@ -574,10 +599,3 @@ def test_dependency_fail(build_command, first_app_generated, tmp_path): # Wheels folder still exists assert (bundle_path / "www/static/wheels").is_dir() - - # Pyscript.toml content has not changed - with (bundle_path / "www/pyscript.toml").open("rb") as f: - assert tomllib.load(f) == { - "existing-key-1": "value-1", - "existing-key-2": 2, - } diff --git a/tests/platforms/web/static/test_build__handle_insert.py b/tests/platforms/web/static/test_build__handle_insert.py new file mode 100644 index 000000000..aaf6c4bd0 --- /dev/null +++ b/tests/platforms/web/static/test_build__handle_insert.py @@ -0,0 +1,168 @@ +import zipfile +from pathlib import Path + +import pytest + +from briefcase.platforms.web.static import StaticWebBuildCommand + + +@pytest.fixture +def build_command(dummy_console, tmp_path): + return StaticWebBuildCommand( + console=dummy_console, + base_path=tmp_path / "base_path", + data_path=tmp_path / "briefcase", + ) + + +def test_handle_insert_register_valid_file(build_command, tmp_path): + """Valid insert is registered under target/slot.""" + wheel_path = Path(tmp_path) / "dummy-1.2.3.whl" + filename = "dummy/deploy/inserts/index.html~header" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr(filename, "ok") + + inserts = {} + with zipfile.ZipFile(wheel_path) as zf: + build_command._handle_insert( + wheel=zf, + parts=Path(filename).parts, + filename=filename, + package_key="pkg 1.0", + inserts=inserts, + ) + + assert "index.html" in inserts and "header" in inserts["index.html"] + assert any("ok" in v for v in inserts["index.html"]["header"].values()) + + +@pytest.mark.parametrize( + "entry, expected_skip", + [ + ("dummy/deploy/inserts/", True), # Top-level directory entry + ("dummy/deploy/inserts/assets/", False), # Nested directory entry + ], +) +def test_handle_insert_skip_dir_entries( + build_command, + tmp_path, + capsys, + entry, + expected_skip, +): + """Deploy/inserts directory entries are skipped with a debug log. + + Parametrised: runs once for top-level dir and once for nested dir. + """ + # Create a dummy wheel containing the "directory" entry + wheel_path = Path(tmp_path) / "dummy-1.2.3.whl" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr(entry, "") + + # Run the handler against this entry + inserts = {} + with zipfile.ZipFile(wheel_path) as zf: + build_command._handle_insert( + wheel=zf, + parts=Path(entry).parts, + filename=entry, + package_key="pkg 1.0", + inserts=inserts, + ) + + output = capsys.readouterr().out + + # Directory entries should be ignored completely + assert inserts == {} + # And a debug message explaining the skip should be logged + if expected_skip: + assert "skipping; not a valid insert file" in output + else: + assert "skipping; not a valid insert file" not in output + + +def test_handle_insert_missing_tilde_skipped( + build_command, + tmp_path, + capsys, +): + """Files under deploy/inserts without '~' are skipped with a debug log.""" + # Create a dummy wheel containing an invalid insert file (no "~" in name) + wheel_filename = Path(tmp_path) / "dummy-1.2.3.whl" + missing_tilde = "dummy/deploy/inserts/index.html" + with zipfile.ZipFile(wheel_filename, "w") as zf: + zf.writestr(missing_tilde, "
oops
") + + inserts = {} + # Run handler directly on the invalid file + with zipfile.ZipFile(wheel_filename) as zf: + build_command._handle_insert( + wheel=zf, + parts=Path(missing_tilde).parts, + filename=missing_tilde, + package_key="pkg 1.0", + inserts=inserts, + ) + + output = capsys.readouterr().out + + # File should be ignored completely + assert inserts == {} + # And a debug message should clearly say why (missing "~") + assert "must match '~'" in output + + +def test_handle_insert_append_existing_contrib(build_command, tmp_path): + """Second insert with same contrib key appends with newline.""" + wheel_path = Path(tmp_path) / "dummy-1.2.3.whl" + filename = "dummy/deploy/inserts/index.html~header" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr(filename, "") + + inserts = {} + # First registration creates contrib entry + with zipfile.ZipFile(wheel_path) as zf: + build_command._handle_insert( + wheel=zf, + parts=Path(filename).parts, + filename=filename, + package_key="pkg 1.0", + inserts=inserts, + ) + # Second registration appends to same key + build_command._handle_insert( + wheel=zf, + parts=Path(filename).parts, + filename=filename, + package_key="pkg 1.0", + inserts=inserts, + ) + + contribs = inserts["index.html"]["header"] + contrib_key = next(iter(contribs.keys())) + value = contribs[contrib_key] + + assert value.count("") == 2 + assert "\n" in value + + +def test_handle_insert_non_utf8_raises( + build_command, + tmp_path, +): + """Non-UTF8 insert content raises UnicodeDecodeError.""" + bad = b"\xff\xfe\xfa" + wheel_path = Path(tmp_path) / "dummy-1.2.3.whl" + filename = "dummy/deploy/inserts/index.html~header" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr(filename, bad) + + inserts = {} + with zipfile.ZipFile(wheel_path) as zf, pytest.raises(UnicodeDecodeError): + build_command._handle_insert( + wheel=zf, + parts=Path(filename).parts, + filename=filename, + package_key="pkg 1.0", + inserts=inserts, + ) diff --git a/tests/platforms/web/static/test_build__handle_legacy_css.py b/tests/platforms/web/static/test_build__handle_legacy_css.py new file mode 100644 index 000000000..d90b3e107 --- /dev/null +++ b/tests/platforms/web/static/test_build__handle_legacy_css.py @@ -0,0 +1,80 @@ +import zipfile +from pathlib import Path + +import pytest + +from briefcase.platforms.web.static import StaticWebBuildCommand + + +@pytest.fixture +def build_command(dummy_console, tmp_path): + return StaticWebBuildCommand( + console=dummy_console, + base_path=tmp_path / "base_path", + data_path=tmp_path / "briefcase", + ) + + +def test_handle_legacy_css_warn_and_append( + build_command, + tmp_path, + capsys, +): + """Legacy CSS warns and appends to new contrib key.""" + wheel_path = Path(tmp_path) / "dummy-1.2.3.whl" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr("dummy/static/one.css", "h1 { x:1; }") + + inserts = {} + with zipfile.ZipFile(wheel_path) as zf: + # First call - warn + build_command._handle_legacy_css( + wheel=zf, + path=Path("dummy/static/one.css"), + filename="dummy/static/one.css", + package_key="pkg 1.0", + inserts=inserts, + ) + # Pre-seed same contrib key to force append on second call + target_map = inserts.setdefault("static/css/briefcase.css", {}).setdefault( + "css", {} + ) + key = "pkg 1.0 (legacy static CSS: one.css)" + target_map[key] = target_map[key] + "\n/*extra*/" + # Second call - no additional warning, content appended + build_command._handle_legacy_css( + wheel=zf, + path=Path("dummy/static/one.css"), + filename="dummy/static/one.css", + package_key="pkg 1.0", + inserts=inserts, + ) + + # One warning for each file + output = capsys.readouterr().out + assert ( + "dummy-1.2.3.whl: legacy '/static' CSS file dummy/static/one.css detected." + in output + ) + + # Content appended + out = inserts["static/css/briefcase.css"]["css"][key] + assert "h1 { x:1; }" in out and "/*extra*/" in out + + +def test_handle_legacy_css_non_utf8_raise(build_command, tmp_path): + """Non-UTF8 legacy CSS raises an UnicodeDecodeError.""" + bad = b"\xff\xfe\xfa" + wheel_path = Path(tmp_path) / "dummy-1.2.3.whl" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr("dummy/static/bad.css", bad) + + inserts = {} + with zipfile.ZipFile(wheel_path) as zf, pytest.raises(UnicodeDecodeError): + build_command._handle_legacy_css( + wheel=zf, + path=Path("dummy/static/bad.css"), + filename="dummy/static/bad.css", + package_key="pkg 1.0", + inserts=inserts, + ) diff --git a/tests/platforms/web/static/test_build__process_wheel.py b/tests/platforms/web/static/test_build__process_wheel.py index 91482bb58..af135e1f1 100644 --- a/tests/platforms/web/static/test_build__process_wheel.py +++ b/tests/platforms/web/static/test_build__process_wheel.py @@ -1,5 +1,3 @@ -from io import StringIO - import pytest from briefcase.platforms.web.static import StaticWebBuildCommand @@ -19,63 +17,51 @@ def build_command(dummy_console, tmp_path): def test_process_wheel(build_command, tmp_path): """A wheel can be processed to have CSS content extracted.""" - # Create a wheel with some content. + # Create a wheel with some content wheel_filename = create_wheel( tmp_path, extra_content=[ - # Two CSS files + # Three CSS files ( "dummy/static/first.css", "span {\n font-color: red;\n font-size: larger\n}\n", ), - ("dummy/static/second.css", "div {\n padding: 10px\n}\n"), - ("dummy/static/deep/third.css", "p {\n color: red\n}\n"), + ( + "dummy/static/second.css", + "div {\n padding: 10px\n}\n", + ), + ( + "dummy/static/deep/third.css", + "p {\n color: red\n}\n", + ), # Content in the static file that isn't CSS ("dummy/static/explosions.js", "alert('boom!');"), - # CSS in a location that isn't the static folder. + # CSS in a location that isn't the static folder ("dummy/other.css", "div.other {\n margin: 10px\n}\n"), ("lost.css", "div.lost {\n margin: 10px\n}\n"), ], ) - # Create a dummy css file - css_file = StringIO() - - build_command._process_wheel(wheel_filename, css_file=css_file) - - assert ( - css_file.getvalue() - == "\n".join( - [ - "", - "/*******************************************************", - " * dummy 1.2.3::first.css", - " *******************************************************/", - "", - "span {", - " font-color: red;", - " font-size: larger", - "}", - "", - "/*******************************************************", - " * dummy 1.2.3::second.css", - " *******************************************************/", - "", - "div {", - " padding: 10px", - "}", - "", - "/*******************************************************", - " * dummy 1.2.3::deep/third.css", - " *******************************************************/", - "", - "p {", - " color: red", - "}", - ] - ) - + "\n" - ) + # Collect into inserts dict + inserts = {} + build_command._process_wheel(wheelfile=wheel_filename, inserts=inserts) + + # Legacy CSS should be collected into the briefcase.css "css" insert slot + assert inserts == { + "static/css/briefcase.css": { + "css": { + "dummy 1.2.3 (legacy static CSS: first.css)": ( + "span {\n font-color: red;\n font-size: larger\n}\n" + ), + "dummy 1.2.3 (legacy static CSS: second.css)": ( + "div {\n padding: 10px\n}\n" + ), + "dummy 1.2.3 (legacy static CSS: deep/third.css)": ( + "p {\n color: red\n}\n" + ), + } + } + } def test_process_wheel_no_content(build_command, tmp_path): @@ -93,9 +79,60 @@ def test_process_wheel_no_content(build_command, tmp_path): ], ) - # Create a dummy css file - css_file = StringIO() + inserts = {} + build_command._process_wheel(wheelfile=wheel_filename, inserts=inserts) + + assert inserts == {} + + +def test_process_wheel_deploy_inserts(build_command, tmp_path): + """Deploy inserts are collected into the correct insert slot.""" + wheel_filename = create_wheel( + tmp_path, + extra_content=[ + ("dummy/deploy/inserts/index.html~header", ""), + ( + "dummy/deploy/inserts/static/css/briefcase.css~CSS", + "body { margin: 0; }", + ), + ], + ) + + inserts = {} + build_command._process_wheel(wheelfile=wheel_filename, inserts=inserts) + + # The index.html header insert exists + assert "index.html" in inserts + assert "header" in inserts["index.html"] + contribs = inserts["index.html"]["header"] + assert any("