Skip to content
Merged
1 change: 1 addition & 0 deletions changes/2687.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Publication channels are now discovered via entry points, allowing third-party plugins to provide custom ``briefcase publish`` channels.
1 change: 1 addition & 0 deletions docs/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ copyright: © Russell Keith-Magee
not_in_nav: |
/index.md
/reference/platforms/linux/docker_build_options.md
/_snippets/*

validation:
omitted_files: warn
Expand Down
3 changes: 3 additions & 0 deletions docs/en/_snippets/available-channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/// note | Note
There are currently no working publication channels available — neither shipped with Briefcase nor provided by third-party packages. The built-in channels for the iOS App Store ([#2697](https://github.com/beeware/briefcase/issues/2697)) and Google Play Store ([#2698](https://github.com/beeware/briefcase/issues/2698)) are placeholders that raise an error when invoked.
///
4 changes: 3 additions & 1 deletion docs/en/reference/commands/package.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Building installers for some platforms depends on the build tools for the platfo

///

Once packaging is complete, the artefact can be distributed to a store or channel. See the [publish][publish] command and the platform-specific publishing guides for [Android](../../how-to/publishing/android.md), [iOS](../../how-to/publishing/iOS.md), and [macOS](../../how-to/publishing/macOS.md).

## Options

The following options can be provided at the command line.
Expand All @@ -42,7 +44,7 @@ Run a specific application target in your project. This argument is only require

### `-u` / `--update`

Update and recompile the application's code before running. Equivalent to running:
Update and recompile the application's code before packaging. Equivalent to running:

```console
$ briefcase update
Expand Down
30 changes: 26 additions & 4 deletions docs/en/reference/commands/publish.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# publish

**COMING SOON**

Uploads your application to a publication channel. By default, targets the current platform's default output format, using that format's default publication channel.
Uploads your application to a [publication channel][publication-channel-interface]. By default, targets the current platform's default output format.

You may need to provide additional configuration details (e.g., authentication credentials), depending on the publication channel selected.

Expand Down Expand Up @@ -30,6 +28,30 @@ $ briefcase publish <platform> <output format>

The following options can be provided at the command line.

### `-a <app name>` / `--app <app name>`

Publish a specific application target in your project. This argument is only required if your project contains more than one application target. The app name specified should be the machine-readable package name for the app.

### `-u` / `--update`

Update and recompile the application's code before publication. Equivalent to running:

```console
$ briefcase update
$ briefcase package
$ briefcase publish
```

### `-p <format>`, `--packaging-format <format>`

The format to use for packaging. The available packaging formats are platform dependent.

### `-c <channel>` / `--channel <channel>`

Nominate a publication channel to use.
Nominate a [publication channel][publication-channel-interface] to use.

--8<-- "_snippets/available-channels.md"

## Platform guides

For platform-specific publishing workflows, see the how-to guides for [Android](../../how-to/publishing/android.md), [iOS](../../how-to/publishing/iOS.md), and [macOS](../../how-to/publishing/macOS.md).
29 changes: 29 additions & 0 deletions docs/en/reference/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,32 @@ macOS = "briefcase.platforms.macOS"
[project.entry-points."briefcase.formats.macOS"]
xcode = "briefcase.platforms.macOS.xcode"
```

## `briefcase.channels.*` { #publication-channel-interface }

Briefcase provides a plugin interface that enables publishing apps to distribution channels (e.g., the iOS App Store or Google Play Store) via the [publish][publish] command.

--8<-- "_snippets/available-channels.md"

Publication channel entry points are scoped by platform and output format. To add a custom publication channel plugin, add a `[project.entry-points."briefcase.channels.<platform>.<format>"]` section to your `pyproject.toml` file; each name/value pair under that section will be interpreted as a publication channel. The name of each setting is the identifier used to select the channel with `--channel` on the command line. The value is a string identifying a class that implements the `briefcase.channels.base.BasePublicationChannel` abstract base class.

A `BasePublicationChannel` subclass must implement:

- `name` - an abstract `str` property returning the human-readable name of the channel.
- `publish_app(app, command, **options)` - an abstract method that performs the actual publication. `app` is the `AppConfig` for the application being published; `command` satisfies the `PublishCommandAPI` protocol (see below).

The `PublishCommandAPI` protocol (`briefcase.channels.base.PublishCommandAPI`) defines the stable API surface that plugins can rely on from the `command` parameter:

- `console` - the console object for user interaction
- `tools` - the tool registry
- `dist_path` - the path to the distribution output directory
- `distribution_path(app)` - returns the path to the distributable artefact for the given app

If only one channel is registered for a platform/format combination, it is selected automatically. If multiple channels are available, the user must specify `--channel` on the command line.

For example, the iOS App Store channel is implemented using the following configuration:

```toml
[project.entry-points."briefcase.channels.iOS.Xcode"]
appstore = "briefcase.channels.appstore:AppStorePublicationChannel"
```
15 changes: 11 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ windows = "briefcase.platforms.windows"
gradle = "briefcase.platforms.android.gradle"

[project.entry-points."briefcase.formats.iOS"]
xcode = "briefcase.platforms.iOS.xcode"
Xcode = "briefcase.platforms.iOS.xcode"

[project.entry-points."briefcase.formats.linux"]
appimage = "briefcase.platforms.linux.appimage"
Expand All @@ -181,13 +181,13 @@ system = "briefcase.platforms.linux.system"

[project.entry-points."briefcase.formats.macOS"]
app = "briefcase.platforms.macOS.app"
xcode = "briefcase.platforms.macOS.xcode"
Xcode = "briefcase.platforms.macOS.xcode"

# [project.entry-points."briefcase.formats.tvOS"]
# xcode = "briefcase.platforms.tvOS.xcode"
# Xcode = "briefcase.platforms.tvOS.xcode"

# [project.entry-points."briefcase.formats.watchOS"]
# xcode = "briefcase.platforms.watchOS.xcode"
# Xcode = "briefcase.platforms.watchOS.xcode"

# [project.entry-points."briefcase.formats.wearos"]
# gradle = "briefcase.platforms.wearos.gradle"
Expand All @@ -199,6 +199,12 @@ static = "briefcase.platforms.web.static"
app = "briefcase.platforms.windows.app"
visualstudio = "briefcase.platforms.windows.visualstudio"

[project.entry-points."briefcase.channels.iOS.Xcode"]
appstore = "briefcase.channels.appstore:AppStorePublicationChannel"

[project.entry-points."briefcase.channels.android.gradle"]
playstore = "briefcase.channels.playstore:PlayStorePublicationChannel"

[tool.coverage.run]
plugins = ["coverage_conditional_plugin"]
parallel = true
Expand All @@ -220,6 +226,7 @@ precision = 1
exclude_lines = [
"pragma: no cover",
"@(abc\\.)?abstractmethod",
"@runtime_checkable",
"NotImplementedError\\(\\)",
"if TYPE_CHECKING:",
]
Expand Down
47 changes: 47 additions & 0 deletions src/briefcase/channels/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

from importlib.metadata import entry_points

from briefcase.channels.base import (
BasePublicationChannel,
PublishCommandAPI, # noqa: F401
)
from briefcase.exceptions import BriefcaseCommandError


def get_publication_channels(
platform: str,
output_format: str,
) -> dict[str, type[BasePublicationChannel]]:
"""Load built-in and third-party publication channels for a platform/format.

:param platform: The target platform (e.g., "iOS")
:param output_format: The output format (e.g., "Xcode")
:returns: A dict mapping channel names to channel classes.
"""
return {
ep.name: ep.load()
for ep in entry_points(group=f"briefcase.channels.{platform}.{output_format}")
}


def get_publication_channel(
name: str,
platform: str,
output_format: str,
) -> BasePublicationChannel:
"""Get a publication channel by name for a platform/format.

:param name: The channel name
:param platform: The target platform
:param output_format: The output format
:returns: An instantiated publication channel.
"""
channels = get_publication_channels(platform, output_format)
if name not in channels:
available = ", ".join(sorted(channels)) or "none"
raise BriefcaseCommandError(
f"Unknown publication channel: {name}\n\n"
f"Available channels for {platform}/{output_format}: {available}"
)
return channels[name]()
25 changes: 25 additions & 0 deletions src/briefcase/channels/appstore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from briefcase.channels.base import BasePublicationChannel
from briefcase.exceptions import BriefcaseCommandError

if TYPE_CHECKING:
from briefcase.channels.base import PublishCommandAPI
from briefcase.config import AppConfig


class AppStorePublicationChannel(BasePublicationChannel):
"""Placeholder for iOS App Store publication channel."""

@property
def name(self) -> str:
return "appstore"

def publish_app(self, app: AppConfig, command: PublishCommandAPI, **options):
raise BriefcaseCommandError(
"Publishing to the iOS App Store is not yet implemented.\n"
"\n"
"See https://github.com/beeware/briefcase/issues/2697 for progress."
)
42 changes: 42 additions & 0 deletions src/briefcase/channels/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, runtime_checkable

if TYPE_CHECKING:
from briefcase.config import AppConfig
from briefcase.console import Console
from briefcase.integrations.base import ToolCache


@runtime_checkable
class PublishCommandAPI(Protocol):
"""Stable API surface exposed to publication channel plugins.

This defines the minimal set of attributes and methods that a plugin
can rely on from the ``command`` parameter passed to ``publish_app()``.
"""

console: Console
tools: ToolCache
dist_path: Path

def distribution_path(self, app: AppConfig) -> Path: ...


class BasePublicationChannel(ABC):
"""Definition for a plugin that defines a new Briefcase publication channel."""

@property
@abstractmethod
def name(self) -> str:
"""The name of the publication channel."""

@abstractmethod
def publish_app(self, app: AppConfig, command: PublishCommandAPI, **options):
"""Publish an application to this channel.

:param app: The application to publish
:param command: The publish command instance
"""
25 changes: 25 additions & 0 deletions src/briefcase/channels/playstore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from briefcase.channels.base import BasePublicationChannel
from briefcase.exceptions import BriefcaseCommandError

if TYPE_CHECKING:
from briefcase.channels.base import PublishCommandAPI
from briefcase.config import AppConfig


class PlayStorePublicationChannel(BasePublicationChannel):
"""Placeholder for Google Play Store publication channel."""

@property
def name(self) -> str:
return "playstore"

def publish_app(self, app: AppConfig, command: PublishCommandAPI, **options):
raise BriefcaseCommandError(
"Publishing to the Google Play Store is not yet implemented.\n"
"\n"
"See https://github.com/beeware/briefcase/issues/2698 for progress."
)
22 changes: 22 additions & 0 deletions src/briefcase/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,28 @@ def finalize_app_config(self, app: AppConfig):
"""
return

def resolve_apps(
self,
app: AppConfig | None = None,
app_name: str | None = None,
) -> dict[str, AppConfig]:
"""Resolve which apps to operate on.

:param app: An explicit app config to use.
:param app_name: The name of the app to look up in the project.
:returns: A dict of app name to AppConfig for the selected apps.
"""
if app_name:
if not (app_obj := self.apps.get(app_name)):
raise BriefcaseCommandError(
f"App '{app_name}' does not exist in this project."
)
return {app_name: app_obj}
elif app:
return {app.app_name: app}
else:
return self.apps

def finalize(
self,
apps: Iterable[AppConfig],
Expand Down
12 changes: 1 addition & 11 deletions src/briefcase/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,17 +138,7 @@ def __call__(
"Cannot specify both --update-stub and --no-update"
)

if app_name:
try:
apps_to_build = {app_name: self.apps[app_name]}
except KeyError:
raise BriefcaseCommandError(
f"App '{app_name}' does not exist in this project."
) from None
elif app:
apps_to_build = {app.app_name: app}
else:
apps_to_build = self.apps
apps_to_build = self.resolve_apps(app=app, app_name=app_name)

# Confirm host compatibility, that all required tools are available,
# and that the app configuration is finalized.
Expand Down
12 changes: 1 addition & 11 deletions src/briefcase/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,17 +1022,7 @@ def __call__(
app_name: str | None = None,
**options,
) -> dict | None:
if app_name:
try:
apps_to_create = {app_name: self.apps[app_name]}
except KeyError:
raise BriefcaseCommandError(
f"App '{app_name}' does not exist in this project."
) from None
elif app:
apps_to_create = {app.app_name: app}
else:
apps_to_create = self.apps
apps_to_create = self.resolve_apps(app=app, app_name=app_name)

# Confirm host compatibility, that all required tools are available,
# and finalize configurations for the apps that will be created.
Expand Down
13 changes: 1 addition & 12 deletions src/briefcase/commands/open.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from abc import abstractmethod

from briefcase.config import AppConfig
from briefcase.exceptions import BriefcaseCommandError

from .base import BaseCommand, full_options

Expand Down Expand Up @@ -62,17 +61,7 @@ def __call__(
app_name: str | None = None,
**options,
):
if app_name:
try:
apps_to_open = {app_name: self.apps[app_name]}
except KeyError:
raise BriefcaseCommandError(
f"App '{app_name}' does not exist in this project."
) from None
elif app:
apps_to_open = {app.app_name: app}
else:
apps_to_open = self.apps
apps_to_open = self.resolve_apps(app=app, app_name=app_name)

# Confirm host compatibility, that all required tools are available,
# and that the app configuration is finalized.
Expand Down
Loading