Ir para o conteúdo

tempestweb.pwa

Os artefatos que tornam o app instalável: o manifest.webmanifest gerado a partir da configuração do projeto e os ícones em todos os tamanhos que o browser pede. Chamado pelo build; importe direto só para gerar os artefatos fora da CLI.

Guia com exemplos: PWA e offline.

tempestweb.pwa

tempestweb.pwa — PWA build artifacts (manifest + icons). Track P, P0/P5.

This package owns the Python side of the PWA: emitting a spec-compliant, installable manifest.webmanifest and the icon set during tempestweb build (both Mode A and Mode B). The shape of the manifest mirrors the pure-JS client/pwa/manifest.js so the two never drift; this module is the build-time emitter that writes files to disk.

See docs/plan.md §7 P0/P5 for the contract.

IconSpec dataclass

Specification for one icon file to emit.

Attributes:

Name Type Description
filename str

File name written under the icons directory.

size int

Square edge length in pixels (e.g. 192, 512).

maskable bool

Whether this icon is intended as a maskable icon. Maskable icons get a larger safe-zone inset so the OS mask never clips art.

Source code in tempestweb/pwa/icons.py
@dataclass(frozen=True, slots=True)
class IconSpec:
    """Specification for one icon file to emit.

    Attributes:
        filename: File name written under the icons directory.
        size: Square edge length in pixels (e.g. 192, 512).
        maskable: Whether this icon is intended as a maskable icon. Maskable
            icons get a larger safe-zone inset so the OS mask never clips art.
    """

    filename: str
    size: int
    maskable: bool = False

ManifestOptions dataclass

Project overrides for the generated manifest.

Every field defaults to an installable-shaped value; a project's tempestweb config overrides what it needs.

Attributes:

Name Type Description
name str

Full application name.

short_name str

Home-screen label.

description str

Human description.

start_url str

URL opened on launch.

scope str

Navigation scope.

display str

One of the installable display modes.

theme_color str

Toolbar color (CSS color).

background_color str

Splash background (CSS color).

lang str

BCP-47 language tag.

dir str

Text direction ("ltr" | "rtl" | "auto").

orientation str | None

Optional preferred orientation.

app_id str | None

Stable app identity; defaults to scope when None.

icons list[dict[str, str]]

Icon set; defaults to DEFAULT_ICONS when empty.

categories list[str]

App-store categories.

launch_handler dict[str, Any] | None

launch_handler object; defaults to reusing an open window ({"client_mode": ["focus-existing", "auto"]}) when None, so launching the installed app focuses the existing window instead of spawning a duplicate.

display_override list[str]

Ordered display fallbacks; defaults to [display, "minimal-ui"] when empty.

shortcuts list[dict[str, Any]]

P5 app shortcuts.

share_target dict[str, Any] | None

P5 share target descriptor.

file_handlers list[dict[str, Any]]

P5 file handler descriptors.

Source code in tempestweb/pwa/manifest.py
@dataclass(slots=True)
class ManifestOptions:
    """Project overrides for the generated manifest.

    Every field defaults to an installable-shaped value; a project's
    ``tempestweb`` config overrides what it needs.

    Attributes:
        name: Full application name.
        short_name: Home-screen label.
        description: Human description.
        start_url: URL opened on launch.
        scope: Navigation scope.
        display: One of the installable display modes.
        theme_color: Toolbar color (CSS color).
        background_color: Splash background (CSS color).
        lang: BCP-47 language tag.
        dir: Text direction ("ltr" | "rtl" | "auto").
        orientation: Optional preferred orientation.
        app_id: Stable app identity; defaults to ``scope`` when None.
        icons: Icon set; defaults to ``DEFAULT_ICONS`` when empty.
        categories: App-store categories.
        launch_handler: ``launch_handler`` object; defaults to reusing an open
            window (``{"client_mode": ["focus-existing", "auto"]}``) when None, so
            launching the installed app focuses the existing window instead of
            spawning a duplicate.
        display_override: Ordered display fallbacks; defaults to
            ``[display, "minimal-ui"]`` when empty.
        shortcuts: P5 app shortcuts.
        share_target: P5 share target descriptor.
        file_handlers: P5 file handler descriptors.
    """

    name: str = "tempestweb app"
    short_name: str = "tempestweb"
    description: str = "A tempestweb application."
    start_url: str = "/"
    scope: str = "/"
    display: str = "standalone"
    theme_color: str = "#111111"
    background_color: str = "#ffffff"
    lang: str = "pt-BR"
    dir: str = "auto"
    orientation: str | None = None
    app_id: str | None = None
    icons: list[dict[str, str]] = field(default_factory=list)
    categories: list[str] = field(default_factory=list)
    launch_handler: dict[str, Any] | None = None
    display_override: list[str] = field(default_factory=list)
    shortcuts: list[dict[str, Any]] = field(default_factory=list)
    share_target: dict[str, Any] | None = None
    file_handlers: list[dict[str, Any]] = field(default_factory=list)

