flwr 1.24.0__py3-none-any.whl → 1.25.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.
Files changed (96) hide show
  1. flwr/cli/app_cmd/review.py +13 -3
  2. flwr/cli/federation/show.py +4 -3
  3. flwr/cli/ls.py +44 -3
  4. flwr/cli/new/new.py +106 -297
  5. flwr/cli/run/run.py +12 -17
  6. flwr/cli/run_utils.py +23 -5
  7. flwr/cli/stop.py +1 -1
  8. flwr/cli/supernode/ls.py +10 -5
  9. flwr/cli/utils.py +0 -137
  10. flwr/client/grpc_adapter_client/connection.py +2 -2
  11. flwr/client/grpc_rere_client/connection.py +6 -3
  12. flwr/client/rest_client/connection.py +6 -4
  13. flwr/common/serde.py +6 -0
  14. flwr/common/typing.py +6 -0
  15. flwr/proto/fleet_pb2.py +10 -10
  16. flwr/proto/fleet_pb2.pyi +5 -1
  17. flwr/proto/run_pb2.py +24 -24
  18. flwr/proto/run_pb2.pyi +10 -1
  19. flwr/server/app.py +1 -0
  20. flwr/server/superlink/fleet/message_handler/message_handler.py +41 -2
  21. flwr/server/superlink/linkstate/in_memory_linkstate.py +34 -0
  22. flwr/server/superlink/linkstate/linkstate.py +32 -0
  23. flwr/server/superlink/linkstate/sqlite_linkstate.py +60 -3
  24. flwr/supercore/constant.py +3 -0
  25. flwr/supercore/utils.py +190 -0
  26. flwr/superlink/servicer/control/control_grpc.py +2 -0
  27. flwr/superlink/servicer/control/control_servicer.py +88 -5
  28. flwr/supernode/nodestate/in_memory_nodestate.py +62 -1
  29. flwr/supernode/nodestate/nodestate.py +45 -0
  30. flwr/supernode/servicer/clientappio/clientappio_servicer.py +7 -1
  31. flwr/supernode/start_client_internal.py +7 -4
  32. {flwr-1.24.0.dist-info → flwr-1.25.0.dist-info}/METADATA +2 -4
  33. {flwr-1.24.0.dist-info → flwr-1.25.0.dist-info}/RECORD +35 -96
  34. flwr/cli/new/templates/__init__.py +0 -15
  35. flwr/cli/new/templates/app/.gitignore.tpl +0 -163
  36. flwr/cli/new/templates/app/LICENSE.tpl +0 -202
  37. flwr/cli/new/templates/app/README.baseline.md.tpl +0 -127
  38. flwr/cli/new/templates/app/README.flowertune.md.tpl +0 -68
  39. flwr/cli/new/templates/app/README.md.tpl +0 -37
  40. flwr/cli/new/templates/app/__init__.py +0 -15
  41. flwr/cli/new/templates/app/code/__init__.baseline.py.tpl +0 -1
  42. flwr/cli/new/templates/app/code/__init__.py +0 -15
  43. flwr/cli/new/templates/app/code/__init__.py.tpl +0 -1
  44. flwr/cli/new/templates/app/code/__init__.pytorch_legacy_api.py.tpl +0 -1
  45. flwr/cli/new/templates/app/code/client.baseline.py.tpl +0 -75
  46. flwr/cli/new/templates/app/code/client.huggingface.py.tpl +0 -93
  47. flwr/cli/new/templates/app/code/client.jax.py.tpl +0 -71
  48. flwr/cli/new/templates/app/code/client.mlx.py.tpl +0 -102
  49. flwr/cli/new/templates/app/code/client.numpy.py.tpl +0 -46
  50. flwr/cli/new/templates/app/code/client.pytorch.py.tpl +0 -80
  51. flwr/cli/new/templates/app/code/client.pytorch_legacy_api.py.tpl +0 -55
  52. flwr/cli/new/templates/app/code/client.sklearn.py.tpl +0 -108
  53. flwr/cli/new/templates/app/code/client.tensorflow.py.tpl +0 -82
  54. flwr/cli/new/templates/app/code/client.xgboost.py.tpl +0 -110
  55. flwr/cli/new/templates/app/code/dataset.baseline.py.tpl +0 -36
  56. flwr/cli/new/templates/app/code/flwr_tune/__init__.py +0 -15
  57. flwr/cli/new/templates/app/code/flwr_tune/client_app.py.tpl +0 -92
  58. flwr/cli/new/templates/app/code/flwr_tune/dataset.py.tpl +0 -87
  59. flwr/cli/new/templates/app/code/flwr_tune/models.py.tpl +0 -56
  60. flwr/cli/new/templates/app/code/flwr_tune/server_app.py.tpl +0 -73
  61. flwr/cli/new/templates/app/code/flwr_tune/strategy.py.tpl +0 -78
  62. flwr/cli/new/templates/app/code/model.baseline.py.tpl +0 -66
  63. flwr/cli/new/templates/app/code/server.baseline.py.tpl +0 -43
  64. flwr/cli/new/templates/app/code/server.huggingface.py.tpl +0 -42
  65. flwr/cli/new/templates/app/code/server.jax.py.tpl +0 -39
  66. flwr/cli/new/templates/app/code/server.mlx.py.tpl +0 -41
  67. flwr/cli/new/templates/app/code/server.numpy.py.tpl +0 -38
  68. flwr/cli/new/templates/app/code/server.pytorch.py.tpl +0 -41
  69. flwr/cli/new/templates/app/code/server.pytorch_legacy_api.py.tpl +0 -31
  70. flwr/cli/new/templates/app/code/server.sklearn.py.tpl +0 -44
  71. flwr/cli/new/templates/app/code/server.tensorflow.py.tpl +0 -38
  72. flwr/cli/new/templates/app/code/server.xgboost.py.tpl +0 -56
  73. flwr/cli/new/templates/app/code/strategy.baseline.py.tpl +0 -1
  74. flwr/cli/new/templates/app/code/task.huggingface.py.tpl +0 -98
  75. flwr/cli/new/templates/app/code/task.jax.py.tpl +0 -57
  76. flwr/cli/new/templates/app/code/task.mlx.py.tpl +0 -102
  77. flwr/cli/new/templates/app/code/task.numpy.py.tpl +0 -7
  78. flwr/cli/new/templates/app/code/task.pytorch.py.tpl +0 -99
  79. flwr/cli/new/templates/app/code/task.pytorch_legacy_api.py.tpl +0 -111
  80. flwr/cli/new/templates/app/code/task.sklearn.py.tpl +0 -67
  81. flwr/cli/new/templates/app/code/task.tensorflow.py.tpl +0 -52
  82. flwr/cli/new/templates/app/code/task.xgboost.py.tpl +0 -67
  83. flwr/cli/new/templates/app/code/utils.baseline.py.tpl +0 -1
  84. flwr/cli/new/templates/app/pyproject.baseline.toml.tpl +0 -146
  85. flwr/cli/new/templates/app/pyproject.flowertune.toml.tpl +0 -80
  86. flwr/cli/new/templates/app/pyproject.huggingface.toml.tpl +0 -65
  87. flwr/cli/new/templates/app/pyproject.jax.toml.tpl +0 -52
  88. flwr/cli/new/templates/app/pyproject.mlx.toml.tpl +0 -56
  89. flwr/cli/new/templates/app/pyproject.numpy.toml.tpl +0 -49
  90. flwr/cli/new/templates/app/pyproject.pytorch.toml.tpl +0 -53
  91. flwr/cli/new/templates/app/pyproject.pytorch_legacy_api.toml.tpl +0 -53
  92. flwr/cli/new/templates/app/pyproject.sklearn.toml.tpl +0 -52
  93. flwr/cli/new/templates/app/pyproject.tensorflow.toml.tpl +0 -53
  94. flwr/cli/new/templates/app/pyproject.xgboost.toml.tpl +0 -61
  95. {flwr-1.24.0.dist-info → flwr-1.25.0.dist-info}/WHEEL +0 -0
  96. {flwr-1.24.0.dist-info → flwr-1.25.0.dist-info}/entry_points.txt +0 -0
