flwr 1.22.0__py3-none-any.whl → 1.24.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 +16 -5
- flwr/app/error.py +2 -2
- flwr/app/exception.py +3 -3
- flwr/cli/app.py +34 -1
- flwr/cli/app_cmd/__init__.py +23 -0
- flwr/cli/app_cmd/publish.py +285 -0
- flwr/cli/app_cmd/review.py +252 -0
- flwr/cli/auth_plugin/__init__.py +15 -6
- flwr/cli/auth_plugin/auth_plugin.py +94 -0
- flwr/cli/auth_plugin/noop_auth_plugin.py +101 -0
- flwr/cli/auth_plugin/oidc_cli_plugin.py +46 -32
- flwr/cli/build.py +166 -53
- flwr/cli/{cli_user_auth_interceptor.py → cli_account_auth_interceptor.py} +29 -11
- flwr/cli/config_utils.py +101 -13
- flwr/cli/federation/__init__.py +24 -0
- flwr/cli/federation/ls.py +140 -0
- flwr/cli/federation/show.py +317 -0
- flwr/cli/install.py +91 -13
- flwr/cli/log.py +54 -11
- flwr/cli/login/login.py +41 -27
- flwr/cli/ls.py +177 -133
- flwr/cli/new/new.py +175 -40
- flwr/cli/new/templates/app/code/task.pytorch.py.tpl +1 -0
- flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.jax.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +3 -3
- flwr/cli/new/templates/app/pyproject.pytorch_legacy_api.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +1 -1
- flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +2 -2
- flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +1 -1
- flwr/cli/pull.py +12 -7
- flwr/cli/run/run.py +82 -31
- flwr/cli/run_utils.py +130 -0
- flwr/cli/stop.py +27 -9
- flwr/cli/supernode/__init__.py +25 -0
- flwr/cli/supernode/ls.py +268 -0
- flwr/cli/supernode/register.py +190 -0
- flwr/cli/supernode/unregister.py +140 -0
- flwr/cli/utils.py +464 -81
- flwr/client/__init__.py +2 -1
- flwr/client/dpfedavg_numpy_client.py +4 -1
- flwr/client/grpc_adapter_client/connection.py +12 -15
- flwr/client/grpc_rere_client/connection.py +68 -41
- flwr/client/grpc_rere_client/grpc_adapter.py +34 -14
- flwr/client/grpc_rere_client/{client_interceptor.py → node_auth_client_interceptor.py} +5 -7
- flwr/client/message_handler/message_handler.py +2 -2
- flwr/client/mod/secure_aggregation/secaggplus_mod.py +10 -8
- flwr/client/numpy_client.py +1 -1
- flwr/client/rest_client/connection.py +94 -51
- flwr/client/run_info_store.py +4 -5
- flwr/client/typing.py +1 -1
- flwr/clientapp/__init__.py +1 -2
- flwr/{client → clientapp}/client_app.py +9 -10
- flwr/clientapp/mod/centraldp_mods.py +16 -17
- flwr/clientapp/mod/localdp_mod.py +8 -9
- flwr/clientapp/typing.py +1 -1
- flwr/{client/clientapp → clientapp}/utils.py +4 -4
- flwr/common/address.py +1 -2
- flwr/common/args.py +3 -4
- flwr/common/config.py +13 -16
- flwr/common/constant.py +56 -13
- flwr/common/differential_privacy.py +3 -4
- flwr/common/event_log_plugin/event_log_plugin.py +3 -4
- flwr/common/exit/exit.py +15 -2
- flwr/common/exit/exit_code.py +39 -10
- flwr/common/exit/exit_handler.py +6 -2
- flwr/common/exit/signal_handler.py +5 -5
- flwr/common/grpc.py +6 -6
- flwr/common/inflatable_protobuf_utils.py +1 -1
- flwr/common/inflatable_utils.py +48 -31
- flwr/common/logger.py +19 -19
- flwr/common/message.py +4 -4
- flwr/common/object_ref.py +7 -7
- flwr/common/record/array.py +6 -6
- flwr/common/record/arrayrecord.py +18 -21
- flwr/common/record/configrecord.py +3 -3
- flwr/common/record/recorddict.py +5 -5
- flwr/common/record/typeddict.py +9 -2
- flwr/common/recorddict_compat.py +7 -10
- flwr/common/retry_invoker.py +20 -20
- flwr/common/secure_aggregation/crypto/symmetric_encryption.py +1 -89
- flwr/common/secure_aggregation/ndarrays_arithmetic.py +3 -3
- flwr/common/serde.py +9 -6
- flwr/common/serde_utils.py +2 -2
- flwr/common/telemetry.py +9 -5
- flwr/common/typing.py +59 -43
- flwr/compat/client/app.py +39 -38
- flwr/compat/client/grpc_client/connection.py +13 -13
- flwr/compat/server/app.py +5 -6
- flwr/proto/appio_pb2.py +13 -3
- flwr/proto/appio_pb2.pyi +134 -65
- flwr/proto/appio_pb2_grpc.py +20 -0
- flwr/proto/appio_pb2_grpc.pyi +27 -0
- flwr/proto/clientappio_pb2.py +17 -7
- flwr/proto/clientappio_pb2.pyi +15 -0
- flwr/proto/clientappio_pb2_grpc.py +206 -40
- flwr/proto/clientappio_pb2_grpc.pyi +168 -53
- flwr/proto/control_pb2.py +72 -40
- flwr/proto/control_pb2.pyi +319 -87
- flwr/proto/control_pb2_grpc.py +339 -28
- flwr/proto/control_pb2_grpc.pyi +209 -37
- flwr/proto/error_pb2.py +13 -3
- flwr/proto/error_pb2.pyi +24 -6
- flwr/proto/error_pb2_grpc.py +20 -0
- flwr/proto/error_pb2_grpc.pyi +27 -0
- flwr/proto/fab_pb2.py +24 -10
- flwr/proto/fab_pb2.pyi +68 -20
- flwr/proto/fab_pb2_grpc.py +20 -0
- flwr/proto/fab_pb2_grpc.pyi +27 -0
- flwr/proto/federation_pb2.py +38 -0
- flwr/proto/federation_pb2.pyi +56 -0
- flwr/proto/federation_pb2_grpc.py +24 -0
- flwr/proto/federation_pb2_grpc.pyi +31 -0
- flwr/proto/fleet_pb2.py +45 -27
- flwr/proto/fleet_pb2.pyi +186 -70
- flwr/proto/fleet_pb2_grpc.py +277 -66
- flwr/proto/fleet_pb2_grpc.pyi +201 -55
- flwr/proto/grpcadapter_pb2.py +14 -4
- flwr/proto/grpcadapter_pb2.pyi +38 -16
- flwr/proto/grpcadapter_pb2_grpc.py +35 -4
- flwr/proto/grpcadapter_pb2_grpc.pyi +38 -7
- flwr/proto/heartbeat_pb2.py +17 -7
- flwr/proto/heartbeat_pb2.pyi +51 -22
- flwr/proto/heartbeat_pb2_grpc.py +20 -0
- flwr/proto/heartbeat_pb2_grpc.pyi +27 -0
- flwr/proto/log_pb2.py +13 -3
- flwr/proto/log_pb2.pyi +34 -11
- flwr/proto/log_pb2_grpc.py +20 -0
- flwr/proto/log_pb2_grpc.pyi +27 -0
- flwr/proto/message_pb2.py +15 -5
- flwr/proto/message_pb2.pyi +154 -86
- flwr/proto/message_pb2_grpc.py +20 -0
- flwr/proto/message_pb2_grpc.pyi +27 -0
- flwr/proto/node_pb2.py +16 -4
- flwr/proto/node_pb2.pyi +77 -4
- flwr/proto/node_pb2_grpc.py +20 -0
- flwr/proto/node_pb2_grpc.pyi +27 -0
- flwr/proto/recorddict_pb2.py +13 -3
- flwr/proto/recorddict_pb2.pyi +184 -107
- flwr/proto/recorddict_pb2_grpc.py +20 -0
- flwr/proto/recorddict_pb2_grpc.pyi +27 -0
- flwr/proto/run_pb2.py +40 -31
- flwr/proto/run_pb2.pyi +149 -84
- flwr/proto/run_pb2_grpc.py +20 -0
- flwr/proto/run_pb2_grpc.pyi +27 -0
- flwr/proto/serverappio_pb2.py +13 -3
- flwr/proto/serverappio_pb2.pyi +32 -8
- flwr/proto/serverappio_pb2_grpc.py +246 -65
- flwr/proto/serverappio_pb2_grpc.pyi +221 -85
- flwr/proto/simulationio_pb2.py +16 -8
- flwr/proto/simulationio_pb2.pyi +15 -0
- flwr/proto/simulationio_pb2_grpc.py +162 -41
- flwr/proto/simulationio_pb2_grpc.pyi +149 -55
- flwr/proto/transport_pb2.py +20 -10
- flwr/proto/transport_pb2.pyi +249 -160
- flwr/proto/transport_pb2_grpc.py +35 -4
- flwr/proto/transport_pb2_grpc.pyi +38 -8
- flwr/server/app.py +173 -127
- flwr/server/client_manager.py +4 -5
- flwr/server/client_proxy.py +10 -11
- flwr/server/compat/app.py +4 -5
- flwr/server/compat/app_utils.py +2 -1
- flwr/server/compat/grid_client_proxy.py +10 -12
- flwr/server/compat/legacy_context.py +3 -4
- flwr/server/fleet_event_log_interceptor.py +2 -1
- flwr/server/grid/grid.py +2 -3
- flwr/server/grid/grpc_grid.py +10 -8
- flwr/server/grid/inmemory_grid.py +4 -4
- flwr/server/run_serverapp.py +2 -3
- flwr/server/server.py +34 -39
- flwr/server/server_app.py +7 -8
- flwr/server/server_config.py +1 -2
- flwr/server/serverapp/app.py +34 -28
- flwr/server/serverapp_components.py +4 -5
- flwr/server/strategy/aggregate.py +9 -8
- flwr/server/strategy/bulyan.py +13 -11
- flwr/server/strategy/dp_adaptive_clipping.py +16 -20
- flwr/server/strategy/dp_fixed_clipping.py +12 -17
- flwr/server/strategy/dpfedavg_adaptive.py +3 -4
- flwr/server/strategy/dpfedavg_fixed.py +6 -10
- flwr/server/strategy/fault_tolerant_fedavg.py +14 -13
- flwr/server/strategy/fedadagrad.py +18 -14
- flwr/server/strategy/fedadam.py +16 -14
- flwr/server/strategy/fedavg.py +16 -17
- flwr/server/strategy/fedavg_android.py +15 -15
- flwr/server/strategy/fedavgm.py +21 -18
- flwr/server/strategy/fedmedian.py +2 -3
- flwr/server/strategy/fedopt.py +11 -10
- flwr/server/strategy/fedprox.py +10 -9
- flwr/server/strategy/fedtrimmedavg.py +12 -11
- flwr/server/strategy/fedxgb_bagging.py +13 -11
- flwr/server/strategy/fedxgb_cyclic.py +6 -6
- flwr/server/strategy/fedxgb_nn_avg.py +4 -4
- flwr/server/strategy/fedyogi.py +16 -14
- flwr/server/strategy/krum.py +12 -11
- flwr/server/strategy/qfedavg.py +16 -15
- flwr/server/strategy/strategy.py +6 -9
- flwr/server/superlink/fleet/grpc_adapter/grpc_adapter_servicer.py +19 -8
- flwr/server/superlink/fleet/grpc_bidi/flower_service_servicer.py +1 -2
- flwr/server/superlink/fleet/grpc_bidi/grpc_bridge.py +3 -4
- flwr/server/superlink/fleet/grpc_bidi/grpc_client_proxy.py +10 -12
- flwr/server/superlink/fleet/grpc_bidi/grpc_server.py +1 -3
- flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +136 -42
- flwr/server/superlink/fleet/grpc_rere/{server_interceptor.py → node_auth_server_interceptor.py} +28 -51
- flwr/server/superlink/fleet/message_handler/message_handler.py +100 -49
- flwr/server/superlink/fleet/rest_rere/rest_api.py +54 -33
- flwr/server/superlink/fleet/vce/backend/backend.py +2 -2
- flwr/server/superlink/fleet/vce/backend/raybackend.py +6 -6
- flwr/server/superlink/fleet/vce/vce_api.py +32 -13
- flwr/server/superlink/linkstate/in_memory_linkstate.py +266 -207
- flwr/server/superlink/linkstate/linkstate.py +161 -62
- flwr/server/superlink/linkstate/linkstate_factory.py +24 -6
- flwr/server/superlink/linkstate/sqlite_linkstate.py +698 -638
- flwr/server/superlink/linkstate/utils.py +9 -60
- flwr/server/superlink/serverappio/serverappio_grpc.py +1 -2
- flwr/server/superlink/serverappio/serverappio_servicer.py +28 -23
- flwr/server/superlink/simulation/simulationio_grpc.py +1 -2
- flwr/server/superlink/simulation/simulationio_servicer.py +19 -14
- flwr/server/superlink/utils.py +4 -6
- flwr/server/typing.py +1 -1
- flwr/server/utils/tensorboard.py +15 -8
- flwr/server/utils/validator.py +2 -3
- flwr/server/workflow/default_workflows.py +5 -5
- flwr/server/workflow/secure_aggregation/secagg_workflow.py +2 -4
- flwr/server/workflow/secure_aggregation/secaggplus_workflow.py +12 -10
- flwr/serverapp/strategy/bulyan.py +16 -15
- flwr/serverapp/strategy/dp_adaptive_clipping.py +12 -11
- flwr/serverapp/strategy/dp_fixed_clipping.py +11 -14
- flwr/serverapp/strategy/fedadagrad.py +10 -11
- flwr/serverapp/strategy/fedadam.py +10 -11
- flwr/serverapp/strategy/fedavg.py +9 -10
- flwr/serverapp/strategy/fedavgm.py +17 -16
- flwr/serverapp/strategy/fedmedian.py +2 -2
- flwr/serverapp/strategy/fedopt.py +10 -11
- flwr/serverapp/strategy/fedprox.py +7 -8
- flwr/serverapp/strategy/fedtrimmedavg.py +9 -9
- flwr/serverapp/strategy/fedxgb_bagging.py +3 -3
- flwr/serverapp/strategy/fedxgb_cyclic.py +9 -9
- flwr/serverapp/strategy/fedyogi.py +9 -11
- flwr/serverapp/strategy/krum.py +7 -7
- flwr/serverapp/strategy/multikrum.py +9 -9
- flwr/serverapp/strategy/qfedavg.py +17 -16
- flwr/serverapp/strategy/strategy.py +6 -9
- flwr/serverapp/strategy/strategy_utils.py +7 -8
- flwr/simulation/app.py +46 -42
- flwr/simulation/legacy_app.py +12 -12
- flwr/simulation/ray_transport/ray_actor.py +11 -12
- flwr/simulation/ray_transport/ray_client_proxy.py +12 -13
- flwr/simulation/run_simulation.py +44 -43
- flwr/simulation/simulationio_connection.py +4 -4
- flwr/supercore/cli/flower_superexec.py +3 -4
- flwr/supercore/constant.py +52 -0
- flwr/supercore/corestate/corestate.py +24 -3
- flwr/supercore/corestate/in_memory_corestate.py +138 -0
- flwr/supercore/corestate/sqlite_corestate.py +157 -0
- flwr/supercore/ffs/disk_ffs.py +1 -2
- flwr/supercore/ffs/ffs.py +1 -2
- flwr/supercore/ffs/ffs_factory.py +1 -2
- flwr/{common → supercore}/heartbeat.py +20 -25
- flwr/supercore/object_store/in_memory_object_store.py +1 -6
- flwr/supercore/object_store/object_store.py +1 -2
- flwr/supercore/object_store/object_store_factory.py +27 -8
- flwr/supercore/object_store/sqlite_object_store.py +253 -0
- flwr/{client/clientapp → supercore/primitives}/__init__.py +1 -1
- flwr/supercore/primitives/asymmetric.py +117 -0
- flwr/supercore/primitives/asymmetric_ed25519.py +175 -0
- flwr/supercore/sqlite_mixin.py +159 -0
- flwr/supercore/superexec/plugin/base_exec_plugin.py +1 -2
- flwr/supercore/superexec/plugin/exec_plugin.py +3 -3
- flwr/supercore/superexec/run_superexec.py +9 -13
- flwr/supercore/utils.py +20 -0
- flwr/superlink/artifact_provider/artifact_provider.py +1 -2
- flwr/{common → superlink}/auth_plugin/__init__.py +6 -6
- flwr/superlink/auth_plugin/auth_plugin.py +88 -0
- flwr/superlink/auth_plugin/noop_auth_plugin.py +84 -0
- flwr/superlink/federation/__init__.py +24 -0
- flwr/superlink/federation/federation_manager.py +64 -0
- flwr/superlink/federation/noop_federation_manager.py +71 -0
- flwr/superlink/servicer/control/{control_user_auth_interceptor.py → control_account_auth_interceptor.py} +41 -32
- flwr/superlink/servicer/control/control_event_log_interceptor.py +7 -7
- flwr/superlink/servicer/control/control_grpc.py +18 -17
- flwr/superlink/servicer/control/control_license_interceptor.py +3 -3
- flwr/superlink/servicer/control/control_servicer.py +239 -63
- flwr/supernode/cli/flower_supernode.py +74 -26
- flwr/supernode/nodestate/in_memory_nodestate.py +60 -49
- flwr/supernode/nodestate/nodestate.py +7 -8
- flwr/supernode/nodestate/nodestate_factory.py +7 -4
- flwr/supernode/runtime/run_clientapp.py +43 -24
- flwr/supernode/servicer/clientappio/clientappio_servicer.py +40 -10
- flwr/supernode/start_client_internal.py +175 -51
- {flwr-1.22.0.dist-info → flwr-1.24.0.dist-info}/METADATA +8 -8
- flwr-1.24.0.dist-info/RECORD +454 -0
- flwr/common/auth_plugin/auth_plugin.py +0 -149
- flwr/supercore/object_store/utils.py +0 -43
- flwr-1.22.0.dist-info/RECORD +0 -428
- {flwr-1.22.0.dist-info → flwr-1.24.0.dist-info}/WHEEL +0 -0
- {flwr-1.22.0.dist-info → flwr-1.24.0.dist-info}/entry_points.txt +0 -0
flwr/cli/utils.py
CHANGED
|
@@ -18,41 +18,65 @@
|
|
|
18
18
|
import hashlib
|
|
19
19
|
import json
|
|
20
20
|
import re
|
|
21
|
-
from collections.abc import Iterator
|
|
21
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
22
22
|
from contextlib import contextmanager
|
|
23
23
|
from pathlib import Path
|
|
24
|
-
from typing import Any,
|
|
24
|
+
from typing import Any, cast
|
|
25
25
|
|
|
26
26
|
import grpc
|
|
27
|
+
import pathspec
|
|
28
|
+
import requests
|
|
27
29
|
import typer
|
|
28
30
|
|
|
29
|
-
from flwr.cli.cli_user_auth_interceptor import CliUserAuthInterceptor
|
|
30
|
-
from flwr.common.auth_plugin import CliAuthPlugin
|
|
31
31
|
from flwr.common.constant import (
|
|
32
|
-
|
|
32
|
+
ACCESS_TOKEN_KEY,
|
|
33
|
+
AUTHN_TYPE_JSON_KEY,
|
|
33
34
|
CREDENTIALS_DIR,
|
|
34
35
|
FLWR_DIR,
|
|
36
|
+
NO_ACCOUNT_AUTH_MESSAGE,
|
|
35
37
|
NO_ARTIFACT_PROVIDER_MESSAGE,
|
|
36
|
-
|
|
38
|
+
NODE_NOT_FOUND_MESSAGE,
|
|
39
|
+
PUBLIC_KEY_ALREADY_IN_USE_MESSAGE,
|
|
40
|
+
PUBLIC_KEY_NOT_VALID,
|
|
37
41
|
PULL_UNFINISHED_RUN_MESSAGE,
|
|
42
|
+
REFRESH_TOKEN_KEY,
|
|
38
43
|
RUN_ID_NOT_FOUND_MESSAGE,
|
|
44
|
+
AuthnType,
|
|
39
45
|
)
|
|
40
46
|
from flwr.common.grpc import (
|
|
41
47
|
GRPC_MAX_MESSAGE_LENGTH,
|
|
42
48
|
create_channel,
|
|
43
49
|
on_channel_state_change,
|
|
44
50
|
)
|
|
51
|
+
from flwr.common.version import package_version as flwr_version
|
|
52
|
+
from flwr.supercore.constant import APP_ID_PATTERN, APP_VERSION_PATTERN
|
|
45
53
|
|
|
46
|
-
from .auth_plugin import
|
|
54
|
+
from .auth_plugin import CliAuthPlugin, get_cli_plugin_class
|
|
55
|
+
from .cli_account_auth_interceptor import CliAccountAuthInterceptor
|
|
47
56
|
from .config_utils import validate_certificate_in_federation_config
|
|
48
57
|
|
|
49
58
|
|
|
50
59
|
def prompt_text(
|
|
51
60
|
text: str,
|
|
52
61
|
predicate: Callable[[str], bool] = lambda _: True,
|
|
53
|
-
default:
|
|
62
|
+
default: str | None = None,
|
|
54
63
|
) -> str:
|
|
55
|
-
"""Ask user to enter text input.
|
|
64
|
+
"""Ask user to enter text input.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
text : str
|
|
69
|
+
The prompt text to display to the user.
|
|
70
|
+
predicate : Callable[[str], bool] (default: lambda _: True)
|
|
71
|
+
A function to validate the user input. Default accepts all non-empty strings.
|
|
72
|
+
default : str | None (default: None)
|
|
73
|
+
Default value to use if user presses enter without input.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
str
|
|
78
|
+
The validated user input.
|
|
79
|
+
"""
|
|
56
80
|
while True:
|
|
57
81
|
result = typer.prompt(
|
|
58
82
|
typer.style(f"\n💬 {text}", fg=typer.colors.MAGENTA, bold=True),
|
|
@@ -66,7 +90,20 @@ def prompt_text(
|
|
|
66
90
|
|
|
67
91
|
|
|
68
92
|
def prompt_options(text: str, options: list[str]) -> str:
|
|
69
|
-
"""Ask user to select one of the given options and return the selected item.
|
|
93
|
+
"""Ask user to select one of the given options and return the selected item.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
text : str
|
|
98
|
+
The prompt text to display to the user.
|
|
99
|
+
options : list[str]
|
|
100
|
+
List of options to present to the user.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
str
|
|
105
|
+
The selected option from the list.
|
|
106
|
+
"""
|
|
70
107
|
# Turn options into a list with index as in " [ 0] quickstart-pytorch"
|
|
71
108
|
options_formatted = [
|
|
72
109
|
" [ "
|
|
@@ -124,9 +161,19 @@ def is_valid_project_name(name: str) -> bool:
|
|
|
124
161
|
def sanitize_project_name(name: str) -> str:
|
|
125
162
|
"""Sanitize the given string to make it a valid Python project name.
|
|
126
163
|
|
|
127
|
-
This
|
|
164
|
+
This function replaces spaces, dots, slashes, and underscores with dashes, removes
|
|
128
165
|
any characters not allowed in Python project names, makes the string lowercase, and
|
|
129
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.
|
|
130
177
|
"""
|
|
131
178
|
# Replace whitespace with '_'
|
|
132
179
|
name_with_hyphens = re.sub(r"[ ./_]", "-", name)
|
|
@@ -151,8 +198,19 @@ def sanitize_project_name(name: str) -> str:
|
|
|
151
198
|
return sanitized_name
|
|
152
199
|
|
|
153
200
|
|
|
154
|
-
def get_sha256_hash(file_path_or_int:
|
|
155
|
-
"""Calculate the SHA-256 hash of a file.
|
|
201
|
+
def get_sha256_hash(file_path_or_int: Path | int) -> str:
|
|
202
|
+
"""Calculate the SHA-256 hash of a file or integer.
|
|
203
|
+
|
|
204
|
+
Parameters
|
|
205
|
+
----------
|
|
206
|
+
file_path_or_int : Path | int
|
|
207
|
+
Either a path to a file to hash, or an integer to convert to string and hash.
|
|
208
|
+
|
|
209
|
+
Returns
|
|
210
|
+
-------
|
|
211
|
+
str
|
|
212
|
+
The SHA-256 hash as a hexadecimal string.
|
|
213
|
+
"""
|
|
156
214
|
sha256 = hashlib.sha256()
|
|
157
215
|
if isinstance(file_path_or_int, Path):
|
|
158
216
|
with open(file_path_or_int, "rb") as f:
|
|
@@ -166,8 +224,8 @@ def get_sha256_hash(file_path_or_int: Union[Path, int]) -> str:
|
|
|
166
224
|
return sha256.hexdigest()
|
|
167
225
|
|
|
168
226
|
|
|
169
|
-
def
|
|
170
|
-
"""Return the path to the
|
|
227
|
+
def get_account_auth_config_path(root_dir: Path, federation: str) -> Path:
|
|
228
|
+
"""Return the path to the account auth config file.
|
|
171
229
|
|
|
172
230
|
Additionally, a `.gitignore` file will be created in the Flower directory to
|
|
173
231
|
include the `.credentials` folder to be excluded from git. If the `.gitignore`
|
|
@@ -211,77 +269,134 @@ def get_user_auth_config_path(root_dir: Path, federation: str) -> Path:
|
|
|
211
269
|
f"Please check the permissions of `{gitignore_path}` and try again.",
|
|
212
270
|
fg=typer.colors.RED,
|
|
213
271
|
bold=True,
|
|
272
|
+
err=True,
|
|
214
273
|
)
|
|
215
274
|
raise typer.Exit(code=1) from err
|
|
216
275
|
|
|
217
276
|
return credentials_dir / f"{federation}.json"
|
|
218
277
|
|
|
219
278
|
|
|
220
|
-
def
|
|
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):
|
|
323
|
+
return AuthnType.NOOP
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def load_cli_auth_plugin(
|
|
221
327
|
root_dir: Path,
|
|
222
328
|
federation: str,
|
|
223
329
|
federation_config: dict[str, Any],
|
|
224
|
-
|
|
225
|
-
) ->
|
|
226
|
-
"""Load the CLI-side
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
330
|
+
authn_type: str | None = None,
|
|
331
|
+
) -> CliAuthPlugin:
|
|
332
|
+
"""Load the CLI-side account auth plugin for the given authn type.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
root_dir : Path
|
|
337
|
+
Root directory of the Flower project.
|
|
338
|
+
federation : str
|
|
339
|
+
Name of the federation.
|
|
340
|
+
federation_config : dict[str, Any]
|
|
341
|
+
Federation configuration dictionary.
|
|
342
|
+
authn_type : str | None
|
|
343
|
+
Authentication type. If None, will be determined from config.
|
|
344
|
+
|
|
345
|
+
Returns
|
|
346
|
+
-------
|
|
347
|
+
CliAuthPlugin
|
|
348
|
+
The loaded authentication plugin instance.
|
|
349
|
+
|
|
350
|
+
Raises
|
|
351
|
+
------
|
|
352
|
+
typer.Exit
|
|
353
|
+
If the authentication type is unknown.
|
|
354
|
+
"""
|
|
355
|
+
# Find the path to the account auth config file
|
|
356
|
+
config_path = get_account_auth_config_path(root_dir, federation)
|
|
357
|
+
|
|
358
|
+
# Determine the auth type if not provided
|
|
359
|
+
# Only `flwr login` command can provide `authn_type` explicitly, as it can query the
|
|
360
|
+
# SuperLink for the auth type.
|
|
361
|
+
if authn_type is None:
|
|
362
|
+
authn_type = AuthnType.NOOP
|
|
363
|
+
if account_auth_enabled(federation_config):
|
|
364
|
+
authn_type = retrieve_authn_type(config_path)
|
|
248
365
|
|
|
249
366
|
# Retrieve auth plugin class and instantiate it
|
|
250
367
|
try:
|
|
251
|
-
|
|
252
|
-
auth_plugin_class = all_plugins[auth_type]
|
|
368
|
+
auth_plugin_class = get_cli_plugin_class(authn_type)
|
|
253
369
|
return auth_plugin_class(config_path)
|
|
254
|
-
except
|
|
255
|
-
typer.echo(f"❌ Unknown
|
|
256
|
-
raise typer.Exit(code=1) from None
|
|
257
|
-
except ImportError:
|
|
258
|
-
typer.echo("❌ No authentication plugins are currently supported.")
|
|
370
|
+
except ValueError:
|
|
371
|
+
typer.echo(f"❌ Unknown account authentication type: {authn_type}")
|
|
259
372
|
raise typer.Exit(code=1) from None
|
|
260
373
|
|
|
261
374
|
|
|
262
375
|
def init_channel(
|
|
263
|
-
app: Path, federation_config: dict[str, Any], auth_plugin:
|
|
376
|
+
app: Path, federation_config: dict[str, Any], auth_plugin: CliAuthPlugin
|
|
264
377
|
) -> grpc.Channel:
|
|
265
|
-
"""Initialize gRPC channel to the Control API.
|
|
378
|
+
"""Initialize gRPC channel to the Control API.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
app : Path
|
|
383
|
+
Path to the Flower app directory.
|
|
384
|
+
federation_config : dict[str, Any]
|
|
385
|
+
Federation configuration dictionary containing address and TLS settings.
|
|
386
|
+
auth_plugin : CliAuthPlugin
|
|
387
|
+
Authentication plugin instance for handling credentials.
|
|
388
|
+
|
|
389
|
+
Returns
|
|
390
|
+
-------
|
|
391
|
+
grpc.Channel
|
|
392
|
+
Configured gRPC channel with authentication interceptors.
|
|
393
|
+
"""
|
|
266
394
|
insecure, root_certificates_bytes = validate_certificate_in_federation_config(
|
|
267
395
|
app, federation_config
|
|
268
396
|
)
|
|
269
397
|
|
|
270
|
-
#
|
|
271
|
-
|
|
272
|
-
if auth_plugin is not None:
|
|
273
|
-
# Check if TLS is enabled. If not, raise an error
|
|
274
|
-
if insecure:
|
|
275
|
-
typer.secho(
|
|
276
|
-
"❌ User authentication requires TLS to be enabled. "
|
|
277
|
-
"Remove `insecure = true` from the federation configuration.",
|
|
278
|
-
fg=typer.colors.RED,
|
|
279
|
-
bold=True,
|
|
280
|
-
)
|
|
281
|
-
raise typer.Exit(code=1)
|
|
282
|
-
|
|
283
|
-
auth_plugin.load_tokens()
|
|
284
|
-
interceptors.append(CliUserAuthInterceptor(auth_plugin))
|
|
398
|
+
# Load tokens
|
|
399
|
+
auth_plugin.load_tokens()
|
|
285
400
|
|
|
286
401
|
# Create the gRPC channel
|
|
287
402
|
channel = create_channel(
|
|
@@ -289,19 +404,32 @@ def init_channel(
|
|
|
289
404
|
insecure=insecure,
|
|
290
405
|
root_certificates=root_certificates_bytes,
|
|
291
406
|
max_message_length=GRPC_MAX_MESSAGE_LENGTH,
|
|
292
|
-
interceptors=
|
|
407
|
+
interceptors=[CliAccountAuthInterceptor(auth_plugin)],
|
|
293
408
|
)
|
|
294
409
|
channel.subscribe(on_channel_state_change)
|
|
295
410
|
return channel
|
|
296
411
|
|
|
297
412
|
|
|
298
413
|
@contextmanager
|
|
299
|
-
def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
414
|
+
def flwr_cli_grpc_exc_handler() -> Iterator[None]: # pylint: disable=too-many-branches
|
|
300
415
|
"""Context manager to handle specific gRPC errors.
|
|
301
416
|
|
|
302
|
-
|
|
303
|
-
UNAVAILABLE,
|
|
304
|
-
application. All other exceptions will be
|
|
417
|
+
Catches grpc.RpcError exceptions with UNAUTHENTICATED, UNIMPLEMENTED,
|
|
418
|
+
UNAVAILABLE, PERMISSION_DENIED, NOT_FOUND, and FAILED_PRECONDITION statuses,
|
|
419
|
+
informs the user, and exits the application. All other exceptions will be
|
|
420
|
+
allowed to escape.
|
|
421
|
+
|
|
422
|
+
Yields
|
|
423
|
+
------
|
|
424
|
+
None
|
|
425
|
+
Context manager yields nothing.
|
|
426
|
+
|
|
427
|
+
Raises
|
|
428
|
+
------
|
|
429
|
+
typer.Exit
|
|
430
|
+
On handled gRPC error statuses with appropriate exit code.
|
|
431
|
+
grpc.RpcError
|
|
432
|
+
For unhandled gRPC error statuses.
|
|
305
433
|
"""
|
|
306
434
|
try:
|
|
307
435
|
yield
|
|
@@ -312,20 +440,23 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
312
440
|
" to authenticate and try again.",
|
|
313
441
|
fg=typer.colors.RED,
|
|
314
442
|
bold=True,
|
|
443
|
+
err=True,
|
|
315
444
|
)
|
|
316
445
|
raise typer.Exit(code=1) from None
|
|
317
446
|
if e.code() == grpc.StatusCode.UNIMPLEMENTED:
|
|
318
|
-
if e.details() ==
|
|
447
|
+
if e.details() == NO_ACCOUNT_AUTH_MESSAGE: # pylint: disable=E1101
|
|
319
448
|
typer.secho(
|
|
320
|
-
"❌
|
|
449
|
+
"❌ Account authentication is not enabled on this SuperLink.",
|
|
321
450
|
fg=typer.colors.RED,
|
|
322
451
|
bold=True,
|
|
452
|
+
err=True,
|
|
323
453
|
)
|
|
324
454
|
elif e.details() == NO_ARTIFACT_PROVIDER_MESSAGE: # pylint: disable=E1101
|
|
325
455
|
typer.secho(
|
|
326
456
|
"❌ The SuperLink does not support `flwr pull` command.",
|
|
327
457
|
fg=typer.colors.RED,
|
|
328
458
|
bold=True,
|
|
459
|
+
err=True,
|
|
329
460
|
)
|
|
330
461
|
else:
|
|
331
462
|
typer.secho(
|
|
@@ -335,6 +466,7 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
335
466
|
"the CLI and SuperLink are compatible.",
|
|
336
467
|
fg=typer.colors.RED,
|
|
337
468
|
bold=True,
|
|
469
|
+
err=True,
|
|
338
470
|
)
|
|
339
471
|
raise typer.Exit(code=1) from None
|
|
340
472
|
if e.code() == grpc.StatusCode.PERMISSION_DENIED:
|
|
@@ -342,6 +474,7 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
342
474
|
"❌ Permission denied.",
|
|
343
475
|
fg=typer.colors.RED,
|
|
344
476
|
bold=True,
|
|
477
|
+
err=True,
|
|
345
478
|
)
|
|
346
479
|
# pylint: disable-next=E1101
|
|
347
480
|
typer.secho(e.details(), fg=typer.colors.RED, bold=True)
|
|
@@ -352,18 +485,26 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
352
485
|
"connection and 'address' in the federation configuration.",
|
|
353
486
|
fg=typer.colors.RED,
|
|
354
487
|
bold=True,
|
|
488
|
+
err=True,
|
|
355
489
|
)
|
|
356
490
|
raise typer.Exit(code=1) from None
|
|
357
|
-
if (
|
|
358
|
-
e.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
491
|
+
if e.code() == grpc.StatusCode.NOT_FOUND:
|
|
492
|
+
if e.details() == RUN_ID_NOT_FOUND_MESSAGE: # pylint: disable=E1101
|
|
493
|
+
typer.secho(
|
|
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
|
|
500
|
+
if e.details() == NODE_NOT_FOUND_MESSAGE: # pylint: disable=E1101
|
|
501
|
+
typer.secho(
|
|
502
|
+
"❌ Node ID not found for this account.",
|
|
503
|
+
fg=typer.colors.RED,
|
|
504
|
+
bold=True,
|
|
505
|
+
err=True,
|
|
506
|
+
)
|
|
507
|
+
raise typer.Exit(code=1) from None
|
|
367
508
|
if e.code() == grpc.StatusCode.FAILED_PRECONDITION:
|
|
368
509
|
if e.details() == PULL_UNFINISHED_RUN_MESSAGE: # pylint: disable=E1101
|
|
369
510
|
typer.secho(
|
|
@@ -371,6 +512,248 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
|
|
|
371
512
|
"the run is finished. You can check the run status with `flwr ls`.",
|
|
372
513
|
fg=typer.colors.RED,
|
|
373
514
|
bold=True,
|
|
515
|
+
err=True,
|
|
516
|
+
)
|
|
517
|
+
raise typer.Exit(code=1) from None
|
|
518
|
+
if (
|
|
519
|
+
e.details() == PUBLIC_KEY_ALREADY_IN_USE_MESSAGE
|
|
520
|
+
): # pylint: disable=E1101
|
|
521
|
+
typer.secho(
|
|
522
|
+
"❌ The provided public key is already in use by another "
|
|
523
|
+
"SuperNode.",
|
|
524
|
+
fg=typer.colors.RED,
|
|
525
|
+
bold=True,
|
|
526
|
+
err=True,
|
|
527
|
+
)
|
|
528
|
+
raise typer.Exit(code=1) from None
|
|
529
|
+
if e.details() == PUBLIC_KEY_NOT_VALID: # pylint: disable=E1101
|
|
530
|
+
typer.secho(
|
|
531
|
+
"❌ The provided public key is invalid. Please provide a valid "
|
|
532
|
+
"NIST EC public key.",
|
|
533
|
+
fg=typer.colors.RED,
|
|
534
|
+
bold=True,
|
|
535
|
+
err=True,
|
|
374
536
|
)
|
|
375
537
|
raise typer.Exit(code=1) from None
|
|
538
|
+
|
|
539
|
+
# Log details from grpc error directly
|
|
540
|
+
typer.secho(
|
|
541
|
+
f"❌ {e.details()}",
|
|
542
|
+
fg=typer.colors.RED,
|
|
543
|
+
bold=True,
|
|
544
|
+
err=True,
|
|
545
|
+
)
|
|
546
|
+
raise typer.Exit(code=1) from None
|
|
376
547
|
raise
|
|
548
|
+
|
|
549
|
+
|
|
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
|
+
def build_pathspec(patterns: Iterable[str]) -> pathspec.PathSpec:
|
|
639
|
+
"""Build a PathSpec from a list of GitIgnore-style patterns.
|
|
640
|
+
|
|
641
|
+
Parameters
|
|
642
|
+
----------
|
|
643
|
+
patterns : Iterable[str]
|
|
644
|
+
Iterable of GitIgnore-style pattern strings.
|
|
645
|
+
|
|
646
|
+
Returns
|
|
647
|
+
-------
|
|
648
|
+
pathspec.PathSpec
|
|
649
|
+
Compiled PathSpec object for pattern matching.
|
|
650
|
+
"""
|
|
651
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def load_gitignore_patterns(file: Path | bytes) -> list[str]:
|
|
655
|
+
"""Load gitignore patterns from .gitignore file bytes.
|
|
656
|
+
|
|
657
|
+
Parameters
|
|
658
|
+
----------
|
|
659
|
+
file : Path | bytes
|
|
660
|
+
The path to a .gitignore file or its bytes content.
|
|
661
|
+
|
|
662
|
+
Returns
|
|
663
|
+
-------
|
|
664
|
+
list[str]
|
|
665
|
+
List of gitignore patterns.
|
|
666
|
+
Returns empty list if content can't be decoded or the file does not exist.
|
|
667
|
+
"""
|
|
668
|
+
try:
|
|
669
|
+
if isinstance(file, Path):
|
|
670
|
+
content = file.read_text(encoding="utf-8")
|
|
671
|
+
else:
|
|
672
|
+
content = file.decode("utf-8")
|
|
673
|
+
patterns = [
|
|
674
|
+
line.strip()
|
|
675
|
+
for line in content.splitlines()
|
|
676
|
+
if line.strip() and not line.strip().startswith("#")
|
|
677
|
+
]
|
|
678
|
+
return patterns
|
|
679
|
+
except (UnicodeDecodeError, OSError):
|
|
680
|
+
return []
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def validate_credentials_content(creds_path: Path) -> str:
|
|
684
|
+
"""Load and validate the credentials file content.
|
|
685
|
+
|
|
686
|
+
Ensures required keys exist:
|
|
687
|
+
- AUTHN_TYPE_JSON_KEY
|
|
688
|
+
- ACCESS_TOKEN_KEY
|
|
689
|
+
- REFRESH_TOKEN_KEY
|
|
690
|
+
"""
|
|
691
|
+
try:
|
|
692
|
+
creds: dict[str, str] = json.loads(creds_path.read_text(encoding="utf-8"))
|
|
693
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
694
|
+
typer.secho(
|
|
695
|
+
f"Invalid credentials file at '{creds_path}': {err}",
|
|
696
|
+
fg=typer.colors.RED,
|
|
697
|
+
err=True,
|
|
698
|
+
)
|
|
699
|
+
raise typer.Exit(code=1) from err
|
|
700
|
+
|
|
701
|
+
required_keys = [AUTHN_TYPE_JSON_KEY, ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY]
|
|
702
|
+
missing = [key for key in required_keys if key not in creds]
|
|
703
|
+
|
|
704
|
+
if missing:
|
|
705
|
+
typer.secho(
|
|
706
|
+
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,
|
|
710
|
+
)
|
|
711
|
+
raise typer.Exit(code=1)
|
|
712
|
+
|
|
713
|
+
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
|