emit_icons

emit_icons(dest_dir: Path, specs: tuple[IconSpec, ...] = DEFAULT_ICON_SPECS, color: tuple[int, int, int, int] = (17, 17, 17, 255)) -> list[Path]

Write the icon set to dest_dir (e.g. <build>/icons).

Maskable specs get a ~10% safe-zone inset so the OS mask never clips the art.

Parameters:

Name Type Description Default
dest_dir Path

Output directory (created if missing).

required
specs tuple[IconSpec, ...]

Icon specifications to emit.

DEFAULT_ICON_SPECS
color tuple[int, int, int, int]

Base color for the icons.

(17, 17, 17, 255)

Returns:

Type Description
list[Path]

The list of paths written, in spec order.

Source code in tempestweb/pwa/icons.py
def emit_icons(
    dest_dir: Path,
    specs: tuple[IconSpec, ...] = DEFAULT_ICON_SPECS,
    color: tuple[int, int, int, int] = (17, 17, 17, 255),
) -> list[Path]:
    """Write the icon set to ``dest_dir`` (e.g. ``<build>/icons``).

    Maskable specs get a ~10% safe-zone inset so the OS mask never clips the art.

    Args:
        dest_dir: Output directory (created if missing).
        specs: Icon specifications to emit.
        color: Base color for the icons.

    Returns:
        The list of paths written, in spec order.
    """
    dest_dir.mkdir(parents=True, exist_ok=True)
    written: list[Path] = []
    for spec in specs:
        # Maskable safe zone: keep art within the central 80% (10% inset each side).
        inset = round(spec.size * 0.1) if spec.maskable else 0
        png = placeholder_png(spec.size, color=color, inset=inset)
        path = dest_dir / spec.filename
        path.write_bytes(png)
        written.append(path)
    return written

placeholder_png

placeholder_png(size: int, color: tuple[int, int, int, int] = (17, 17, 17, 255), inset: int = 0, inset_color: tuple[int, int, int, int] = (255, 255, 255, 255)) -> bytes

Build a valid 8-bit RGBA PNG of a solid color with an optional inset.

The inset draws a centered square of inset_color to mimic a maskable icon's safe zone, so the generated maskable variants look intentional.

Parameters:

Name Type Description Default
size int

Square edge length in pixels (> 0).

required
color tuple[int, int, int, int]

Background RGBA (0-255 each).

(17, 17, 17, 255)
inset int

Border width in pixels left as color around an inner square.

0
inset_color tuple[int, int, int, int]

RGBA of the inner square.

(255, 255, 255, 255)

Returns:

Type Description
bytes

The complete PNG file bytes.

Raises:

Type Description
ValueError

If size is not positive.

Source code in tempestweb/pwa/icons.py
def placeholder_png(
    size: int,
    color: tuple[int, int, int, int] = (17, 17, 17, 255),
    inset: int = 0,
    inset_color: tuple[int, int, int, int] = (255, 255, 255, 255),
) -> bytes:
    """Build a valid 8-bit RGBA PNG of a solid color with an optional inset.

    The inset draws a centered square of ``inset_color`` to mimic a maskable
    icon's safe zone, so the generated maskable variants look intentional.

    Args:
        size: Square edge length in pixels (> 0).
        color: Background RGBA (0-255 each).
        inset: Border width in pixels left as ``color`` around an inner square.
        inset_color: RGBA of the inner square.

    Returns:
        The complete PNG file bytes.

    Raises:
        ValueError: If ``size`` is not positive.
    """
    if size <= 0:
        raise ValueError("size must be a positive integer")

    bg = bytes(color)
    fg = bytes(inset_color)
    lo = inset
    hi = size - inset

    raw = bytearray()
    for y in range(size):
        raw.append(0)  # filter type 0 (None) per scanline
        for x in range(size):
            if inset and lo <= x < hi and lo <= y < hi:
                raw += fg
            else:
                raw += bg

    ihdr = struct.pack(
        ">IIBBBBB",
        size,  # width
        size,  # height
        8,  # bit depth
        6,  # color type: RGBA
        0,  # compression
        0,  # filter
        0,  # interlace
    )
    idat = zlib.compress(bytes(raw), 9)

    return (
        b"\x89PNG\r\n\x1a\n"
        + _png_chunk(b"IHDR", ihdr)
        + _png_chunk(b"IDAT", idat)
        + _png_chunk(b"IEND", b"")
    )

