flwr 1.24.0__py3-none-any.whl → 1.26.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flwr/__init__.py +1 -1
- flwr/app/__init__.py +4 -1
- flwr/app/message_type.py +29 -0
- flwr/app/metadata.py +5 -2
- flwr/app/user_config.py +19 -0
- flwr/cli/app.py +37 -19
- flwr/cli/app_cmd/publish.py +25 -75
- flwr/cli/app_cmd/review.py +25 -66
- flwr/cli/auth_plugin/auth_plugin.py +5 -10
- flwr/cli/auth_plugin/noop_auth_plugin.py +1 -2
- flwr/cli/auth_plugin/oidc_cli_plugin.py +38 -38
- flwr/cli/build.py +15 -28
- flwr/cli/config/__init__.py +21 -0
- flwr/cli/config/ls.py +71 -0
- flwr/cli/config_migration.py +297 -0
- flwr/cli/config_utils.py +63 -156
- flwr/cli/constant.py +71 -0
- flwr/cli/federation/__init__.py +0 -2
- flwr/cli/federation/ls.py +256 -64
- flwr/cli/flower_config.py +429 -0
- flwr/cli/install.py +23 -62
- flwr/cli/log.py +23 -37
- flwr/cli/login/login.py +29 -63
- flwr/cli/ls.py +72 -61
- flwr/cli/new/new.py +98 -309
- flwr/cli/pull.py +19 -37
- flwr/cli/run/run.py +87 -100
- flwr/cli/run_utils.py +23 -5
- flwr/cli/stop.py +33 -74
- flwr/cli/supernode/ls.py +35 -62
- flwr/cli/supernode/register.py +31 -80
- flwr/cli/supernode/unregister.py +24 -70
- flwr/cli/typing.py +200 -0
- flwr/cli/utils.py +160 -412
- flwr/client/grpc_adapter_client/connection.py +2 -2
- flwr/client/grpc_rere_client/connection.py +9 -6
- flwr/client/grpc_rere_client/grpc_adapter.py +1 -1
- flwr/client/message_handler/message_handler.py +2 -1
- flwr/client/mod/centraldp_mods.py +1 -1
- flwr/client/mod/localdp_mod.py +1 -1
- flwr/client/mod/secure_aggregation/secaggplus_mod.py +1 -1
- flwr/client/rest_client/connection.py +6 -4
- flwr/client/run_info_store.py +2 -1
- flwr/clientapp/client_app.py +2 -1
- flwr/common/__init__.py +3 -2
- flwr/common/args.py +5 -5
- flwr/common/config.py +12 -17
- flwr/common/constant.py +3 -16
- flwr/common/context.py +2 -1
- flwr/common/exit/exit.py +4 -4
- flwr/common/exit/exit_code.py +6 -0
- flwr/common/grpc.py +2 -1
- flwr/common/logger.py +1 -1
- flwr/common/message.py +1 -1
- flwr/common/retry_invoker.py +13 -5
- flwr/common/secure_aggregation/ndarrays_arithmetic.py +5 -2
- flwr/common/serde.py +13 -5
- flwr/common/telemetry.py +1 -1
- flwr/common/typing.py +10 -3
- flwr/compat/client/app.py +6 -9
- flwr/compat/client/grpc_client/connection.py +2 -1
- flwr/compat/common/constant.py +29 -0
- flwr/compat/server/app.py +1 -1
- flwr/proto/clientappio_pb2.py +2 -2
- flwr/proto/clientappio_pb2_grpc.py +104 -88
- flwr/proto/clientappio_pb2_grpc.pyi +140 -80
- flwr/proto/federation_pb2.py +5 -3
- flwr/proto/federation_pb2.pyi +32 -2
- flwr/proto/fleet_pb2.py +10 -10
- flwr/proto/fleet_pb2.pyi +5 -1
- flwr/proto/run_pb2.py +18 -26
- flwr/proto/run_pb2.pyi +10 -58
- flwr/proto/serverappio_pb2.py +2 -2
- flwr/proto/serverappio_pb2_grpc.py +138 -207
- flwr/proto/serverappio_pb2_grpc.pyi +189 -155
- flwr/proto/simulationio_pb2.py +2 -2
- flwr/proto/simulationio_pb2_grpc.py +62 -90
- flwr/proto/simulationio_pb2_grpc.pyi +95 -55
- flwr/server/app.py +7 -13
- flwr/server/compat/grid_client_proxy.py +2 -1
- flwr/server/grid/grpc_grid.py +5 -5
- flwr/server/serverapp/app.py +11 -4
- flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +1 -1
- flwr/server/superlink/fleet/grpc_rere/node_auth_server_interceptor.py +13 -12
- flwr/server/superlink/fleet/message_handler/message_handler.py +42 -2
- flwr/server/superlink/linkstate/__init__.py +2 -2
- flwr/server/superlink/linkstate/in_memory_linkstate.py +36 -10
- flwr/server/superlink/linkstate/linkstate.py +34 -21
- flwr/server/superlink/linkstate/linkstate_factory.py +16 -8
- flwr/server/superlink/linkstate/{sqlite_linkstate.py → sql_linkstate.py} +471 -516
- flwr/server/superlink/linkstate/utils.py +49 -2
- flwr/server/superlink/serverappio/serverappio_servicer.py +1 -33
- flwr/server/superlink/simulation/simulationio_servicer.py +0 -19
- flwr/server/utils/validator.py +1 -1
- flwr/server/workflow/default_workflows.py +2 -1
- flwr/server/workflow/secure_aggregation/secaggplus_workflow.py +1 -1
- flwr/serverapp/strategy/bulyan.py +7 -1
- flwr/serverapp/strategy/dp_fixed_clipping.py +9 -1
- flwr/serverapp/strategy/fedavg.py +1 -1
- flwr/serverapp/strategy/fedxgb_cyclic.py +1 -1
- flwr/simulation/ray_transport/ray_client_proxy.py +2 -6
- flwr/simulation/run_simulation.py +3 -12
- flwr/simulation/simulationio_connection.py +3 -3
- flwr/{common → supercore}/address.py +7 -33
- flwr/supercore/app_utils.py +2 -1
- flwr/supercore/constant.py +27 -2
- flwr/supercore/corestate/{sqlite_corestate.py → sql_corestate.py} +19 -23
- flwr/supercore/credential_store/__init__.py +33 -0
- flwr/supercore/credential_store/credential_store.py +34 -0
- flwr/supercore/credential_store/file_credential_store.py +76 -0
- flwr/{common → supercore}/date.py +0 -11
- flwr/supercore/ffs/disk_ffs.py +1 -1
- flwr/supercore/object_store/object_store_factory.py +14 -6
- flwr/supercore/object_store/{sqlite_object_store.py → sql_object_store.py} +115 -117
- flwr/supercore/sql_mixin.py +315 -0
- flwr/{cli/new/templates → supercore/state}/__init__.py +2 -2
- flwr/{cli/new/templates/app/code/flwr_tune → supercore/state/alembic}/__init__.py +2 -2
- flwr/supercore/state/alembic/env.py +103 -0
- flwr/supercore/state/alembic/script.py.mako +43 -0
- flwr/supercore/state/alembic/utils.py +239 -0
- flwr/{cli/new/templates/app → supercore/state/alembic/versions}/__init__.py +2 -2
- flwr/supercore/state/alembic/versions/rev_2026_01_28_initialize_migration_of_state_tables.py +200 -0
- flwr/supercore/state/schema/README.md +121 -0
- flwr/{cli/new/templates/app/code → supercore/state/schema}/__init__.py +2 -2
- flwr/supercore/state/schema/corestate_tables.py +36 -0
- flwr/supercore/state/schema/linkstate_tables.py +152 -0
- flwr/supercore/state/schema/objectstore_tables.py +90 -0
- flwr/supercore/superexec/run_superexec.py +2 -2
- flwr/supercore/utils.py +225 -0
- flwr/superlink/federation/federation_manager.py +2 -2
- flwr/superlink/federation/noop_federation_manager.py +8 -6
- flwr/superlink/servicer/control/control_grpc.py +2 -0
- flwr/superlink/servicer/control/control_servicer.py +106 -21
- flwr/supernode/cli/flower_supernode.py +2 -1
- flwr/supernode/nodestate/in_memory_nodestate.py +62 -1
- flwr/supernode/nodestate/nodestate.py +45 -0
- flwr/supernode/runtime/run_clientapp.py +14 -14
- flwr/supernode/servicer/clientappio/clientappio_servicer.py +13 -5
- flwr/supernode/start_client_internal.py +17 -10
- {flwr-1.24.0.dist-info → flwr-1.26.0.dist-info}/METADATA +8 -8
- {flwr-1.24.0.dist-info → flwr-1.26.0.dist-info}/RECORD +144 -184
- flwr/cli/federation/show.py +0 -317
- flwr/cli/new/templates/app/.gitignore.tpl +0 -163
- flwr/cli/new/templates/app/LICENSE.tpl +0 -202
- flwr/cli/new/templates/app/README.baseline.md.tpl +0 -127
- flwr/cli/new/templates/app/README.flowertune.md.tpl +0 -68
- flwr/cli/new/templates/app/README.md.tpl +0 -37
- flwr/cli/new/templates/app/code/__init__.baseline.py.tpl +0 -1
- flwr/cli/new/templates/app/code/__init__.py.tpl +0 -1
- flwr/cli/new/templates/app/code/__init__.pytorch_legacy_api.py.tpl +0 -1
- flwr/cli/new/templates/app/code/client.baseline.py.tpl +0 -75
- flwr/cli/new/templates/app/code/client.huggingface.py.tpl +0 -93
- flwr/cli/new/templates/app/code/client.jax.py.tpl +0 -71
- flwr/cli/new/templates/app/code/client.mlx.py.tpl +0 -102
- flwr/cli/new/templates/app/code/client.numpy.py.tpl +0 -46
- flwr/cli/new/templates/app/code/client.pytorch.py.tpl +0 -80
- flwr/cli/new/templates/app/code/client.pytorch_legacy_api.py.tpl +0 -55
- flwr/cli/new/templates/app/code/client.sklearn.py.tpl +0 -108
- flwr/cli/new/templates/app/code/client.tensorflow.py.tpl +0 -82
- flwr/cli/new/templates/app/code/client.xgboost.py.tpl +0 -110
- flwr/cli/new/templates/app/code/dataset.baseline.py.tpl +0 -36
- flwr/cli/new/templates/app/code/flwr_tune/client_app.py.tpl +0 -92
- flwr/cli/new/templates/app/code/flwr_tune/dataset.py.tpl +0 -87
- flwr/cli/new/templates/app/code/flwr_tune/models.py.tpl +0 -56
- flwr/cli/new/templates/app/code/flwr_tune/server_app.py.tpl +0 -73
- flwr/cli/new/templates/app/code/flwr_tune/strategy.py.tpl +0 -78
- flwr/cli/new/templates/app/code/model.baseline.py.tpl +0 -66
- flwr/cli/new/templates/app/code/server.baseline.py.tpl +0 -43
- flwr/cli/new/templates/app/code/server.huggingface.py.tpl +0 -42
- flwr/cli/new/templates/app/code/server.jax.py.tpl +0 -39
- flwr/cli/new/templates/app/code/server.mlx.py.tpl +0 -41
- flwr/cli/new/templates/app/code/server.numpy.py.tpl +0 -38
- flwr/cli/new/templates/app/code/server.pytorch.py.tpl +0 -41
- flwr/cli/new/templates/app/code/server.pytorch_legacy_api.py.tpl +0 -31
- flwr/cli/new/templates/app/code/server.sklearn.py.tpl +0 -44
- flwr/cli/new/templates/app/code/server.tensorflow.py.tpl +0 -38
- flwr/cli/new/templates/app/code/server.xgboost.py.tpl +0 -56
- flwr/cli/new/templates/app/code/strategy.baseline.py.tpl +0 -1
- flwr/cli/new/templates/app/code/task.huggingface.py.tpl +0 -98
- flwr/cli/new/templates/app/code/task.jax.py.tpl +0 -57
- flwr/cli/new/templates/app/code/task.mlx.py.tpl +0 -102
- flwr/cli/new/templates/app/code/task.numpy.py.tpl +0 -7
- flwr/cli/new/templates/app/code/task.pytorch.py.tpl +0 -99
- flwr/cli/new/templates/app/code/task.pytorch_legacy_api.py.tpl +0 -111
- flwr/cli/new/templates/app/code/task.sklearn.py.tpl +0 -67
- flwr/cli/new/templates/app/code/task.tensorflow.py.tpl +0 -52
- flwr/cli/new/templates/app/code/task.xgboost.py.tpl +0 -67
- flwr/cli/new/templates/app/code/utils.baseline.py.tpl +0 -1
- flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +0 -146
- flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +0 -80
- flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +0 -65
- flwr/cli/new/templates/app/pyproject.jax.toml.tpl +0 -52
- flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +0 -56
- flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +0 -49
- flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +0 -53
- flwr/cli/new/templates/app/pyproject.pytorch_legacy_api.toml.tpl +0 -53
- flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +0 -52
- flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +0 -53
- flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +0 -61
- flwr/common/pyproject.py +0 -42
- flwr/supercore/sqlite_mixin.py +0 -159
- /flwr/{common → supercore}/version.py +0 -0
- {flwr-1.24.0.dist-info → flwr-1.26.0.dist-info}/WHEEL +0 -0
- {flwr-1.24.0.dist-info → flwr-1.26.0.dist-info}/entry_points.txt +0 -0
flwr/cli/utils.py
CHANGED
|
@@ -18,21 +18,25 @@
|
|
|
18
18
|
import hashlib
|
|
19
19
|
import json
|
|
20
20
|
import re
|
|
21
|
+
import sys
|
|
21
22
|
from collections.abc import Callable, Iterable, Iterator
|
|
22
23
|
from contextlib import contextmanager
|
|
24
|
+
from io import StringIO
|
|
23
25
|
from pathlib import Path
|
|
24
26
|
from typing import Any, cast
|
|
25
27
|
|
|
28
|
+
import click
|
|
26
29
|
import grpc
|
|
27
30
|
import pathspec
|
|
28
|
-
import requests
|
|
29
31
|
import typer
|
|
32
|
+
from rich.console import Console
|
|
30
33
|
|
|
34
|
+
from flwr.cli.typing import SuperLinkConnection
|
|
31
35
|
from flwr.common.constant import (
|
|
32
36
|
ACCESS_TOKEN_KEY,
|
|
33
37
|
AUTHN_TYPE_JSON_KEY,
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
FEDERATION_NOT_FOUND_MESSAGE,
|
|
39
|
+
FEDERATION_NOT_SPECIFIED_MESSAGE,
|
|
36
40
|
NO_ACCOUNT_AUTH_MESSAGE,
|
|
37
41
|
NO_ARTIFACT_PROVIDER_MESSAGE,
|
|
38
42
|
NODE_NOT_FOUND_MESSAGE,
|
|
@@ -42,18 +46,67 @@ from flwr.common.constant import (
|
|
|
42
46
|
REFRESH_TOKEN_KEY,
|
|
43
47
|
RUN_ID_NOT_FOUND_MESSAGE,
|
|
44
48
|
AuthnType,
|
|
49
|
+
CliOutputFormat,
|
|
45
50
|
)
|
|
46
51
|
from flwr.common.grpc import (
|
|
47
52
|
GRPC_MAX_MESSAGE_LENGTH,
|
|
48
53
|
create_channel,
|
|
49
54
|
on_channel_state_change,
|
|
50
55
|
)
|
|
51
|
-
from flwr.common.
|
|
52
|
-
from flwr.supercore.
|
|
56
|
+
from flwr.common.logger import print_json_error, redirect_output, restore_output
|
|
57
|
+
from flwr.supercore.credential_store import get_credential_store
|
|
53
58
|
|
|
54
59
|
from .auth_plugin import CliAuthPlugin, get_cli_plugin_class
|
|
55
60
|
from .cli_account_auth_interceptor import CliAccountAuthInterceptor
|
|
56
|
-
from .config_utils import
|
|
61
|
+
from .config_utils import load_certificate_in_connection
|
|
62
|
+
from .constant import AUTHN_TYPE_STORE_KEY
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def print_json_to_stdout(data: str | Any) -> None:
|
|
66
|
+
"""Print JSON data to stdout, bypassing any output redirection.
|
|
67
|
+
|
|
68
|
+
Use this function within the `cli_output_handler` context manager to print JSON
|
|
69
|
+
output directly to the terminal, even when stdout is being captured.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(data, str):
|
|
72
|
+
Console(file=sys.__stdout__).print_json(data)
|
|
73
|
+
else:
|
|
74
|
+
Console(file=sys.__stdout__).print_json(data=data)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextmanager # docsig: ignore=SIG503
|
|
78
|
+
def cli_output_handler(
|
|
79
|
+
output_format: str = CliOutputFormat.DEFAULT,
|
|
80
|
+
) -> Iterator[bool]:
|
|
81
|
+
"""Context manager for handling CLI output in different formats.
|
|
82
|
+
|
|
83
|
+
This context manager provides consistent output handling for CLI commands by:
|
|
84
|
+
- Redirecting stdout/stderr when JSON format is requested
|
|
85
|
+
- Catching and handling exceptions appropriately based on the output format
|
|
86
|
+
|
|
87
|
+
Use the `print_json_to_stdout()` utility function to print JSON output that bypasses
|
|
88
|
+
output redirection.
|
|
89
|
+
"""
|
|
90
|
+
is_json = output_format == CliOutputFormat.JSON
|
|
91
|
+
captured_output = StringIO()
|
|
92
|
+
|
|
93
|
+
if is_json:
|
|
94
|
+
redirect_output(captured_output)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
yield is_json
|
|
98
|
+
except Exception as err: # pylint: disable=broad-except
|
|
99
|
+
if is_json:
|
|
100
|
+
restore_output()
|
|
101
|
+
print_json_error(captured_output.getvalue(), err)
|
|
102
|
+
else:
|
|
103
|
+
if isinstance(err, typer.Exit):
|
|
104
|
+
raise # Allow typer.Exit to escape normally
|
|
105
|
+
raise click.ClickException(str(err)) from None
|
|
106
|
+
finally:
|
|
107
|
+
if is_json:
|
|
108
|
+
restore_output()
|
|
109
|
+
captured_output.close()
|
|
57
110
|
|
|
58
111
|
|
|
59
112
|
def prompt_text(
|
|
@@ -158,46 +211,6 @@ def is_valid_project_name(name: str) -> bool:
|
|
|
158
211
|
return True
|
|
159
212
|
|
|
160
213
|
|
|
161
|
-
def sanitize_project_name(name: str) -> str:
|
|
162
|
-
"""Sanitize the given string to make it a valid Python project name.
|
|
163
|
-
|
|
164
|
-
This function replaces spaces, dots, slashes, and underscores with dashes, removes
|
|
165
|
-
any characters not allowed in Python project names, makes the string lowercase, and
|
|
166
|
-
ensures it starts with a valid character.
|
|
167
|
-
|
|
168
|
-
Parameters
|
|
169
|
-
----------
|
|
170
|
-
name : str
|
|
171
|
-
The project name to sanitize.
|
|
172
|
-
|
|
173
|
-
Returns
|
|
174
|
-
-------
|
|
175
|
-
str
|
|
176
|
-
The sanitized project name that is valid for Python projects.
|
|
177
|
-
"""
|
|
178
|
-
# Replace whitespace with '_'
|
|
179
|
-
name_with_hyphens = re.sub(r"[ ./_]", "-", name)
|
|
180
|
-
|
|
181
|
-
# Allowed characters in a module name: letters, digits, underscore
|
|
182
|
-
allowed_chars = set(
|
|
183
|
-
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
# Make the string lowercase
|
|
187
|
-
sanitized_name = name_with_hyphens.lower()
|
|
188
|
-
|
|
189
|
-
# Remove any characters not allowed in Python module names
|
|
190
|
-
sanitized_name = "".join(c for c in sanitized_name if c in allowed_chars)
|
|
191
|
-
|
|
192
|
-
# Ensure the first character is a letter or underscore
|
|
193
|
-
while sanitized_name and (
|
|
194
|
-
sanitized_name[0].isdigit() or sanitized_name[0] not in allowed_chars
|
|
195
|
-
):
|
|
196
|
-
sanitized_name = sanitized_name[1:]
|
|
197
|
-
|
|
198
|
-
return sanitized_name
|
|
199
|
-
|
|
200
|
-
|
|
201
214
|
def get_sha256_hash(file_path_or_int: Path | int) -> str:
|
|
202
215
|
"""Calculate the SHA-256 hash of a file or integer.
|
|
203
216
|
|
|
@@ -224,121 +237,27 @@ def get_sha256_hash(file_path_or_int: Path | int) -> str:
|
|
|
224
237
|
return sha256.hexdigest()
|
|
225
238
|
|
|
226
239
|
|
|
227
|
-
def
|
|
228
|
-
"""
|
|
240
|
+
def get_authn_type(host: str) -> str:
|
|
241
|
+
"""Retrieve the authentication type for the given host from the credential store.
|
|
229
242
|
|
|
230
|
-
|
|
231
|
-
include the `.credentials` folder to be excluded from git. If the `.gitignore`
|
|
232
|
-
file already exists, a warning will be displayed if the `.credentials` entry is
|
|
233
|
-
not found.
|
|
243
|
+
`AuthnType.NOOP` is returned if no authentication type is found.
|
|
234
244
|
"""
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
credentials_dir.mkdir(parents=True, exist_ok=True)
|
|
239
|
-
|
|
240
|
-
# Determine the absolute path of the Flower directory for .gitignore
|
|
241
|
-
gitignore_path = abs_flwr_dir / ".gitignore"
|
|
242
|
-
credential_entry = CREDENTIALS_DIR
|
|
243
|
-
|
|
244
|
-
try:
|
|
245
|
-
if gitignore_path.exists():
|
|
246
|
-
with open(gitignore_path, encoding="utf-8") as gitignore_file:
|
|
247
|
-
lines = gitignore_file.read().splitlines()
|
|
248
|
-
|
|
249
|
-
# Warn if .credentials is not already in .gitignore
|
|
250
|
-
if credential_entry not in lines:
|
|
251
|
-
typer.secho(
|
|
252
|
-
f"`.gitignore` exists, but `{credential_entry}` entry not found. "
|
|
253
|
-
"Consider adding it to your `.gitignore` to exclude Flower "
|
|
254
|
-
"credentials from git.",
|
|
255
|
-
fg=typer.colors.YELLOW,
|
|
256
|
-
bold=True,
|
|
257
|
-
)
|
|
258
|
-
else:
|
|
259
|
-
typer.secho(
|
|
260
|
-
f"Creating a new `.gitignore` with `{credential_entry}` entry...",
|
|
261
|
-
fg=typer.colors.BLUE,
|
|
262
|
-
)
|
|
263
|
-
# Create a new .gitignore with .credentials
|
|
264
|
-
with open(gitignore_path, "w", encoding="utf-8") as gitignore_file:
|
|
265
|
-
gitignore_file.write(f"{credential_entry}\n")
|
|
266
|
-
except Exception as err:
|
|
267
|
-
typer.secho(
|
|
268
|
-
"❌ An error occurred while handling `.gitignore.` "
|
|
269
|
-
f"Please check the permissions of `{gitignore_path}` and try again.",
|
|
270
|
-
fg=typer.colors.RED,
|
|
271
|
-
bold=True,
|
|
272
|
-
err=True,
|
|
273
|
-
)
|
|
274
|
-
raise typer.Exit(code=1) from err
|
|
275
|
-
|
|
276
|
-
return credentials_dir / f"{federation}.json"
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def account_auth_enabled(federation_config: dict[str, Any]) -> bool:
|
|
280
|
-
"""Check if account authentication is enabled in the federation config.
|
|
281
|
-
|
|
282
|
-
Parameters
|
|
283
|
-
----------
|
|
284
|
-
federation_config : dict[str, Any]
|
|
285
|
-
The federation configuration dictionary.
|
|
286
|
-
|
|
287
|
-
Returns
|
|
288
|
-
-------
|
|
289
|
-
bool
|
|
290
|
-
True if account authentication is enabled, False otherwise.
|
|
291
|
-
"""
|
|
292
|
-
enabled: bool = federation_config.get("enable-user-auth", False)
|
|
293
|
-
enabled |= federation_config.get("enable-account-auth", False)
|
|
294
|
-
if "enable-user-auth" in federation_config:
|
|
295
|
-
typer.secho(
|
|
296
|
-
"`enable-user-auth` is deprecated and will be removed in a future "
|
|
297
|
-
"release. Please use `enable-account-auth` instead.",
|
|
298
|
-
fg=typer.colors.YELLOW,
|
|
299
|
-
bold=True,
|
|
300
|
-
)
|
|
301
|
-
return enabled
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def retrieve_authn_type(config_path: Path) -> str:
|
|
305
|
-
"""Retrieve the auth type from the config file or return NOOP if not found.
|
|
306
|
-
|
|
307
|
-
Parameters
|
|
308
|
-
----------
|
|
309
|
-
config_path : Path
|
|
310
|
-
Path to the authentication configuration file.
|
|
311
|
-
|
|
312
|
-
Returns
|
|
313
|
-
-------
|
|
314
|
-
str
|
|
315
|
-
The authentication type string, or AuthnType.NOOP if not found.
|
|
316
|
-
"""
|
|
317
|
-
try:
|
|
318
|
-
with config_path.open("r", encoding="utf-8") as file:
|
|
319
|
-
json_file = json.load(file)
|
|
320
|
-
authn_type: str = json_file[AUTHN_TYPE_JSON_KEY]
|
|
321
|
-
return authn_type
|
|
322
|
-
except (FileNotFoundError, KeyError):
|
|
245
|
+
store = get_credential_store()
|
|
246
|
+
authn_type = store.get(AUTHN_TYPE_STORE_KEY % host)
|
|
247
|
+
if authn_type is None:
|
|
323
248
|
return AuthnType.NOOP
|
|
249
|
+
return authn_type.decode("utf-8")
|
|
324
250
|
|
|
325
251
|
|
|
326
|
-
def
|
|
327
|
-
|
|
328
|
-
federation: str,
|
|
329
|
-
federation_config: dict[str, Any],
|
|
330
|
-
authn_type: str | None = None,
|
|
252
|
+
def load_cli_auth_plugin_from_connection(
|
|
253
|
+
host: str, authn_type: str | None = None
|
|
331
254
|
) -> CliAuthPlugin:
|
|
332
|
-
"""Load the CLI-side account auth plugin for the given
|
|
255
|
+
"""Load the CLI-side account auth plugin for the given connection.
|
|
333
256
|
|
|
334
257
|
Parameters
|
|
335
258
|
----------
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
federation : str
|
|
339
|
-
Name of the federation.
|
|
340
|
-
federation_config : dict[str, Any]
|
|
341
|
-
Federation configuration dictionary.
|
|
259
|
+
host : str
|
|
260
|
+
The SuperLink Control API address.
|
|
342
261
|
authn_type : str | None
|
|
343
262
|
Authentication type. If None, will be determined from config.
|
|
344
263
|
|
|
@@ -349,41 +268,48 @@ def load_cli_auth_plugin(
|
|
|
349
268
|
|
|
350
269
|
Raises
|
|
351
270
|
------
|
|
352
|
-
|
|
271
|
+
click.ClickException
|
|
353
272
|
If the authentication type is unknown.
|
|
354
273
|
"""
|
|
355
|
-
# Find the path to the account auth config file
|
|
356
|
-
config_path = get_account_auth_config_path(root_dir, federation)
|
|
357
|
-
|
|
358
274
|
# Determine the auth type if not provided
|
|
359
275
|
# Only `flwr login` command can provide `authn_type` explicitly, as it can query the
|
|
360
276
|
# SuperLink for the auth type.
|
|
361
277
|
if authn_type is None:
|
|
362
|
-
authn_type =
|
|
363
|
-
if account_auth_enabled(federation_config):
|
|
364
|
-
authn_type = retrieve_authn_type(config_path)
|
|
278
|
+
authn_type = get_authn_type(host)
|
|
365
279
|
|
|
366
280
|
# Retrieve auth plugin class and instantiate it
|
|
367
281
|
try:
|
|
368
282
|
auth_plugin_class = get_cli_plugin_class(authn_type)
|
|
369
|
-
return auth_plugin_class(
|
|
283
|
+
return auth_plugin_class(host)
|
|
370
284
|
except ValueError:
|
|
371
|
-
|
|
372
|
-
|
|
285
|
+
raise click.ClickException(
|
|
286
|
+
f"Unknown account authentication type: {authn_type}"
|
|
287
|
+
) from None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def require_superlink_address(connection: SuperLinkConnection) -> str:
|
|
291
|
+
"""Return the SuperLink address or exit if it is not configured."""
|
|
292
|
+
if connection.address is None:
|
|
293
|
+
cmd = click.get_current_context().command.name
|
|
294
|
+
raise click.ClickException(
|
|
295
|
+
f"`flwr {cmd}` currently works with a SuperLink. Ensure that the "
|
|
296
|
+
"correct SuperLink (Control API) address is provided SuperLink connection "
|
|
297
|
+
"you are using. Check your Flower configuration file. You may use `flwr "
|
|
298
|
+
"config list` to see its location in the file system."
|
|
299
|
+
)
|
|
300
|
+
return connection.address
|
|
373
301
|
|
|
374
302
|
|
|
375
|
-
def
|
|
376
|
-
|
|
303
|
+
def init_channel_from_connection(
|
|
304
|
+
connection: SuperLinkConnection, auth_plugin: CliAuthPlugin | None = None
|
|
377
305
|
) -> grpc.Channel:
|
|
378
306
|
"""Initialize gRPC channel to the Control API.
|
|
379
307
|
|
|
380
308
|
Parameters
|
|
381
309
|
----------
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
Federation configuration dictionary containing address and TLS settings.
|
|
386
|
-
auth_plugin : CliAuthPlugin
|
|
310
|
+
connection : SuperLinkConnection
|
|
311
|
+
SuperLink connection configuration.
|
|
312
|
+
auth_plugin : CliAuthPlugin | None (default: None)
|
|
387
313
|
Authentication plugin instance for handling credentials.
|
|
388
314
|
|
|
389
315
|
Returns
|
|
@@ -391,17 +317,20 @@ def init_channel(
|
|
|
391
317
|
grpc.Channel
|
|
392
318
|
Configured gRPC channel with authentication interceptors.
|
|
393
319
|
"""
|
|
394
|
-
|
|
395
|
-
app, federation_config
|
|
396
|
-
)
|
|
320
|
+
address = require_superlink_address(connection)
|
|
397
321
|
|
|
322
|
+
root_certificates_bytes = load_certificate_in_connection(connection)
|
|
323
|
+
|
|
324
|
+
# Load authentication plugin
|
|
325
|
+
if auth_plugin is None:
|
|
326
|
+
auth_plugin = load_cli_auth_plugin_from_connection(address)
|
|
398
327
|
# Load tokens
|
|
399
328
|
auth_plugin.load_tokens()
|
|
400
329
|
|
|
401
330
|
# Create the gRPC channel
|
|
402
331
|
channel = create_channel(
|
|
403
|
-
server_address=
|
|
404
|
-
insecure=insecure,
|
|
332
|
+
server_address=address,
|
|
333
|
+
insecure=connection.insecure,
|
|
405
334
|
root_certificates=root_certificates_bytes,
|
|
406
335
|
max_message_length=GRPC_MAX_MESSAGE_LENGTH,
|
|
407
336
|
interceptors=[CliAccountAuthInterceptor(auth_plugin)],
|
|
@@ -426,7 +355,7 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]: # pylint: disable=too-many-b
|
|
|
426
355
|
|
|
427
356
|
Raises
|
|
428
357
|
------
|
|
429
|
-
|
|
358
|
+
click.ClickException
|
|
430
359
|
On handled gRPC error statuses with appropriate exit code.
|
|
431
360
|
grpc.RpcError
|
|
432
361
|
For unhandled gRPC error statuses.
|
|
@@ -435,206 +364,77 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]: # pylint: disable=too-many-b
|
|
|
435
364
|
yield
|
|
436
365
|
except grpc.RpcError as e:
|
|
437
366
|
if e.code() == grpc.StatusCode.UNAUTHENTICATED:
|
|
438
|
-
|
|
439
|
-
"
|
|
440
|
-
" to authenticate and try again."
|
|
441
|
-
|
|
442
|
-
bold=True,
|
|
443
|
-
err=True,
|
|
444
|
-
)
|
|
445
|
-
raise typer.Exit(code=1) from None
|
|
367
|
+
raise click.ClickException(
|
|
368
|
+
"Authentication failed. Please run `flwr login`"
|
|
369
|
+
" to authenticate and try again."
|
|
370
|
+
) from None
|
|
446
371
|
if e.code() == grpc.StatusCode.UNIMPLEMENTED:
|
|
447
372
|
if e.details() == NO_ACCOUNT_AUTH_MESSAGE: # pylint: disable=E1101
|
|
448
|
-
|
|
449
|
-
"
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
"❌ The SuperLink cannot process this request. Please verify that "
|
|
464
|
-
"you set the address to its Control API endpoint correctly in your "
|
|
465
|
-
"`pyproject.toml`, and ensure that the Flower versions used by "
|
|
466
|
-
"the CLI and SuperLink are compatible.",
|
|
467
|
-
fg=typer.colors.RED,
|
|
468
|
-
bold=True,
|
|
469
|
-
err=True,
|
|
470
|
-
)
|
|
471
|
-
raise typer.Exit(code=1) from None
|
|
373
|
+
raise click.ClickException(
|
|
374
|
+
"Account authentication is not enabled on this SuperLink."
|
|
375
|
+
) from None
|
|
376
|
+
if e.details() == NO_ARTIFACT_PROVIDER_MESSAGE: # pylint: disable=E1101
|
|
377
|
+
raise click.ClickException(
|
|
378
|
+
"The SuperLink does not support `flwr pull` command."
|
|
379
|
+
) from None
|
|
380
|
+
raise click.ClickException(
|
|
381
|
+
"The SuperLink cannot process this request. Please verify that "
|
|
382
|
+
"you set the address to its Control API endpoint correctly in your "
|
|
383
|
+
"SuperLink connection in your Flower Configuration file. You may use "
|
|
384
|
+
"`flwr config list` to see its location in the file system. "
|
|
385
|
+
"Additonally, ensure that the Flower versions used by the CLI and "
|
|
386
|
+
"SuperLink are compatible."
|
|
387
|
+
) from None
|
|
472
388
|
if e.code() == grpc.StatusCode.PERMISSION_DENIED:
|
|
473
|
-
typer.secho(
|
|
474
|
-
"❌ Permission denied.",
|
|
475
|
-
fg=typer.colors.RED,
|
|
476
|
-
bold=True,
|
|
477
|
-
err=True,
|
|
478
|
-
)
|
|
479
389
|
# pylint: disable-next=E1101
|
|
480
|
-
|
|
481
|
-
raise typer.Exit(code=1) from None
|
|
390
|
+
raise click.ClickException(f"Permission denied.\n{e.details()}") from None
|
|
482
391
|
if e.code() == grpc.StatusCode.UNAVAILABLE:
|
|
483
|
-
|
|
392
|
+
raise click.ClickException(
|
|
484
393
|
"Connection to the SuperLink is unavailable. Please check your network "
|
|
485
|
-
"connection and 'address' in the
|
|
486
|
-
|
|
487
|
-
bold=True,
|
|
488
|
-
err=True,
|
|
489
|
-
)
|
|
490
|
-
raise typer.Exit(code=1) from None
|
|
394
|
+
"connection and 'address' in the SuperLink connection configuration."
|
|
395
|
+
) from None
|
|
491
396
|
if e.code() == grpc.StatusCode.NOT_FOUND:
|
|
492
397
|
if e.details() == RUN_ID_NOT_FOUND_MESSAGE: # pylint: disable=E1101
|
|
493
|
-
|
|
494
|
-
"❌ Run ID not found.",
|
|
495
|
-
fg=typer.colors.RED,
|
|
496
|
-
bold=True,
|
|
497
|
-
err=True,
|
|
498
|
-
)
|
|
499
|
-
raise typer.Exit(code=1) from None
|
|
398
|
+
raise click.ClickException("Run ID not found.") from None
|
|
500
399
|
if e.details() == NODE_NOT_FOUND_MESSAGE: # pylint: disable=E1101
|
|
501
|
-
|
|
502
|
-
"
|
|
503
|
-
|
|
504
|
-
bold=True,
|
|
505
|
-
err=True,
|
|
506
|
-
)
|
|
507
|
-
raise typer.Exit(code=1) from None
|
|
400
|
+
raise click.ClickException(
|
|
401
|
+
"Node ID not found for this account."
|
|
402
|
+
) from None
|
|
508
403
|
if e.code() == grpc.StatusCode.FAILED_PRECONDITION:
|
|
509
404
|
if e.details() == PULL_UNFINISHED_RUN_MESSAGE: # pylint: disable=E1101
|
|
510
|
-
|
|
511
|
-
"
|
|
512
|
-
"the run is finished. You can check the run status with `flwr ls`."
|
|
513
|
-
|
|
514
|
-
bold=True,
|
|
515
|
-
err=True,
|
|
516
|
-
)
|
|
517
|
-
raise typer.Exit(code=1) from None
|
|
405
|
+
raise click.ClickException(
|
|
406
|
+
"Run is not finished yet. Artifacts can only be pulled after "
|
|
407
|
+
"the run is finished. You can check the run status with `flwr ls`."
|
|
408
|
+
) from None
|
|
518
409
|
if (
|
|
519
410
|
e.details() == PUBLIC_KEY_ALREADY_IN_USE_MESSAGE
|
|
520
411
|
): # pylint: disable=E1101
|
|
521
|
-
|
|
522
|
-
"
|
|
523
|
-
|
|
524
|
-
fg=typer.colors.RED,
|
|
525
|
-
bold=True,
|
|
526
|
-
err=True,
|
|
527
|
-
)
|
|
528
|
-
raise typer.Exit(code=1) from None
|
|
412
|
+
raise click.ClickException(
|
|
413
|
+
"The provided public key is already in use by another SuperNode."
|
|
414
|
+
) from None
|
|
529
415
|
if e.details() == PUBLIC_KEY_NOT_VALID: # pylint: disable=E1101
|
|
530
|
-
|
|
531
|
-
"
|
|
532
|
-
"NIST EC public key."
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
416
|
+
raise click.ClickException(
|
|
417
|
+
"The provided public key is invalid. Please provide a valid "
|
|
418
|
+
"NIST EC public key."
|
|
419
|
+
) from None
|
|
420
|
+
if e.details() == FEDERATION_NOT_SPECIFIED_MESSAGE: # pylint: disable=E1101
|
|
421
|
+
raise click.ClickException(
|
|
422
|
+
"No federation specified. "
|
|
423
|
+
"Please use the `--federation` flag or set a default federation "
|
|
424
|
+
"in your SuperLink connection configuration."
|
|
425
|
+
) from None
|
|
426
|
+
patten = re.compile(FEDERATION_NOT_FOUND_MESSAGE.replace("%s", "(.+)"))
|
|
427
|
+
if m := patten.match(e.details()): # pylint: disable=E1101
|
|
428
|
+
raise click.ClickException(
|
|
429
|
+
f"Federation '{m.group(1)}' does not exist. "
|
|
430
|
+
"Please verify the federation name and try again."
|
|
431
|
+
) from None
|
|
538
432
|
|
|
539
433
|
# Log details from grpc error directly
|
|
540
|
-
|
|
541
|
-
f"❌ {e.details()}",
|
|
542
|
-
fg=typer.colors.RED,
|
|
543
|
-
bold=True,
|
|
544
|
-
err=True,
|
|
545
|
-
)
|
|
546
|
-
raise typer.Exit(code=1) from None
|
|
434
|
+
raise click.ClickException(f"{e.details()}") from None
|
|
547
435
|
raise
|
|
548
436
|
|
|
549
437
|
|
|
550
|
-
def request_download_link(
|
|
551
|
-
app_id: str, app_version: str | None, in_url: str, out_url: str
|
|
552
|
-
) -> str:
|
|
553
|
-
"""Request a download link for the given app from the Flower platform API.
|
|
554
|
-
|
|
555
|
-
Parameters
|
|
556
|
-
----------
|
|
557
|
-
app_id : str
|
|
558
|
-
The application identifier.
|
|
559
|
-
app_version : str | None
|
|
560
|
-
The application version, or None for latest.
|
|
561
|
-
in_url : str
|
|
562
|
-
The API endpoint URL.
|
|
563
|
-
out_url : str
|
|
564
|
-
The key name for the download URL in the response.
|
|
565
|
-
|
|
566
|
-
Returns
|
|
567
|
-
-------
|
|
568
|
-
str
|
|
569
|
-
The download URL for the application.
|
|
570
|
-
|
|
571
|
-
Raises
|
|
572
|
-
------
|
|
573
|
-
typer.Exit
|
|
574
|
-
If connection fails, app not found, or API request fails.
|
|
575
|
-
"""
|
|
576
|
-
headers = {
|
|
577
|
-
"Content-Type": "application/json",
|
|
578
|
-
"Accept": "application/json",
|
|
579
|
-
}
|
|
580
|
-
body = {
|
|
581
|
-
"app_id": app_id, # send raw string of app_id
|
|
582
|
-
"app_version": app_version,
|
|
583
|
-
"flwr_version": flwr_version,
|
|
584
|
-
}
|
|
585
|
-
try:
|
|
586
|
-
resp = requests.post(in_url, headers=headers, data=json.dumps(body), timeout=20)
|
|
587
|
-
except requests.RequestException as e:
|
|
588
|
-
typer.secho(
|
|
589
|
-
f"Unable to connect to Platform API: {e}",
|
|
590
|
-
fg=typer.colors.RED,
|
|
591
|
-
err=True,
|
|
592
|
-
)
|
|
593
|
-
raise typer.Exit(code=1) from e
|
|
594
|
-
|
|
595
|
-
if resp.status_code == 404:
|
|
596
|
-
error_message = resp.json()["detail"]
|
|
597
|
-
if isinstance(error_message, dict):
|
|
598
|
-
available_app_versions = error_message["available_app_versions"]
|
|
599
|
-
available_versions_str = (
|
|
600
|
-
", ".join(map(str, available_app_versions))
|
|
601
|
-
if available_app_versions
|
|
602
|
-
else "None"
|
|
603
|
-
)
|
|
604
|
-
typer.secho(
|
|
605
|
-
f"{app_id}=={app_version} not found in Platform API. "
|
|
606
|
-
f"Available app versions for {app_id}: {available_versions_str}",
|
|
607
|
-
fg=typer.colors.RED,
|
|
608
|
-
err=True,
|
|
609
|
-
)
|
|
610
|
-
else:
|
|
611
|
-
typer.secho(
|
|
612
|
-
f"{app_id} not found in Platform API.",
|
|
613
|
-
fg=typer.colors.RED,
|
|
614
|
-
err=True,
|
|
615
|
-
)
|
|
616
|
-
raise typer.Exit(code=1)
|
|
617
|
-
|
|
618
|
-
if not resp.ok:
|
|
619
|
-
typer.secho(
|
|
620
|
-
f"Platform API request failed with "
|
|
621
|
-
f"status {resp.status_code}. Details: {resp.text}",
|
|
622
|
-
fg=typer.colors.RED,
|
|
623
|
-
err=True,
|
|
624
|
-
)
|
|
625
|
-
raise typer.Exit(code=1)
|
|
626
|
-
|
|
627
|
-
data = resp.json()
|
|
628
|
-
if out_url not in data:
|
|
629
|
-
typer.secho(
|
|
630
|
-
"Invalid response from Platform API",
|
|
631
|
-
fg=typer.colors.RED,
|
|
632
|
-
err=True,
|
|
633
|
-
)
|
|
634
|
-
raise typer.Exit(code=1)
|
|
635
|
-
return str(data[out_url])
|
|
636
|
-
|
|
637
|
-
|
|
638
438
|
def build_pathspec(patterns: Iterable[str]) -> pathspec.PathSpec:
|
|
639
439
|
"""Build a PathSpec from a list of GitIgnore-style patterns.
|
|
640
440
|
|
|
@@ -691,69 +491,17 @@ def validate_credentials_content(creds_path: Path) -> str:
|
|
|
691
491
|
try:
|
|
692
492
|
creds: dict[str, str] = json.loads(creds_path.read_text(encoding="utf-8"))
|
|
693
493
|
except (OSError, json.JSONDecodeError) as err:
|
|
694
|
-
|
|
695
|
-
f"Invalid credentials file at '{creds_path}': {err}"
|
|
696
|
-
|
|
697
|
-
err=True,
|
|
698
|
-
)
|
|
699
|
-
raise typer.Exit(code=1) from err
|
|
494
|
+
raise click.ClickException(
|
|
495
|
+
f"Invalid credentials file at '{creds_path}': {err}"
|
|
496
|
+
) from err
|
|
700
497
|
|
|
701
498
|
required_keys = [AUTHN_TYPE_JSON_KEY, ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY]
|
|
702
499
|
missing = [key for key in required_keys if key not in creds]
|
|
703
500
|
|
|
704
501
|
if missing:
|
|
705
|
-
|
|
502
|
+
raise click.ClickException(
|
|
706
503
|
f"Credentials file '{creds_path}' is missing "
|
|
707
|
-
f"required key(s): {', '.join(missing)}. Please log in again."
|
|
708
|
-
fg=typer.colors.RED,
|
|
709
|
-
err=True,
|
|
504
|
+
f"required key(s): {', '.join(missing)}. Please log in again."
|
|
710
505
|
)
|
|
711
|
-
raise typer.Exit(code=1)
|
|
712
506
|
|
|
713
507
|
return creds[ACCESS_TOKEN_KEY]
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
def parse_app_spec(app_spec: str) -> tuple[str, str | None]:
|
|
717
|
-
"""Parse app specification string into app ID and version.
|
|
718
|
-
|
|
719
|
-
Parameters
|
|
720
|
-
----------
|
|
721
|
-
app_spec : str
|
|
722
|
-
The app specification string in the format '@account/app' or
|
|
723
|
-
'@account/app==x.y.z' (digits only).
|
|
724
|
-
|
|
725
|
-
Returns
|
|
726
|
-
-------
|
|
727
|
-
tuple[str, str | None]
|
|
728
|
-
A tuple containing the app ID and optional version.
|
|
729
|
-
|
|
730
|
-
Raises
|
|
731
|
-
------
|
|
732
|
-
typer.Exit
|
|
733
|
-
If the app specification format is invalid.
|
|
734
|
-
"""
|
|
735
|
-
if "==" in app_spec:
|
|
736
|
-
app_id, app_version = app_spec.split("==")
|
|
737
|
-
|
|
738
|
-
# Validate app version format
|
|
739
|
-
if not re.match(APP_VERSION_PATTERN, app_version):
|
|
740
|
-
typer.secho(
|
|
741
|
-
"❌ Invalid app version. Expected format: x.y.z (digits only).",
|
|
742
|
-
fg=typer.colors.RED,
|
|
743
|
-
err=True,
|
|
744
|
-
)
|
|
745
|
-
raise typer.Exit(code=1)
|
|
746
|
-
else:
|
|
747
|
-
app_id = app_spec
|
|
748
|
-
app_version = None
|
|
749
|
-
|
|
750
|
-
# Validate app_id format
|
|
751
|
-
if not re.match(APP_ID_PATTERN, app_id):
|
|
752
|
-
typer.secho(
|
|
753
|
-
"❌ Invalid remote app ID. Expected format: '@account/app'.",
|
|
754
|
-
fg=typer.colors.RED,
|
|
755
|
-
err=True,
|
|
756
|
-
)
|
|
757
|
-
raise typer.Exit(code=1)
|
|
758
|
-
|
|
759
|
-
return app_id, app_version
|