@@ -36,6 +36,7 @@ from flwr.supercore.primitives.asymmetric_ed25519 import (
36
36
  load_private_key,
37
37
  sign_message,
38
38
  )
39
+ from flwr.supercore.utils import parse_app_spec, request_download_link
39
40
 
40
41
  from ..auth_plugin.oidc_cli_plugin import OidcCliPlugin
41
42
  from ..config_utils import (
@@ -45,7 +46,7 @@ from ..config_utils import (
45
46
  )
46
47
  from ..constant import FEDERATION_CONFIG_HELP_MESSAGE
47
48
  from ..install import install_from_fab
48
- from ..utils import load_cli_auth_plugin, parse_app_spec, request_download_link
49
+ from ..utils import load_cli_auth_plugin
49
50
 
50
51
  TRY_AGAIN_MESSAGE = "Please try again or press CTRL+C to abort.\n"
51
52
 
@@ -104,12 +105,21 @@ def review(
104
105
  token = auth_plugin.access_token
105
106
 
106
107
  # Validate app version and ID format
107
- app_id, app_version = parse_app_spec(app_spec)
108
+ try:
109
+ app_id, app_version = parse_app_spec(app_spec)
110
+ except ValueError as e:
111
+ typer.secho(f"❌ {e}", fg=typer.colors.RED, err=True)
112
+ raise typer.Exit(code=1) from e
108
113
 
109
114
  # Download FAB
110
115
  typer.secho("Downloading FAB... ", fg=typer.colors.BLUE)
111
116
  url = f"{PLATFORM_API_URL}/hub/fetch-fab"
112
- presigned_url = request_download_link(app_id, app_version, url, "fab_url")
117
+ try:
118
+ presigned_url, _ = request_download_link(app_id, app_version, url, "fab_url")
119
+ except ValueError as e:
120
+ typer.secho(f"❌ {e}", fg=typer.colors.RED, err=True)
121
+ raise typer.Exit(code=1) from e
122
+
113
123
  fab_bytes = _download_fab(presigned_url)
114
124
 
115
125
  # Unpack FAB
@@ -41,6 +41,7 @@ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
41
41
  from flwr.proto.control_pb2_grpc import ControlStub
42
42
  from flwr.proto.node_pb2 import NodeInfo # pylint: disable=E0611
43
43
  from flwr.supercore.constant import NOOP_FEDERATION
44
+ from flwr.supercore.utils import humanize_duration
44
45
 
45
46
  from ..run_utils import RunRow, format_runs
46
47
  from ..utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
@@ -262,7 +263,7 @@ def _to_runs_table(run_list: list[RunRow]) -> Table:
262
263
  f"[bold]{row.run_id}[/bold]",
263
264
  f"@{row.fab_id}=={row.fab_version}",
264
265
  f"[{status_style}]{row.status_text}[/{status_style}]",
265
- row.elapsed,
266
+ f"{humanize_duration(row.elapsed)}",
266
267
  )
267
268
  table.add_row(*formatted_row)
268
269
 
@@ -298,7 +299,7 @@ def _to_json(
298
299
  for node in nodes:
299
300
  nodes_list.append(
300
301
  {
301
- "node_id": node.node_id,
302
+ "node_id": f"{node.node_id}",
302
303
  "owner": node.owner_name,
303
304
  "status": node.status,
304
305
  }
@@ -307,7 +308,7 @@ def _to_json(
307
308
  for run in runs:
308
309
  runs_list.append(
309
310
  {
310
- "run_id": run.run_id,
311
+ "run_id": f"{run.run_id}",
311
312
  "app": f"@{run.fab_id}=={run.fab_version}",
312
313
  "status": run.status_text,
313
314
  "elapsed": run.elapsed,
flwr/cli/ls.py CHANGED
@@ -40,6 +40,7 @@ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
40
40
  ListRunsResponse,
41
41
  )
42
42
  from flwr.proto.control_pb2_grpc import ControlStub
43
+ from flwr.supercore.utils import humanize_bytes, humanize_duration
43
44
 
44
45
  from .run_utils import RunRow, format_runs
45
46
  from .utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
@@ -231,7 +232,7 @@ def _to_table(run_list: list[RunRow]) -> Table:
231
232
  row.federation,
232
233
  f"@{row.fab_id}=={row.fab_version}",
233
234
  f"[{status_style}]{row.status_text}[/{status_style}]",
234
- row.elapsed,
235
+ humanize_duration(row.elapsed),
235
236
  status_changed_at,
236
237
  )
237
238
  table.add_row(*formatted_row)
@@ -265,11 +266,39 @@ def _to_detail_table(run: RunRow) -> Table:
265
266
  table.add_row("App", f"@{run.fab_id}=={run.fab_version}")
266
267
  table.add_row("FAB Hash", f"{run.fab_hash[:8]}...{run.fab_hash[-8:]}")
267
268
  table.add_row("Status", f"[{status_style}]{run.status_text}[/{status_style}]")
268
- table.add_row("Elapsed", f"[blue]{run.elapsed}[/blue]")
269
+ table.add_row("Elapsed", f"[blue]{humanize_duration(run.elapsed)}[/blue]")
269
270
  table.add_row("Pending At", run.pending_at)
270
271
  table.add_row("Starting At", run.starting_at)
271
272
  table.add_row("Running At", run.running_at)
272
273
  table.add_row("Finished At", run.finished_at)
274
+ table.add_row(
275
+ "Network traffic (inbound)",
276
+ f"[blue]{humanize_bytes(run.network_traffic_inbound)}[/blue]",
277
+ )
278
+ table.add_row(
279
+ "Network traffic (outbound)",
280
+ f"[blue]{humanize_bytes(run.network_traffic_outbound)}[/blue]",
281
+ )
282
+ table.add_row(
283
+ "Network Traffic (total)",
284
+ "[blue]"
285
+ f"{humanize_bytes(run.network_traffic_inbound + run.network_traffic_outbound)}"
286
+ "[/blue]",
287
+ )
288
+ table.add_row(
289
+ "Compute Time (ServerApp)",
290
+ f"[blue]{humanize_duration(run.compute_time_serverapp)}[/blue]",
291
+ )
292
+ table.add_row(
293
+ "Compute Time (ClientApp)",
294
+ f"[blue]{humanize_duration(run.compute_time_clientapp)}[/blue]",
295
+ )
296
+ table.add_row(
297
+ "Compute Time (total)",
298
+ "[blue]"
299
+ f"{humanize_duration(run.compute_time_serverapp + run.compute_time_clientapp)}"
300
+ "[/blue]",
301
+ )
273
302
 
274
303
  return table
275
304
 
@@ -291,7 +320,7 @@ def _to_json(run_list: list[RunRow]) -> str:
291
320
  for row in run_list:
292
321
  runs_list.append(
293
322
  {
294
- "run-id": row.run_id,
323
+ "run-id": f"{row.run_id}",
295
324
  "federation": row.federation,
296
325
  "fab-id": row.fab_id,
297
326
  "fab-name": row.fab_id.split("/")[-1],
@@ -303,6 +332,18 @@ def _to_json(run_list: list[RunRow]) -> str:
303
332
  "starting-at": row.starting_at,
304
333
  "running-at": row.running_at,
305
334
  "finished-at": row.finished_at,
335
+ "network-traffic": {
336
+ "inbound-bytes": row.network_traffic_inbound,
337
+ "outbound-bytes": row.network_traffic_outbound,
338
+ "total-bytes": row.network_traffic_inbound
339
+ + row.network_traffic_outbound,
340
+ },
341
+ "compute-time": {
342
+ "serverapp-seconds": row.compute_time_serverapp,
343
+ "clientapp-seconds": row.compute_time_clientapp,
344
+ "total-seconds": row.compute_time_serverapp
345
+ + row.compute_time_clientapp,
346
+ },
306
347
  }
307
348
  )
308
349
 
flwr/cli/new/new.py CHANGED
@@ -16,93 +16,84 @@
16
16
 
17
17
 
18
18
  import io
19
- import re
20
19
  import zipfile
21
- from enum import Enum
22
20
  from pathlib import Path
23
- from string import Template
24
- from typing import Annotated
21
+ from typing import Annotated, cast
25
22
 
26
23
  import requests
27
24
  import typer
28
25
 
29
26
  from flwr.supercore.constant import PLATFORM_API_URL
27
+ from flwr.supercore.utils import parse_app_spec, request_download_link
30
28
 
31
- from ..utils import (
32
- is_valid_project_name,
33
- parse_app_spec,
34
- prompt_options,
35
- prompt_text,
36
- request_download_link,
37
- sanitize_project_name,
38
- )
29
+ from ..utils import prompt_options, prompt_text
39
30
 
40
31
 
41
- class MlFramework(str, Enum):
42
- """Available frameworks."""
43
-
44
- PYTORCH = "PyTorch"
45
- TENSORFLOW = "TensorFlow"
46
- SKLEARN = "sklearn"
47
- HUGGINGFACE = "HuggingFace"
48
- JAX = "JAX"
49
- MLX = "MLX"
50
- NUMPY = "NumPy"
51
- XGBOOST = "XGBoost"
52
- FLOWERTUNE = "FlowerTune"
53
- BASELINE = "Flower Baseline"
54
- PYTORCH_LEGACY_API = "PyTorch (Legacy API, deprecated)"
55
-
56
-
57
- class LlmChallengeName(str, Enum):
58
- """Available LLM challenges."""
59
-
60
- GENERALNLP = "GeneralNLP"
61
- FINANCE = "Finance"
62
- MEDICAL = "Medical"
63
- CODE = "Code"
64
-
65
-
66
- class TemplateNotFound(Exception):
67
- """Raised when template does not exist."""
68
-
69
-
70
- def load_template(name: str) -> str:
71
- """Load template from template directory and return as text."""
72
- tpl_dir = (Path(__file__).parent / "templates").absolute()
73
- tpl_file_path = tpl_dir / name
74
-
75
- if not tpl_file_path.is_file():
76
- raise TemplateNotFound(f"Template '{name}' not found")
77
-
78
- with open(tpl_file_path, encoding="utf-8") as tpl_file:
79
- return tpl_file.read()
80
-
81
-
82
- def render_template(template: str, data: dict[str, str]) -> str:
83
- """Render template."""
84
- tpl_file = load_template(template)
85
- tpl = Template(tpl_file)
86
- if ".gitignore" not in template:
87
- return tpl.substitute(data)
88
- return tpl.template
89
-
32
+ # pylint: disable=too-many-locals,too-many-branches,too-many-statements
33
+ def new(
34
+ app_spec: Annotated[
35
+ str | None,
36
+ typer.Argument(
37
+ help="Flower app specifier. Use the format "
38
+ "'@account_name/app_name' or '@account_name/app_name==x.y.z'. "
39
+ "Version is optional (defaults to latest)."
40
+ ),
41
+ ] = None,
42
+ framework: Annotated[
43
+ str | None,
44
+ typer.Option(case_sensitive=False, help="Deprecated. The ML framework to use"),
45
+ ] = None,
46
+ username: Annotated[
47
+ str | None,
48
+ typer.Option(
49
+ case_sensitive=False, help="Deprecated. The Flower username of the author"
50
+ ),
51
+ ] = None,
52
+ ) -> None:
53
+ """Create new Flower App."""
54
+ if framework is not None or username is not None:
55
+ typer.secho(
56
+ "❌ The --framework and --username options are deprecated and will be "
57
+ "removed in future versions of Flower. Please provide an app specifier "
58
+ "after `flwr new` instead, e.g., '@account_name/app_name' or "
59
+ "'@account_name/app_name==x.y.z'.",
60
+ fg=typer.colors.RED,
61
+ bold=True,
62
+ err=True,
63
+ )
64
+ raise typer.Exit(code=1)
90
65
 
91
- def create_file(file_path: Path, content: str) -> None:
92
- """Create file including all nessecary directories and write content into file."""
93
- file_path.parent.mkdir(exist_ok=True)
94
- file_path.write_text(content, encoding="utf-8")
66
+ if app_spec is None:
67
+ # Fetch recommended apps
68
+ print(
69
+ typer.style(
70
+ "\n🌸 Fetching recommended apps...",
71
+ fg=typer.colors.GREEN,
72
+ bold=True,
73
+ )
74
+ )
75
+ apps = fetch_recommended_apps()
95
76
 
77
+ if not apps:
78
+ typer.secho(
79
+ "No recommended apps found. Please provide an app specifier manually.",
80
+ fg=typer.colors.YELLOW,
81
+ )
82
+ app_spec = prompt_text("Please provide the app specifier")
83
+ else:
84
+ # Extract app_ids and show selection menu
85
+ app_ids = [app["app_id"] for app in apps]
86
+ app_spec = prompt_options(
87
+ "Select a Flower App to create by entering "
88
+ "the number from the list below:",
89
+ app_ids,
90
+ )
96
91
 
97
- def render_and_create(file_path: Path, template: str, context: dict[str, str]) -> None:
98
- """Render template and write to file."""
99
- content = render_template(template, context)
100
- create_file(file_path, content)
92
+ # Download remote app
93
+ download_remote_app_via_api(app_spec)
101
94
 
102
95
 
103
- def print_success_prompt(
104
- package_name: str, llm_challenge_str: str | None = None
105
- ) -> None:
96
+ def print_success_prompt(package_name: str) -> None:
106
97
  """Print styled setup instructions for running a new Flower App after creation."""
107
98
  prompt = typer.style(
108
99
  "🎊 Flower App creation successful.\n\n"
@@ -111,10 +102,8 @@ def print_success_prompt(
111
102
  bold=True,
112
103
  )
113
104
 
114
- _add = " huggingface-cli login\n" if llm_challenge_str else ""
115
-
116
105
  prompt += typer.style(
117
- f" cd {package_name} && pip install -e .\n" + _add + "\n",
106
+ f" cd {package_name} && pip install -e .\n\n",
118
107
  fg=typer.colors.BRIGHT_CYAN,
119
108
  bold=True,
120
109
  )
@@ -141,6 +130,25 @@ def print_success_prompt(
141
130
  print(prompt)
142
131
 
143
132
 
133
+ def fetch_recommended_apps() -> list[dict[str, str]]:
134
+ """Fetch recommended apps from Platform API."""
135
+ url = f"{PLATFORM_API_URL}/hub/apps?tag=recommended"
136
+ try:
137
+ response = requests.get(url, headers={"accept": "application/json"}, timeout=10)
138
+ response.raise_for_status()
139
+ data = response.json()
140
+ apps = data.get("apps", [])
141
+ return cast(list[dict[str, str]], apps)
142
+
143
+ except requests.RequestException as e:
144
+ typer.secho(
145
+ f"❌ Failed to fetch recommended apps: {e}",
146
+ fg=typer.colors.RED,
147
+ err=True,
148
+ )
149
+ raise typer.Exit(code=1) from e
150
+
151
+
144
152
  # Security: prevent zip-slip
145
153
  def _safe_extract_zip(zf: zipfile.ZipFile, dest_dir: Path) -> None:
146
154
  """Extract ZIP file into destination directory."""
@@ -205,7 +213,12 @@ def _download_zip_to_memory(presigned_url: str) -> io.BytesIO:
205
213
  def download_remote_app_via_api(app_spec: str) -> None:
206
214
  """Download App from Platform API."""
207
215
  # Validate app version and ID format
208
- app_id, app_version = parse_app_spec(app_spec)
216
+ try:
217
+ app_id, app_version = parse_app_spec(app_spec)
218
+ except ValueError as e:
219
+ typer.secho(f"❌ {e}", fg=typer.colors.RED, err=True)
220
+ raise typer.Exit(code=1) from e
221
+
209
222
  app_name = app_id.split("/")[1]
210
223
 
211
224
  project_dir = Path.cwd() / app_name
@@ -219,236 +232,32 @@ def download_remote_app_via_api(app_spec: str) -> None:
219
232
  ):
220
233
  return
221
234
 
222
- print(
223
- typer.style(
224
- f"\n🔗 Requesting download link for {app_id}...",
225
- fg=typer.colors.GREEN,
226
- bold=True,
227
- )
235
+ typer.secho(
236
+ f"\n🔗 Requesting download link for {app_id}...",
237
+ fg=typer.colors.GREEN,
238
+ bold=True,
228
239
  )
229
240
  # Fetch ZIP downloading URL
230
241
  url = f"{PLATFORM_API_URL}/hub/fetch-zip"
231
- presigned_url = request_download_link(app_id, app_version, url, "zip_url")
242
+ try:
243
+ presigned_url, _ = request_download_link(app_id, app_version, url, "zip_url")
244
+ except ValueError as e:
245
+ typer.secho(f"❌ {e}", fg=typer.colors.RED, err=True)
246
+ raise typer.Exit(code=1) from e
232
247
 
233
- print(
234
- typer.style(
235
- "⬇️ Downloading ZIP into memory...",
236
- fg=typer.colors.GREEN,
237
- bold=True,
238
- )
248
+ typer.secho(
249
+ "🔽 Downloading ZIP into memory...",
250
+ fg=typer.colors.GREEN,
251
+ bold=True,
239
252
  )
240
253
  zip_buf = _download_zip_to_memory(presigned_url)
241
254
 
242
- print(
243
- typer.style(
244
- f"📦 Unpacking into {project_dir}...",
245
- fg=typer.colors.GREEN,
246
- bold=True,
247
- )
255
+ typer.secho(
256
+ f"📦 Unpacking into {project_dir}...",
257
+ fg=typer.colors.GREEN,
258
+ bold=True,
248
259
  )
249
260
  with zipfile.ZipFile(zip_buf) as zf:
250
261
  _safe_extract_zip(zf, Path.cwd())
251
262
 
252
263
  print_success_prompt(app_name)
253
-
254
-
255
- # pylint: disable=too-many-locals,too-many-branches,too-many-statements
256
- def new(
257
- app_name: Annotated[
258
- str | None,
259
- typer.Argument(
260
- help="Flower app name. For remote apps, use the format "
261
- "'@account_name/app_name' or '@account_name/app_name==x.y.z'. "
262
- "Version is optional (defaults to latest)."
263
- ),
264
- ] = None,
265
- framework: Annotated[
266
- MlFramework | None,
267
- typer.Option(case_sensitive=False, help="The ML framework to use"),
268
- ] = None,
269
- username: Annotated[
270
- str | None,
271
- typer.Option(case_sensitive=False, help="The Flower username of the author"),
272
- ] = None,
273
- ) -> None:
274
- """Create new Flower App."""
275
- if app_name is None:
276
- app_name = prompt_text("Please provide the app name")
277
-
278
- # Download remote app
279
- if app_name and app_name.startswith("@"):
280
- download_remote_app_via_api(app_name)
281
- return
282
-
283
- if not is_valid_project_name(app_name):
284
- app_name = prompt_text(
285
- "Please provide a name that only contains "
286
- "characters in {'-', a-zA-Z', '0-9'}",
287
- predicate=is_valid_project_name,
288
- default=sanitize_project_name(app_name),
289
- )
290
-
291
- # Set project directory path
292
- package_name = re.sub(r"[-_.]+", "-", app_name).lower()
293
- import_name = package_name.replace("-", "_")
294
- project_dir = Path.cwd() / package_name
295
-
296
- if project_dir.exists():
297
- if not typer.confirm(
298
- typer.style(
299
- f"\n💬 {app_name} already exists, do you want to override it?",
300
- fg=typer.colors.MAGENTA,
301
- bold=True,
302
- )
303
- ):
304
- return
305
-
306
- if username is None:
307
- username = prompt_text("Please provide your Flower username")
308
-
309
- if framework is not None:
310
- framework_str = str(framework.value)
311
- else:
312
- framework_str = prompt_options(
313
- "Please select ML framework by typing in the number",
314
- [mlf.value for mlf in MlFramework],
315
- )
316
-
317
- llm_challenge_str = None
318
- if framework_str == MlFramework.FLOWERTUNE:
319
- llm_challenge_value = prompt_options(
320
- "Please select LLM challenge by typing in the number",
321
- sorted([challenge.value for challenge in LlmChallengeName]),
322
- )
323
- llm_challenge_str = llm_challenge_value.lower()
324
-
325
- if framework_str == MlFramework.BASELINE:
326
- framework_str = "baseline"
327
-
328
- if framework_str == MlFramework.PYTORCH_LEGACY_API:
329
- framework_str = "pytorch_legacy_api"
330
-
331
- print(
332
- typer.style(
333
- f"\n🔨 Creating Flower App {app_name}...",
334
- fg=typer.colors.GREEN,
335
- bold=True,
336
- )
337
- )
338
-
339
- context = {
340
- "framework_str": framework_str,
341
- "import_name": import_name.replace("-", "_"),
342
- "package_name": package_name,
343
- "project_name": app_name,
344
- "username": username,
345
- }
346
-
347
- template_name = framework_str.lower()
348
-
349
- # List of files to render
350
- if llm_challenge_str:
351
- files = {
352
- ".gitignore": {"template": "app/.gitignore.tpl"},
353
- "pyproject.toml": {"template": f"app/pyproject.{template_name}.toml.tpl"},
354
- "README.md": {"template": f"app/README.{template_name}.md.tpl"},
355
- f"{import_name}/__init__.py": {"template": "app/code/__init__.py.tpl"},
356
- f"{import_name}/server_app.py": {
357
- "template": "app/code/flwr_tune/server_app.py.tpl"
358
- },
359
- f"{import_name}/client_app.py": {
360
- "template": "app/code/flwr_tune/client_app.py.tpl"
361
- },
362
- f"{import_name}/models.py": {
363
- "template": "app/code/flwr_tune/models.py.tpl"
364
- },
365
- f"{import_name}/dataset.py": {
366
- "template": "app/code/flwr_tune/dataset.py.tpl"
367
- },
368
- f"{import_name}/strategy.py": {
369
- "template": "app/code/flwr_tune/strategy.py.tpl"
370
- },
371
- }
372
-
373
- # Challenge specific context
374
- fraction_train = "0.2" if llm_challenge_str == "code" else "0.1"
375
- if llm_challenge_str == "generalnlp":
376
- challenge_name = "General NLP"
377
- num_clients = "20"
378
- dataset_name = "flwrlabs/alpaca-gpt4"
379
- elif llm_challenge_str == "finance":
380
- challenge_name = "Finance"
381
- num_clients = "50"
382
- dataset_name = "flwrlabs/fingpt-sentiment-train"
383
- elif llm_challenge_str == "medical":
384
- challenge_name = "Medical"
385
- num_clients = "20"
386
- dataset_name = "flwrlabs/medical-meadow-medical-flashcards"
387
- else:
388
- challenge_name = "Code"
389
- num_clients = "10"
390
- dataset_name = "flwrlabs/code-alpaca-20k"
391
-
392
- context["llm_challenge_str"] = llm_challenge_str
393
- context["fraction_train"] = fraction_train
394
- context["challenge_name"] = challenge_name
395
- context["num_clients"] = num_clients
396
- context["dataset_name"] = dataset_name
397
- else:
398
- files = {
399
- ".gitignore": {"template": "app/.gitignore.tpl"},
400
- "README.md": {"template": "app/README.md.tpl"},
401
- "pyproject.toml": {"template": f"app/pyproject.{template_name}.toml.tpl"},
402
- f"{import_name}/__init__.py": {"template": "app/code/__init__.py.tpl"},
403
- f"{import_name}/server_app.py": {
404
- "template": f"app/code/server.{template_name}.py.tpl"
405
- },
406
- f"{import_name}/client_app.py": {
407
- "template": f"app/code/client.{template_name}.py.tpl"
408
- },
409
- }
410
-
411
- # Depending on the framework, generate task.py file
412
- frameworks_with_tasks = [
413
- MlFramework.PYTORCH.value,
414
- MlFramework.JAX.value,
415
- MlFramework.HUGGINGFACE.value,
416
- MlFramework.MLX.value,
417
- MlFramework.TENSORFLOW.value,
418
- MlFramework.SKLEARN.value,
419
- MlFramework.NUMPY.value,
420
- MlFramework.XGBOOST.value,
421
- "pytorch_legacy_api",
422
- ]
423
- if framework_str in frameworks_with_tasks:
424
- files[f"{import_name}/task.py"] = {
425
- "template": f"app/code/task.{template_name}.py.tpl"
426
- }
427
-
428
- if framework_str == "pytorch_legacy_api":
429
- # Use custom __init__ that better captures name of framework
430
- files[f"{import_name}/__init__.py"] = {
431
- "template": f"app/code/__init__.{framework_str}.py.tpl"
432
- }
433
-
434
- if framework_str == "baseline":
435
- # Include additional files for baseline template
436
- for file_name in ["model", "dataset", "strategy", "utils", "__init__"]:
437
- files[f"{import_name}/{file_name}.py"] = {
438
- "template": f"app/code/{file_name}.{template_name}.py.tpl"
439
- }
440
-
441
- # Replace README.md
442
- files["README.md"]["template"] = f"app/README.{template_name}.md.tpl"
443
-
444
- # Add LICENSE
445
- files["LICENSE"] = {"template": "app/LICENSE.tpl"}
446
-
447
- for file_path, value in files.items():
448
- render_and_create(
449
- file_path=project_dir / file_path,
450
- template=value["template"],
451
- context=context,
452
- )
453
-
454
- print_success_prompt(package_name, llm_challenge_str)
flwr/cli/run/run.py CHANGED
@@ -46,14 +46,10 @@ from flwr.common.typing import Fab
46
46
  from flwr.proto.control_pb2 import StartRunRequest # pylint: disable=E0611
47
47
  from flwr.proto.control_pb2_grpc import ControlStub
48
48
  from flwr.supercore.constant import NOOP_FEDERATION
49
+ from flwr.supercore.utils import parse_app_spec
49
50
 
50
51
  from ..log import start_stream
51
- from ..utils import (
52
- flwr_cli_grpc_exc_handler,
53
- init_channel,
54
- load_cli_auth_plugin,
55
- parse_app_spec,
56
- )
52
+ from ..utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
57
53
 
58
54
  CONN_REFRESH_PERIOD = 60 # Connection refresh period for log streaming (seconds)
59
55
 
@@ -111,8 +107,15 @@ def run(
111
107
  app_spec = None
112
108
  if (app_str := str(app)).startswith("@"):
113
109
  # Validate app version and ID format
114
- _ = parse_app_spec(app_str)
110
+ try:
111
+ _ = parse_app_spec(app_str)
112
+ except ValueError as e:
113
+ typer.secho(f"❌ {e}", fg=typer.colors.RED, err=True)
114
+ raise typer.Exit(code=1) from e
115
+
115
116
  app_spec = app_str
117
+ # Set `app` to current directory for credential storage
118
+ app = Path(".")
116
119
  is_remote_app = app_spec is not None
117
120
 
118
121
  typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
@@ -212,22 +215,14 @@ def _run_with_control_api(
212
215
  f"🎊 Successfully started run {res.run_id}", fg=typer.colors.GREEN
213
216
  )
214
217
  else:
215
- if is_remote_app:
216
- typer.secho(
217
- "❌ Failed to start run. Please check that the provided "
218
- "app identifier (@account_name/app_name) is correct.",
219
- fg=typer.colors.RED,
220
- err=True,
221
- )
222
- else:
223
- typer.secho("❌ Failed to start run", fg=typer.colors.RED, err=True)
218
+ typer.secho("❌ Failed to start run", fg=typer.colors.RED, err=True)
224
219
  raise typer.Exit(code=1)
225
220
 
226
221
  if output_format == CliOutputFormat.JSON:
227
222
  # Only include FAB metadata if we actually built a local FAB
228
223
  payload: dict[str, Any] = {
229
224
  "success": res.HasField("run_id"),
230
- "run-id": res.run_id if res.HasField("run_id") else None,
225
+ "run-id": f"{res.run_id}" if res.HasField("run_id") else None,
231
226
  }
232
227
  if not is_remote_app:
233
228
  payload.update(