build_manifest

build_manifest(options: ManifestOptions | None = None) -> dict[str, Any]

Build a manifest object from options, filling installable defaults.

Parameters:

Name Type Description Default
options ManifestOptions | None

Project overrides. None uses every default.

None

Returns:

Type Description
dict[str, Any]

A JSON-able manifest object ready for emit_manifest.

Source code in tempestweb/pwa/manifest.py
def build_manifest(options: ManifestOptions | None = None) -> dict[str, Any]:
    """Build a manifest object from options, filling installable defaults.

    Args:
        options: Project overrides. ``None`` uses every default.

    Returns:
        A JSON-able manifest object ready for ``emit_manifest``.
    """
    opts = options or ManifestOptions()
    display = opts.display if opts.display in INSTALLABLE_DISPLAYS else "standalone"
    icons = opts.icons if opts.icons else DEFAULT_ICONS

    manifest: dict[str, Any] = {
        "name": opts.name,
        "short_name": opts.short_name,
        "description": opts.description,
        "start_url": opts.start_url,
        "scope": opts.scope,
        "display": display,
        "theme_color": opts.theme_color,
        "background_color": opts.background_color,
        "lang": opts.lang,
        "dir": opts.dir,
        "icons": [dict(icon) for icon in icons],
    }

    # Stable app identity defaults to the scope.
    manifest["id"] = opts.app_id if opts.app_id is not None else manifest["scope"]

    # Reuse an open window on launch instead of spawning a duplicate.
    manifest["launch_handler"] = (
        opts.launch_handler
        if opts.launch_handler is not None
        else {"client_mode": ["focus-existing", "auto"]}
    )

    # Ordered display fallbacks; default to the chosen display then minimal-ui.
    override = (
        list(opts.display_override)
        if opts.display_override
        else [display, "minimal-ui"]
    )
    manifest["display_override"] = list(dict.fromkeys(override))

    if opts.orientation:
        manifest["orientation"] = opts.orientation
    if opts.categories:
        manifest["categories"] = list(opts.categories)

    # P5 extras pass through untouched when present.
    if opts.shortcuts:
        manifest["shortcuts"] = [dict(s) for s in opts.shortcuts]
    if opts.share_target:
        manifest["share_target"] = dict(opts.share_target)
    if opts.file_handlers:
        manifest["file_handlers"] = [dict(h) for h in opts.file_handlers]

    return manifest

default_extras

default_extras() -> dict[str, Any]

Return the default P5 manifest extras a scaffolded app ships.

A "Home" shortcut, a POST share target and a CSV file handler. References only — the host app wires the routes (share_target pairs with native.share).

Returns:

Type Description
dict[str, Any]

A dict with shortcuts, share_target and file_handlers.

Source code in tempestweb/pwa/manifest.py
def default_extras() -> dict[str, Any]:
    """Return the default P5 manifest extras a scaffolded app ships.

    A "Home" shortcut, a POST share target and a CSV file handler. References
    only — the host app wires the routes (share_target pairs with native.share).

    Returns:
        A dict with ``shortcuts``, ``share_target`` and ``file_handlers``.
    """
    return {
        "shortcuts": [
            {
                "name": "Home",
                "short_name": "Home",
                "url": "/",
                "description": "Open the app home",
            }
        ],
        "share_target": {
            "action": "/share-target",
            "method": "POST",
            "enctype": "multipart/form-data",
            "params": {"title": "title", "text": "text", "url": "url"},
        },
        "file_handlers": [{"action": "/open", "accept": {"text/csv": [".csv"]}}],
    }

