diff --git a/pyproject.toml b/pyproject.toml index 297574ef67e..065d5bc1f21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ exclude = [ ] [tool.poetry.scripts] +flwr = "flwr.cli.app:app" flower-driver-api = "flwr.server:run_driver_api" flower-fleet-api = "flwr.server:run_fleet_api" flower-superlink = "flwr.server:run_superlink" @@ -67,6 +68,7 @@ protobuf = "^4.25.2" cryptography = "^41.0.2" pycryptodome = "^3.18.0" iterators = "^0.0.2" +typer = { version = "^0.9.0", extras=["all"] } # Optional dependencies (VCE) ray = { version = "==2.6.3", optional = true } pydantic = { version = "<2.0.0", optional = true } diff --git a/src/py/flwr/cli/__init__.py b/src/py/flwr/cli/__init__.py new file mode 100644 index 00000000000..d4d3b8ac4d4 --- /dev/null +++ b/src/py/flwr/cli/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower command line interface.""" diff --git a/src/py/flwr/cli/app.py b/src/py/flwr/cli/app.py new file mode 100644 index 00000000000..dc390de0354 --- /dev/null +++ b/src/py/flwr/cli/app.py @@ -0,0 +1,35 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower command line interface.""" + +import typer + +from .example import example +from .new import new + +app = typer.Typer( + help=typer.style( + "flwr is the Flower command line interface.", + fg=typer.colors.BRIGHT_YELLOW, + bold=True, + ), + no_args_is_help=True, +) + +app.command()(new) +app.command()(example) + +if __name__ == "__main__": + app() diff --git a/src/py/flwr/cli/example.py b/src/py/flwr/cli/example.py new file mode 100644 index 00000000000..625ca872964 --- /dev/null +++ b/src/py/flwr/cli/example.py @@ -0,0 +1,64 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower command line interface `example` command.""" + +import json +import os +import subprocess +import tempfile +import urllib.request + +from .utils import prompt_options + + +def example() -> None: + """Clone a Flower example. + + All examples available in the Flower repository are available through this command. + """ + # Load list of examples directly from GitHub + url = "https://api.github.com/repos/adap/flower/git/trees/main" + with urllib.request.urlopen(url) as res: + data = json.load(res) + examples_directory_url = [ + item["url"] for item in data["tree"] if item["path"] == "examples" + ][0] + + with urllib.request.urlopen(examples_directory_url) as res: + data = json.load(res) + example_names = [ + item["path"] for item in data["tree"] if item["path"] not in [".gitignore"] + ] + + example_name = prompt_options( + "Please select example by typing in the number", + example_names, + ) + + with tempfile.TemporaryDirectory() as tmpdirname: + subprocess.check_output( + [ + "git", + "clone", + "--depth=1", + "https://github.com/adap/flower.git", + tmpdirname, + ] + ) + examples_dir = os.path.join(tmpdirname, "examples", example_name) + subprocess.check_output(["mv", examples_dir, "."]) + + print() + print(f"Example ready to use in {os.path.join(os.getcwd(), example_name)}") diff --git a/src/py/flwr/cli/new/__init__.py b/src/py/flwr/cli/new/__init__.py new file mode 100644 index 00000000000..a973f47021c --- /dev/null +++ b/src/py/flwr/cli/new/__init__.py @@ -0,0 +1,21 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower command line interface `new` command.""" + +from .new import new as new + +__all__ = [ + "new", +] diff --git a/src/py/flwr/cli/new/new.py b/src/py/flwr/cli/new/new.py new file mode 100644 index 00000000000..d5db6091344 --- /dev/null +++ b/src/py/flwr/cli/new/new.py @@ -0,0 +1,130 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower command line interface `new` command.""" + +import os +from enum import Enum +from string import Template +from typing import Dict, Optional + +import typer +from typing_extensions import Annotated + +from ..utils import prompt_options + + +class MlFramework(str, Enum): + """Available frameworks.""" + + PYTORCH = "PyTorch" + TENSORFLOW = "TensorFlow" + + +class TemplateNotFound(Exception): + """Raised when template does not exist.""" + + +def load_template(name: str) -> str: + """Load template from template directory and return as text.""" + tpl_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "templates")) + tpl_file_path = os.path.join(tpl_dir, name) + + if not os.path.isfile(tpl_file_path): + raise TemplateNotFound(f"Template '{name}' not found") + + with open(tpl_file_path, encoding="utf-8") as tpl_file: + return tpl_file.read() + + +def render_template(template: str, data: Dict[str, str]) -> str: + """Render template.""" + tpl_file = load_template(template) + tpl = Template(tpl_file) + result = tpl.substitute(data) + return result + + +def create_file(file_path: str, content: str) -> None: + """Create file including all nessecary directories and write content into file.""" + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + + +def render_and_create(file_path: str, template: str, context: Dict[str, str]) -> None: + """Render template and write to file.""" + content = render_template(template, context) + create_file(file_path, content) + + +def new( + project_name: Annotated[ + str, + typer.Argument(metavar="project_name", help="The name of the project"), + ], + framework: Annotated[ + Optional[MlFramework], + typer.Option(case_sensitive=False, help="The ML framework to use"), + ] = None, +) -> None: + """Create new Flower project.""" + print(f"Creating Flower project {project_name}...") + + if framework is not None: + framework_str = str(framework.value) + else: + framework_value = prompt_options( + "Please select ML framework by typing in the number", + [mlf.value for mlf in MlFramework], + ) + selected_value = [ + name + for name, value in vars(MlFramework).items() + if value == framework_value + ] + framework_str = selected_value[0] + + # Set project directory path + cwd = os.getcwd() + pnl = project_name.lower() + project_dir = os.path.join(cwd, pnl) + + # List of files to render + files = { + "README.md": { + "template": "app/README.md.tpl", + }, + "requirements.txt": { + "template": f"app/requirements.{framework_str.lower()}.txt.tpl" + }, + "flower.toml": {"template": "app/flower.toml.tpl"}, + f"{pnl}/__init__.py": {"template": "app/code/__init__.py.tpl"}, + f"{pnl}/server.py": { + "template": f"app/code/server.{framework_str.lower()}.py.tpl" + }, + f"{pnl}/client.py": { + "template": f"app/code/client.{framework_str.lower()}.py.tpl" + }, + } + context = {"project_name": project_name} + + for file_path, value in files.items(): + render_and_create( + file_path=os.path.join(project_dir, file_path), + template=value["template"], + context=context, + ) + + print("Project creation successful.") diff --git a/src/py/flwr/cli/new/new_test.py b/src/py/flwr/cli/new/new_test.py new file mode 100644 index 00000000000..39717bc67ab --- /dev/null +++ b/src/py/flwr/cli/new/new_test.py @@ -0,0 +1,93 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Test for Flower command line interface `new` command.""" + +import os + +from .new import MlFramework, create_file, load_template, new, render_template + + +def test_load_template() -> None: + """Test if load_template returns a string.""" + # Prepare + filename = "app/README.md.tpl" + + # Execute + text = load_template(filename) + + # Assert + assert isinstance(text, str) + + +def test_render_template() -> None: + """Test if a string is correctly substituted.""" + # Prepare + filename = "app/README.md.tpl" + data = {"project_name": "FedGPT"} + + # Execute + result = render_template(filename, data) + + # Assert + assert "# FedGPT" in result + + +def test_create_file(tmp_path: str) -> None: + """Test if file with content is created.""" + # Prepare + file_path = os.path.join(tmp_path, "test.txt") + content = "Foobar" + + # Execute + create_file(file_path, content) + + # Assert + with open(file_path, encoding="utf-8") as f: + text = f.read() + + assert text == "Foobar" + + +def test_new(tmp_path: str) -> None: + """Test if project is created for framework.""" + # Prepare + project_name = "FedGPT" + framework = MlFramework.PYTORCH + expected_files_top_level = { + "requirements.txt", + "fedgpt", + "README.md", + "flower.toml", + } + expected_files_module = { + "__init__.py", + "server.py", + "client.py", + } + + # Change into the temprorary directory + os.chdir(tmp_path) + + # Execute + new(project_name=project_name, framework=framework) + + # Assert + file_list = os.listdir(os.path.join(tmp_path, project_name.lower())) + assert set(file_list) == expected_files_top_level + + file_list = os.listdir( + os.path.join(tmp_path, project_name.lower(), project_name.lower()) + ) + assert set(file_list) == expected_files_module diff --git a/src/py/flwr/cli/new/templates/__init__.py b/src/py/flwr/cli/new/templates/__init__.py new file mode 100644 index 00000000000..7a951c2da1a --- /dev/null +++ b/src/py/flwr/cli/new/templates/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower CLI `new` command templates.""" diff --git a/src/py/flwr/cli/new/templates/app/README.md.tpl b/src/py/flwr/cli/new/templates/app/README.md.tpl new file mode 100644 index 00000000000..7904fa8d3a3 --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/README.md.tpl @@ -0,0 +1,33 @@ +# $project_name + +## Install dependencies + +```bash +pip install -r requirements.txt +``` + +## Start the SuperLink + +```bash +flower-superlink --insecure +``` + +## Start the long-running Flower client + +In a new terminal window, start the first long-running Flower client: + +```bash +flower-client-app client:app --insecure +``` + +In yet another new terminal window, start the second long-running Flower client: + +```bash +flower-client-app client:app --insecure +``` + +## Start the ServerApp + +```bash +flower-server-app server:app --insecure +``` diff --git a/src/py/flwr/cli/new/templates/app/__init__.py b/src/py/flwr/cli/new/templates/app/__init__.py new file mode 100644 index 00000000000..617628fc913 --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower CLI `new` command app templates.""" diff --git a/src/py/flwr/cli/new/templates/app/code/__init__.py b/src/py/flwr/cli/new/templates/app/code/__init__.py new file mode 100644 index 00000000000..7f1a0e9f4fa --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/code/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower CLI `new` command app / code templates.""" diff --git a/src/py/flwr/cli/new/templates/app/code/__init__.py.tpl b/src/py/flwr/cli/new/templates/app/code/__init__.py.tpl new file mode 100644 index 00000000000..57998c81efb --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/code/__init__.py.tpl @@ -0,0 +1 @@ +"""$project_name.""" diff --git a/src/py/flwr/cli/new/templates/app/code/client.pytorch.py.tpl b/src/py/flwr/cli/new/templates/app/code/client.pytorch.py.tpl new file mode 100644 index 00000000000..006d00f75e4 --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/code/client.pytorch.py.tpl @@ -0,0 +1 @@ +"""$project_name: A Flower / PyTorch app.""" diff --git a/src/py/flwr/cli/new/templates/app/code/client.tensorflow.py.tpl b/src/py/flwr/cli/new/templates/app/code/client.tensorflow.py.tpl new file mode 100644 index 00000000000..cc00f8ff0b8 --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/code/client.tensorflow.py.tpl @@ -0,0 +1 @@ +"""$project_name: A Flower / TensorFlow app.""" diff --git a/src/py/flwr/cli/new/templates/app/code/server.pytorch.py.tpl b/src/py/flwr/cli/new/templates/app/code/server.pytorch.py.tpl new file mode 100644 index 00000000000..006d00f75e4 --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/code/server.pytorch.py.tpl @@ -0,0 +1 @@ +"""$project_name: A Flower / PyTorch app.""" diff --git a/src/py/flwr/cli/new/templates/app/code/server.tensorflow.py.tpl b/src/py/flwr/cli/new/templates/app/code/server.tensorflow.py.tpl new file mode 100644 index 00000000000..cc00f8ff0b8 --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/code/server.tensorflow.py.tpl @@ -0,0 +1 @@ +"""$project_name: A Flower / TensorFlow app.""" diff --git a/src/py/flwr/cli/new/templates/app/flower.toml.tpl b/src/py/flwr/cli/new/templates/app/flower.toml.tpl new file mode 100644 index 00000000000..4dd7117bc3a --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/flower.toml.tpl @@ -0,0 +1,10 @@ +[flower] +name = "$project_name" +version = "1.0.0" +description = "" +license = "Apache-2.0" +authors = ["The Flower Authors "] + +[components] +serverapp = "$project_name.server:app" +clientapp = "$project_name.client:app" diff --git a/src/py/flwr/cli/new/templates/app/requirements.pytorch.txt.tpl b/src/py/flwr/cli/new/templates/app/requirements.pytorch.txt.tpl new file mode 100644 index 00000000000..d9426e0b62c --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/requirements.pytorch.txt.tpl @@ -0,0 +1,4 @@ +flwr>=1.8, <2.0 +flwr-datasets[vision]>=0.0.2, <1.0.0 +torch==1.13.1 +torchvision==0.14.1 diff --git a/src/py/flwr/cli/new/templates/app/requirements.tensorflow.txt.tpl b/src/py/flwr/cli/new/templates/app/requirements.tensorflow.txt.tpl new file mode 100644 index 00000000000..4fe7bfdc1e8 --- /dev/null +++ b/src/py/flwr/cli/new/templates/app/requirements.tensorflow.txt.tpl @@ -0,0 +1,4 @@ +flwr>=1.8, <2.0 +flwr-datasets[vision]>=0.0.2, <1.0.0 +tensorflow-macos>=2.9.1, != 2.11.1 ; sys_platform == "darwin" and platform_machine == "arm64" +tensorflow-cpu>=2.9.1, != 2.11.1 ; platform_machine == "x86_64" diff --git a/src/py/flwr/cli/utils.py b/src/py/flwr/cli/utils.py new file mode 100644 index 00000000000..d61189ffc4e --- /dev/null +++ b/src/py/flwr/cli/utils.py @@ -0,0 +1,54 @@ +# Copyright 2024 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Flower command line interface utils.""" + +from typing import List + +import typer + + +def prompt_options(text: str, options: List[str]) -> str: + """Ask user to select one of the given options and return the selected item.""" + # Turn options into a list with index as in " [ 0] quickstart-pytorch" + options_formatted = [ + " [ " + + typer.style(index, fg=typer.colors.GREEN, bold=True) + + "]" + + f" {typer.style(name, fg=typer.colors.WHITE, bold=True)}" + for index, name in enumerate(options) + ] + + while True: + index = typer.prompt( + "\n" + + typer.style(f"💬 {text}", fg=typer.colors.MAGENTA, bold=True) + + "\n\n" + + "\n".join(options_formatted) + + "\n\n\n" + ) + try: + options[int(index)] # pylint: disable=expression-not-assigned + break + except IndexError: + print(typer.style("❌ Index out of range", fg=typer.colors.RED, bold=True)) + continue + except ValueError: + print( + typer.style("❌ Please choose a number", fg=typer.colors.RED, bold=True) + ) + continue + + result = options[int(index)] + return result