diff --git a/sdk/python/flet/cli/cli.py b/sdk/python/flet/cli/cli.py new file mode 100644 index 0000000000..c1cfa8c153 --- /dev/null +++ b/sdk/python/flet/cli/cli.py @@ -0,0 +1,79 @@ +import argparse +import sys +import flet.version +import flet.cli.commands.run +import flet.cli.commands.build + +# Source https://stackoverflow.com/a/26379693 +def set_default_subparser(self, name, args=None, positional_args=0): + """default subparser selection. Call after setup, just before parse_args() + name: is the name of the subparser to call by default + args: if set is the argument list handed to parse_args() + + , tested with 2.7, 3.2, 3.3, 3.4 + it works with 2.6 assuming argparse is installed + """ + subparser_found = False + existing_default = False # check if default parser previously defined + for arg in sys.argv[1:]: + if arg in ["-h", "--help", "--version"]: # global help if no subparser + break + else: + for x in self._subparsers._actions: + if not isinstance(x, argparse._SubParsersAction): + continue + for sp_name in x._name_parser_map.keys(): + if sp_name in sys.argv[1:]: + subparser_found = True + if sp_name == name: # check existance of default parser + existing_default = True + if not subparser_found: + # If the default subparser is not among the existing ones, + # create a new parser. + # As this is called just before 'parse_args', the default + # parser created here will not pollute the help output. + + if not existing_default: + for x in self._subparsers._actions: + if not isinstance(x, argparse._SubParsersAction): + continue + x.add_parser(name) + break # this works OK, but should I check further? + + # insert default in last position before global positional + # arguments, this implies no global options are specified after + # first positional argument + if args is None and len(sys.argv) > 1: + sys.argv.insert(positional_args, name) + elif args is not None: + args.insert(positional_args, name) + # print(sys.argv) + + +argparse.ArgumentParser.set_default_subparser = set_default_subparser + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--version", action="version", version=flet.version.version) + sp = parser.add_subparsers(dest="command") + # sp.default = "run" + + flet.cli.commands.run.Command.register_to(sp, "run") + flet.cli.commands.build.Command.register_to(sp, "build") + parser.set_default_subparser("run", positional_args=1) + + # print usage if called without args + if len(sys.argv) == 1: + parser.print_help(sys.stdout) + sys.exit(1) + + # parse args + args = parser.parse_args() + + # execute command + args.handler(args) + + +if __name__ == "__main__": + main() diff --git a/sdk/python/flet/cli/commands/base.py b/sdk/python/flet/cli/commands/base.py new file mode 100644 index 0000000000..22933e7db0 --- /dev/null +++ b/sdk/python/flet/cli/commands/base.py @@ -0,0 +1,57 @@ +import argparse +from typing import Any, List, Optional + +from flet.cli.commands.options import Option, verbose_option + + +class BaseCommand: + """A CLI subcommand""" + + # The subcommand's name + name: Optional[str] = None + # The subcommand's help string, if not given, __doc__ will be used. + description: Optional[str] = None + # A list of pre-defined options which will be loaded on initializing + # Rewrite this if you don't want the default ones + arguments: List[Option] = [verbose_option] + + def __init__(self, parser: argparse.ArgumentParser) -> None: + for arg in self.arguments: + arg.add_to_parser(parser) + self.add_arguments(parser) + + @classmethod + def register_to( + cls, + subparsers: argparse._SubParsersAction, + name: Optional[str] = None, + **kwargs: Any + ) -> None: + """Register a subcommand to the subparsers, + with an optional name of the subcommand. + """ + help_text = cls.description or cls.__doc__ + name = name or cls.name or "" + # Remove the existing subparser as it will raises an error on Python 3.11+ + subparsers._name_parser_map.pop(name, None) + subactions = subparsers._get_subactions() + subactions[:] = [action for action in subactions if action.dest != name] + parser = subparsers.add_parser( + name, + description=help_text, + help=help_text, + # formatter_class=PdmFormatter, + **kwargs, + ) + command = cls(parser) + parser.set_defaults(handler=command.handle) + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + """Manipulate the argument parser to add more arguments""" + pass + + def handle(self, options: argparse.Namespace) -> None: + """The command handler function. + :param options: the parsed Namespace object + """ + raise NotImplementedError diff --git a/sdk/python/flet/cli/commands/build.py b/sdk/python/flet/cli/commands/build.py new file mode 100644 index 0000000000..7d896d353a --- /dev/null +++ b/sdk/python/flet/cli/commands/build.py @@ -0,0 +1,23 @@ +import argparse +from flet.cli.commands.base import BaseCommand + + +class Command(BaseCommand): + """ + Package Flet app to a standalone bundle + """ + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument("script", type=str, help="path to a Python script") + parser.add_argument( + "-F", + "--onefile", + dest="onefile", + action="store_true", + default=False, + help="create a one-file bundled executable", + ) + + def handle(self, options: argparse.Namespace) -> None: + # print("BUILD COMMAND", options) + raise NotImplementedError("Build command is not implemented yet.") diff --git a/sdk/python/flet/cli/commands/options.py b/sdk/python/flet/cli/commands/options.py new file mode 100644 index 0000000000..a4dd559038 --- /dev/null +++ b/sdk/python/flet/cli/commands/options.py @@ -0,0 +1,27 @@ +import argparse +from typing import Any + + +class Option: + """A reusable option object which delegates all arguments + to parser.add_argument(). + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.args = args + self.kwargs = kwargs + + def add_to_parser(self, parser: argparse._ActionsContainer) -> None: + parser.add_argument(*self.args, **self.kwargs) + + def add_to_group(self, group: argparse._ArgumentGroup) -> None: + group.add_argument(*self.args, **self.kwargs) + + +verbose_option = Option( + "-v", + "--verbose", + action="count", + default=0, + help="-v for detailed output and -vv for more detailed", +) diff --git a/sdk/python/flet/cli/commands/run.py b/sdk/python/flet/cli/commands/run.py new file mode 100644 index 0000000000..7a81c43a34 --- /dev/null +++ b/sdk/python/flet/cli/commands/run.py @@ -0,0 +1,178 @@ +import argparse +import logging +import os +from pathlib import Path +import signal +import subprocess +import sys +import threading +import time +from flet.cli.commands.base import BaseCommand +from flet.flet import open_flet_view +from flet.utils import get_free_tcp_port, is_windows, open_in_browser +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + + +class Command(BaseCommand): + """ + Run Flet app + """ + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument("script", type=str, help="path to a Python script") + parser.add_argument( + "-p", + "--port", + dest="port", + type=int, + default=None, + help="custom TCP port to run Flet app on", + ) + parser.add_argument( + "-d", + "--directory", + dest="directory", + action="store_true", + default=False, + help="watch script directory", + ) + parser.add_argument( + "-r", + "--recursive", + dest="recursive", + action="store_true", + default=False, + help="watch script directory and all sub-directories recursively", + ) + parser.add_argument( + "-n", + "--hidden", + dest="hidden", + action="store_true", + default=False, + help="application window is hidden on startup", + ) + parser.add_argument( + "-w", + "--web", + dest="web", + action="store_true", + default=False, + help="open app in a web browser", + ) + + def handle(self, options: argparse.Namespace) -> None: + # print("RUN COMMAND", options) + script_path = options.script + if not os.path.isabs(options.script): + script_path = str(Path(os.getcwd()).joinpath(options.script).resolve()) + + if not Path(script_path).exists(): + print(f"File not found: {script_path}") + exit(1) + + script_dir = os.path.dirname(script_path) + + port = options.port + if options.port is None: + port = get_free_tcp_port() + + my_event_handler = Handler( + [sys.executable, "-u", script_path], + None if options.directory or options.recursive else script_path, + port, + options.web, + options.hidden, + ) + + my_observer = Observer() + my_observer.schedule(my_event_handler, script_dir, recursive=options.recursive) + my_observer.start() + + try: + while True: + if my_event_handler.terminate.wait(1): + break + except KeyboardInterrupt: + pass + + if my_event_handler.fvp is not None and not is_windows(): + try: + logging.debug(f"Flet View process {my_event_handler.fvp.pid}") + os.kill(my_event_handler.fvp.pid + 1, signal.SIGKILL) + except: + pass + my_observer.stop() + my_observer.join() + + +class Handler(FileSystemEventHandler): + def __init__(self, args, script_path, port, web, hidden) -> None: + super().__init__() + self.args = args + self.script_path = script_path + self.port = port + self.web = web + self.hidden = hidden + self.last_time = time.time() + self.is_running = False + self.fvp = None + self.page_url_prefix = f"PAGE_URL_{time.time()}" + self.page_url = None + self.terminate = threading.Event() + self.start_process() + + def start_process(self): + p_env = {**os.environ} + if self.port is not None: + p_env["FLET_SERVER_PORT"] = str(self.port) + p_env["FLET_DISPLAY_URL_PREFIX"] = self.page_url_prefix + + self.p = subprocess.Popen(self.args, env=p_env, stdout=subprocess.PIPE) + self.is_running = True + th = threading.Thread(target=self.print_output, args=[self.p], daemon=True) + th.start() + + def on_any_event(self, event): + if ( + self.script_path is None or event.src_path == self.script_path + ) and not event.is_directory: + current_time = time.time() + if (current_time - self.last_time) > 0.5 and self.is_running: + self.last_time = current_time + th = threading.Thread(target=self.restart_program, args=(), daemon=True) + th.start() + + def print_output(self, p): + while True: + line = p.stdout.readline() + if not line: + break + line = line.decode("utf-8").rstrip("\r\n") + if line.startswith(self.page_url_prefix): + if not self.page_url: + self.page_url = line[len(self.page_url_prefix) + 1 :] + print(self.page_url) + if self.web: + open_in_browser(self.page_url) + else: + th = threading.Thread( + target=self.open_flet_view_and_wait, args=(), daemon=True + ) + th.start() + else: + print(line) + + def open_flet_view_and_wait(self): + self.fvp = open_flet_view(self.page_url, self.hidden) + self.fvp.wait() + self.p.kill() + self.terminate.set() + + def restart_program(self): + self.is_running = False + self.p.kill() + self.p.wait() + time.sleep(0.5) + self.start_process() diff --git a/sdk/python/flet/flet.py b/sdk/python/flet/flet.py index daeeba9059..65f93e64f8 100644 --- a/sdk/python/flet/flet.py +++ b/sdk/python/flet/flet.py @@ -1,28 +1,36 @@ -import argparse import json import logging +import os import signal -import socket import subprocess +import sys import tarfile import tempfile import threading -import time import traceback import urllib.request import zipfile from pathlib import Path from time import sleep -from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer - from flet import constants, version from flet.connection import Connection from flet.event import Event from flet.page import Page from flet.reconnecting_websocket import ReconnectingWebSocket -from flet.utils import * +from flet.utils import ( + get_arch, + get_current_script_dir, + get_free_tcp_port, + get_platform, + is_linux, + is_linux_server, + is_macos, + is_windows, + open_in_browser, + safe_tar_extractall, + which, +) try: from typing import Literal @@ -128,7 +136,7 @@ def exit_gracefully(signum, frame): and not is_linux_server() and url_prefix is None ): - fvp = _open_flet_view(conn.page_url, view == FLET_APP_HIDDEN) + fvp = open_flet_view(conn.page_url, view == FLET_APP_HIDDEN) try: fvp.wait() except (Exception) as e: @@ -264,7 +272,7 @@ def _start_flet_server( host, port, attached, assets_dir, upload_dir, web_renderer, route_url_strategy ): if port == 0: - port = _get_free_tcp_port() + port = get_free_tcp_port() logging.info(f"Starting local Flet Server on port {port}...") logging.info(f"Attached process: {attached}") @@ -363,7 +371,7 @@ def _start_flet_server( return port -def _open_flet_view(page_url, hidden): +def open_flet_view(page_url, hidden): logging.info(f"Starting Flet View app...") args = [] @@ -516,172 +524,6 @@ def _get_latest_flet_release(): return None -def _get_free_tcp_port(): - sock = socket.socket() - sock.bind(("", 0)) - return sock.getsockname()[1] - - # Fix: https://bugs.python.org/issue35935 # if _is_windows(): # signal.signal(signal.SIGINT, signal.SIG_DFL) - - -class Handler(FileSystemEventHandler): - def __init__(self, args, script_path, port, web, hidden) -> None: - super().__init__() - self.args = args - self.script_path = script_path - self.port = port - self.web = web - self.hidden = hidden - self.last_time = time.time() - self.is_running = False - self.fvp = None - self.page_url_prefix = f"PAGE_URL_{time.time()}" - self.page_url = None - self.terminate = threading.Event() - self.start_process() - - def start_process(self): - p_env = {**os.environ} - if self.port is not None: - p_env["FLET_SERVER_PORT"] = str(self.port) - p_env["FLET_DISPLAY_URL_PREFIX"] = self.page_url_prefix - - self.p = subprocess.Popen(self.args, env=p_env, stdout=subprocess.PIPE) - self.is_running = True - th = threading.Thread(target=self.print_output, args=[self.p], daemon=True) - th.start() - - def on_any_event(self, event): - if ( - self.script_path is None or event.src_path == self.script_path - ) and not event.is_directory: - current_time = time.time() - if (current_time - self.last_time) > 0.5 and self.is_running: - self.last_time = current_time - th = threading.Thread(target=self.restart_program, args=(), daemon=True) - th.start() - - def print_output(self, p): - while True: - line = p.stdout.readline() - if not line: - break - line = line.decode("utf-8").rstrip("\r\n") - if line.startswith(self.page_url_prefix): - if not self.page_url: - self.page_url = line[len(self.page_url_prefix) + 1 :] - print(self.page_url) - if self.web: - open_in_browser(self.page_url) - else: - th = threading.Thread( - target=self.open_flet_view_and_wait, args=(), daemon=True - ) - th.start() - else: - print(line) - - def open_flet_view_and_wait(self): - self.fvp = _open_flet_view(self.page_url, self.hidden) - self.fvp.wait() - self.p.kill() - self.terminate.set() - - def restart_program(self): - self.is_running = False - self.p.kill() - self.p.wait() - sleep(0.5) - self.start_process() - - -def main(): - parser = argparse.ArgumentParser( - description="Runs Flet app in Python with hot reload." - ) - parser.add_argument("script", type=str, help="path to a Python script") - parser.add_argument( - "--port", - "-p", - dest="port", - type=int, - default=None, - help="custom TCP port to run Flet app on", - ) - parser.add_argument( - "--directory", - "-d", - dest="directory", - action="store_true", - default=False, - help="watch script directory", - ) - parser.add_argument( - "--recursive", - "-r", - dest="recursive", - action="store_true", - default=False, - help="watch script directory and all sub-directories recursively", - ) - parser.add_argument( - "--hidden", - "-n", - dest="hidden", - action="store_true", - default=False, - help="application window is hidden on startup", - ) - parser.add_argument( - "--web", - "-w", - dest="web", - action="store_true", - default=False, - help="open app in a web browser", - ) - - # logging.basicConfig(level=logging.DEBUG) - - args = parser.parse_args() - - script_path = args.script - if not os.path.isabs(args.script): - script_path = str(Path(os.getcwd()).joinpath(args.script).resolve()) - - script_dir = os.path.dirname(script_path) - - port = args.port - if args.port is None: - port = _get_free_tcp_port() - - my_event_handler = Handler( - [sys.executable, "-u", script_path], - None if args.directory or args.recursive else script_path, - port, - args.web, - args.hidden, - ) - - my_observer = Observer() - my_observer.schedule(my_event_handler, script_dir, recursive=args.recursive) - my_observer.start() - - try: - while True: - if my_event_handler.terminate.wait(1): - break - except KeyboardInterrupt: - pass - - if my_event_handler.fvp is not None and not is_windows(): - try: - logging.debug(f"Flet View process {my_event_handler.fvp.pid}") - os.kill(my_event_handler.fvp.pid + 1, signal.SIGKILL) - except: - pass - my_observer.stop() - my_observer.join() diff --git a/sdk/python/flet/utils.py b/sdk/python/flet/utils.py index 8bda42f92a..0138f6de99 100644 --- a/sdk/python/flet/utils.py +++ b/sdk/python/flet/utils.py @@ -1,6 +1,7 @@ import math import os import platform +import socket import sys import unicodedata import webbrowser @@ -105,6 +106,12 @@ def is_localhost_url(url): ) +def get_free_tcp_port(): + sock = socket.socket() + sock.bind(("", 0)) + return sock.getsockname()[1] + + def get_current_script_dir(): pathname = os.path.dirname(sys.argv[0]) return os.path.abspath(pathname) diff --git a/sdk/python/flet/version.py b/sdk/python/flet/version.py index ca35f635d5..7078457433 100644 --- a/sdk/python/flet/version.py +++ b/sdk/python/flet/version.py @@ -6,7 +6,7 @@ from pathlib import Path import flet -from flet.utils import which +from flet.utils import which, is_windows # this value will be replaced by CI @@ -17,7 +17,7 @@ def update_version(): """Return the current version or default.""" working = Path().absolute() os.chdir(Path(flet.__file__).absolute().parent) - in_repo = which("git") and sp.run( + in_repo = which("git.exe" if is_windows() else "git") and sp.run( ["git", "status"], capture_output=True, text=True, @@ -47,7 +47,7 @@ class RepositoryError(OSError): version = git_p.stdout.strip()[1:] else: - version = "0.1.60" + version = "0.2.0" os.chdir(working) return version diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index fde2a04e8b..c6f8adef6a 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -38,7 +38,7 @@ dev = [ "pre-commit>=2.17.0"] [project.scripts] -flet = "flet.flet:main" +flet = "flet.cli.cli:main" [project.entry-points.pyinstaller40] hook-dirs = "flet.__pyinstaller:get_hook_dirs"