emit_manifest

emit_manifest(manifest: dict[str, Any], indent: int = 2) -> str

Serialize a manifest object to JSON text for manifest.webmanifest.

Parameters:

Name Type Description Default
manifest dict[str, Any]

A manifest object (typically from build_manifest).

required
indent int

Spaces of indentation (0 for minified).

2

Returns:

Type Description
str

The JSON string. ensure_ascii is False so accented names survive.

Source code in tempestweb/pwa/manifest.py
def emit_manifest(manifest: dict[str, Any], indent: int = 2) -> str:
    """Serialize a manifest object to JSON text for ``manifest.webmanifest``.

    Args:
        manifest: A manifest object (typically from ``build_manifest``).
        indent: Spaces of indentation (0 for minified).

    Returns:
        The JSON string. ``ensure_ascii`` is False so accented names survive.
    """
    return json.dumps(manifest, indent=indent or None, ensure_ascii=False)

validate_extras

validate_extras(manifest: dict[str, Any]) -> list[str]

Validate the P5 manifest extras (shortcuts/share_target/file_handlers).

Progressive enhancements with uneven browser support, so this is a shape check, not an install requirement. Mirrors validateExtras in client/pwa/manifest.js.

Parameters:

Name Type Description Default
manifest dict[str, Any]

A manifest (or extras) object.

required

Returns:

Type Description
list[str]

Human-readable problems; empty when present extras are well-formed.

Source code in tempestweb/pwa/manifest.py
def validate_extras(manifest: dict[str, Any]) -> list[str]:
    """Validate the P5 manifest extras (shortcuts/share_target/file_handlers).

    Progressive enhancements with uneven browser support, so this is a shape
    check, not an install requirement. Mirrors ``validateExtras`` in
    ``client/pwa/manifest.js``.

    Args:
        manifest: A manifest (or extras) object.

    Returns:
        Human-readable problems; empty when present extras are well-formed.
    """
    errors: list[str] = []
    if not isinstance(manifest, dict):
        return ["manifest must be an object"]

    shortcuts = manifest.get("shortcuts")
    if shortcuts is not None:
        if not isinstance(shortcuts, list):
            errors.append("shortcuts must be an array")
        else:
            for i, s in enumerate(shortcuts):
                if not isinstance(s, dict) or not isinstance(s.get("name"), str):
                    errors.append(f"shortcuts[{i}].name is required")
                if not isinstance(s, dict) or not isinstance(s.get("url"), str):
                    errors.append(f"shortcuts[{i}].url is required")

    share_target = manifest.get("share_target")
    if share_target is not None:
        if not isinstance(share_target, dict):
            errors.append("share_target must be an object")
        else:
            if not isinstance(share_target.get("action"), str):
                errors.append("share_target.action is required")
            method = str(share_target.get("method", "GET")).upper()
            if method == "POST" and not isinstance(share_target.get("enctype"), str):
                errors.append("share_target with method POST requires an enctype")

    file_handlers = manifest.get("file_handlers")
    if file_handlers is not None:
        if not isinstance(file_handlers, list):
            errors.append("file_handlers must be an array")
        else:
            for i, h in enumerate(file_handlers):
                if not isinstance(h, dict) or not isinstance(h.get("action"), str):
                    errors.append(f"file_handlers[{i}].action is required")
                if not isinstance(h, dict) or not isinstance(h.get("accept"), dict):
                    errors.append(f"file_handlers[{i}].accept is required")

    return errors

validate_installable

validate_installable(manifest: dict[str, Any]) -> list[str]

Return install-criteria errors for a manifest ([] when installable).

Mirrors validateInstallable in client/pwa/manifest.js.

Parameters:

Name Type Description Default
manifest dict[str, Any]

A parsed manifest object.

required

Returns:

Type Description
list[str]

Human-readable problems; empty when installable.

Source code in tempestweb/pwa/manifest.py
def validate_installable(manifest: dict[str, Any]) -> list[str]:
    """Return install-criteria errors for a manifest ([] when installable).

    Mirrors ``validateInstallable`` in ``client/pwa/manifest.js``.

    Args:
        manifest: A parsed manifest object.

    Returns:
        Human-readable problems; empty when installable.
    """
    errors: list[str] = []
    if not isinstance(manifest, dict):
        return ["manifest must be an object"]
    if not manifest.get("name") and not manifest.get("short_name"):
        errors.append("name or short_name is required")
    if not manifest.get("start_url"):
        errors.append("start_url is required")
    if manifest.get("display") not in INSTALLABLE_DISPLAYS:
        errors.append('display must be "standalone", "fullscreen" or "minimal-ui"')

    icons = manifest.get("icons") or []

    def has_size(size: str) -> bool:
        """Report whether any declared icon advertises the given size.

        An icon's ``sizes`` is a space-separated list, so the check splits
        before comparing — a substring test against the raw value would let
        ``"1512x512"`` satisfy a request for ``"512x512"``.

        Args:
            size: The size to look for, e.g. ``"192x192"``.

        Returns:
            ``True`` when at least one icon declares it.
        """
        return any(size in str(icon.get("sizes", "")).split() for icon in icons)

    if not has_size("192x192"):
        errors.append("a 192x192 icon is required")
    if not has_size("512x512"):
        errors.append("a 512x512 icon is required")
    if not any("any" in str(icon.get("purpose", "any")).split() for icon in icons):
        errors.append('at least one icon must have purpose "any"')
    return errors

write_manifest

write_manifest(dest: Path, options: ManifestOptions | None = None, indent: int = 2) -> Path

Build and write manifest.webmanifest to dest.

Parameters:

Name Type Description Default
dest Path

Output file path (parent dirs are created).

required
options ManifestOptions | None

Project overrides.

None
indent int

JSON indentation.

2

Returns:

Type Description
Path

The path written.

Source code in tempestweb/pwa/manifest.py
def write_manifest(
    dest: Path,
    options: ManifestOptions | None = None,
    indent: int = 2,
) -> Path:
    """Build and write ``manifest.webmanifest`` to ``dest``.

    Args:
        dest: Output file path (parent dirs are created).
        options: Project overrides.
        indent: JSON indentation.

    Returns:
        The path written.
    """
    manifest = build_manifest(options)
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_text(emit_manifest(manifest, indent=indent) + "\n", encoding="utf-8")
    return dest

package_digests

package_digests(lock: dict[str, Any]) -> dict[str, str]

Map each wheel file name in the lock to the sha256 it declares.

Parameters:

Name Type Description Default
lock dict[str, Any]

The parsed pyodide-lock.json document.

required

Returns:

Type Description
dict[str, str]

{file_name: sha256} for every entry that publishes a digest.

Source code in tempestweb/pwa/pyodide_vendor.py
def package_digests(lock: dict[str, Any]) -> dict[str, str]:
    """Map each wheel file name in the lock to the ``sha256`` it declares.

    Args:
        lock: The parsed ``pyodide-lock.json`` document.

    Returns:
        ``{file_name: sha256}`` for every entry that publishes a digest.
    """
    packages: dict[str, Any] = lock.get("packages", {})
    return {
        str(entry["file_name"]): str(entry["sha256"])
        for entry in packages.values()
        if entry.get("file_name") and entry.get("sha256")
    }

pyodide_cdn_base

pyodide_cdn_base(version: str) -> str

Return the jsdelivr base URL for a Pyodide release.

Parameters:

Name Type Description Default
version str

The Pyodide release tag (e.g. "v314.0.0").

required

Returns:

Type Description
str

The full/ base URL, with a trailing slash.

Source code in tempestweb/pwa/pyodide_vendor.py
def pyodide_cdn_base(version: str) -> str:
    """Return the jsdelivr base URL for a Pyodide release.

    Args:
        version: The Pyodide release tag (e.g. ``"v314.0.0"``).

    Returns:
        The ``full/`` base URL, with a trailing slash.
    """
    return f"https://cdn.jsdelivr.net/pyodide/{version}/full/"

resolve_package_files

resolve_package_files(lock: dict[str, Any], roots: tuple[str, ...]) -> list[str]

Resolve the wheel files for roots and their transitive dependencies.

Walks the dependency graph in pyodide-lock.json from each root package, collecting every reachable package's file_name.

Parameters:

Name Type Description Default
lock dict[str, Any]

The parsed pyodide-lock.json document.

required
roots tuple[str, ...]

The package names the app imports (e.g. ("pydantic",)).

required

Returns:

Type Description
list[str]

The sorted, de-duplicated list of wheel file names to vendor.

Raises:

Type Description
KeyError

If a required package is absent from the lock file.

Source code in tempestweb/pwa/pyodide_vendor.py
def resolve_package_files(lock: dict[str, Any], roots: tuple[str, ...]) -> list[str]:
    """Resolve the wheel files for ``roots`` and their transitive dependencies.

    Walks the dependency graph in ``pyodide-lock.json`` from each root package,
    collecting every reachable package's ``file_name``.

    Args:
        lock: The parsed ``pyodide-lock.json`` document.
        roots: The package names the app imports (e.g. ``("pydantic",)``).

    Returns:
        The sorted, de-duplicated list of wheel file names to vendor.

    Raises:
        KeyError: If a required package is absent from the lock file.
    """
    packages: dict[str, Any] = lock["packages"]
    by_norm: dict[str, Any] = {_normalize(e["name"]): e for e in packages.values()}
    seen: set[str] = set()
    files: list[str] = []
    stack: list[str] = list(roots)
    while stack:
        key = _normalize(stack.pop())
        if key in seen:
            continue
        seen.add(key)
        entry = by_norm.get(key)
        if entry is None:
            raise KeyError(f"package {key!r} not in pyodide lock")
        files.append(str(entry["file_name"]))
        stack.extend(entry.get("depends", []))
    return sorted(files)

vendor_pyodide

vendor_pyodide(out_dir: str | Path, *, version: str, packages: tuple[str, ...], fetch: Fetcher | None = None) -> list[str]

Download the Pyodide runtime + packages closure into out_dir.

Writes the core runtime files and every resolved wheel as siblings in out_dir (the artifact's pyodide/ directory). The lock file is fetched once and reused to resolve the package closure, then written alongside the rest so the offline indexURL is self-contained.

Parameters:

Name Type Description Default
out_dir str | Path

The directory to write the vendored files into (created if absent).

required
version str

The Pyodide release tag to vendor.

required
packages tuple[str, ...]

The package names the app imports (their closure is vendored).

required
fetch Fetcher | None

Download function (injected in tests). Defaults to an HTTP fetch.

None

Returns:

Type Description
list[str]

The file names written, in fetch order (lock first, then the remaining

list[str]

core files, then the wheels).

Raises:

Type Description
ValueError

If a downloaded wheel does not match the sha256 its lock entry declares.

Source code in tempestweb/pwa/pyodide_vendor.py
def vendor_pyodide(
    out_dir: str | Path,
    *,
    version: str,
    packages: tuple[str, ...],
    fetch: Fetcher | None = None,
) -> list[str]:
    """Download the Pyodide runtime + ``packages`` closure into ``out_dir``.

    Writes the core runtime files and every resolved wheel as siblings in
    ``out_dir`` (the artifact's ``pyodide/`` directory). The lock file is fetched
    once and reused to resolve the package closure, then written alongside the
    rest so the offline ``indexURL`` is self-contained.

    Args:
        out_dir: The directory to write the vendored files into (created if
            absent).
        version: The Pyodide release tag to vendor.
        packages: The package names the app imports (their closure is vendored).
        fetch: Download function (injected in tests). Defaults to an HTTP fetch.

    Returns:
        The file names written, in fetch order (lock first, then the remaining
        core files, then the wheels).

    Raises:
        ValueError: If a downloaded wheel does not match the ``sha256`` its lock
            entry declares.
    """
    downloader: Fetcher = fetch or _default_fetch
    base = pyodide_cdn_base(version)
    dest = Path(out_dir)
    dest.mkdir(parents=True, exist_ok=True)

    lock_bytes = downloader(base + "pyodide-lock.json")
    (dest / "pyodide-lock.json").write_bytes(lock_bytes)
    lock = json.loads(lock_bytes.decode("utf-8"))

    wheels = resolve_package_files(lock, packages)
    digests = package_digests(lock)
    remaining = [f for f in PYODIDE_CORE_FILES if f != "pyodide-lock.json"] + wheels
    for file_name in remaining:
        payload = downloader(base + file_name)
        expected = digests.get(file_name)
        if expected is not None:
            _verify_digest(file_name, payload, expected)
        (dest / file_name).write_bytes(payload)

    return ["pyodide-lock.json", *remaining]