Skip to content

Commit 514bbee

Browse files
Flet CLI moved into separate module (#679)
* A new CLI prototype with two commands * Fix version detection on Windows Close #628 * CLI parser with run and build command * Move get_free_tcp_port to utils * Update cli.py * CLI run command * Update build.py * Fix typo and comments
1 parent d669577 commit 514bbee

File tree

9 files changed

+393
-180
lines changed

9 files changed

+393
-180
lines changed

sdk/python/flet/cli/cli.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import argparse
2+
import sys
3+
import flet.version
4+
import flet.cli.commands.run
5+
import flet.cli.commands.build
6+
7+
# Source https://stackoverflow.com/a/26379693
8+
def set_default_subparser(self, name, args=None, positional_args=0):
9+
"""default subparser selection. Call after setup, just before parse_args()
10+
name: is the name of the subparser to call by default
11+
args: if set is the argument list handed to parse_args()
12+
13+
, tested with 2.7, 3.2, 3.3, 3.4
14+
it works with 2.6 assuming argparse is installed
15+
"""
16+
subparser_found = False
17+
existing_default = False # check if default parser previously defined
18+
for arg in sys.argv[1:]:
19+
if arg in ["-h", "--help", "--version"]: # global help if no subparser
20+
break
21+
else:
22+
for x in self._subparsers._actions:
23+
if not isinstance(x, argparse._SubParsersAction):
24+
continue
25+
for sp_name in x._name_parser_map.keys():
26+
if sp_name in sys.argv[1:]:
27+
subparser_found = True
28+
if sp_name == name: # check existance of default parser
29+
existing_default = True
30+
if not subparser_found:
31+
# If the default subparser is not among the existing ones,
32+
# create a new parser.
33+
# As this is called just before 'parse_args', the default
34+
# parser created here will not pollute the help output.
35+
36+
if not existing_default:
37+
for x in self._subparsers._actions:
38+
if not isinstance(x, argparse._SubParsersAction):
39+
continue
40+
x.add_parser(name)
41+
break # this works OK, but should I check further?
42+
43+
# insert default in last position before global positional
44+
# arguments, this implies no global options are specified after
45+
# first positional argument
46+
if args is None and len(sys.argv) > 1:
47+
sys.argv.insert(positional_args, name)
48+
elif args is not None:
49+
args.insert(positional_args, name)
50+
# print(sys.argv)
51+
52+
53+
argparse.ArgumentParser.set_default_subparser = set_default_subparser
54+
55+
56+
def main():
57+
parser = argparse.ArgumentParser()
58+
parser.add_argument("--version", action="version", version=flet.version.version)
59+
sp = parser.add_subparsers(dest="command")
60+
# sp.default = "run"
61+
62+
flet.cli.commands.run.Command.register_to(sp, "run")
63+
flet.cli.commands.build.Command.register_to(sp, "build")
64+
parser.set_default_subparser("run", positional_args=1)
65+
66+
# print usage if called without args
67+
if len(sys.argv) == 1:
68+
parser.print_help(sys.stdout)
69+
sys.exit(1)
70+
71+
# parse args
72+
args = parser.parse_args()
73+
74+
# execute command
75+
args.handler(args)
76+
77+
78+
if __name__ == "__main__":
79+
main()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import argparse
2+
from typing import Any, List, Optional
3+
4+
from flet.cli.commands.options import Option, verbose_option
5+
6+
7+
class BaseCommand:
8+
"""A CLI subcommand"""
9+
10+
# The subcommand's name
11+
name: Optional[str] = None
12+
# The subcommand's help string, if not given, __doc__ will be used.
13+
description: Optional[str] = None
14+
# A list of pre-defined options which will be loaded on initializing
15+
# Rewrite this if you don't want the default ones
16+
arguments: List[Option] = [verbose_option]
17+
18+
def __init__(self, parser: argparse.ArgumentParser) -> None:
19+
for arg in self.arguments:
20+
arg.add_to_parser(parser)
21+
self.add_arguments(parser)
22+
23+
@classmethod
24+
def register_to(
25+
cls,
26+
subparsers: argparse._SubParsersAction,
27+
name: Optional[str] = None,
28+
**kwargs: Any
29+
) -> None:
30+
"""Register a subcommand to the subparsers,
31+
with an optional name of the subcommand.
32+
"""
33+
help_text = cls.description or cls.__doc__
34+
name = name or cls.name or ""
35+
# Remove the existing subparser as it will raises an error on Python 3.11+
36+
subparsers._name_parser_map.pop(name, None)
37+
subactions = subparsers._get_subactions()
38+
subactions[:] = [action for action in subactions if action.dest != name]
39+
parser = subparsers.add_parser(
40+
name,
41+
description=help_text,
42+
help=help_text,
43+
# formatter_class=PdmFormatter,
44+
**kwargs,
45+
)
46+
command = cls(parser)
47+
parser.set_defaults(handler=command.handle)
48+
49+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
50+
"""Manipulate the argument parser to add more arguments"""
51+
pass
52+
53+
def handle(self, options: argparse.Namespace) -> None:
54+
"""The command handler function.
55+
:param options: the parsed Namespace object
56+
"""
57+
raise NotImplementedError
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import argparse
2+
from flet.cli.commands.base import BaseCommand
3+
4+
5+
class Command(BaseCommand):
6+
"""
7+
Package Flet app to a standalone bundle
8+
"""
9+
10+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
11+
parser.add_argument("script", type=str, help="path to a Python script")
12+
parser.add_argument(
13+
"-F",
14+
"--onefile",
15+
dest="onefile",
16+
action="store_true",
17+
default=False,
18+
help="create a one-file bundled executable",
19+
)
20+
21+
def handle(self, options: argparse.Namespace) -> None:
22+
# print("BUILD COMMAND", options)
23+
raise NotImplementedError("Build command is not implemented yet.")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import argparse
2+
from typing import Any
3+
4+
5+
class Option:
6+
"""A reusable option object which delegates all arguments
7+
to parser.add_argument().
8+
"""
9+
10+
def __init__(self, *args: Any, **kwargs: Any) -> None:
11+
self.args = args
12+
self.kwargs = kwargs
13+
14+
def add_to_parser(self, parser: argparse._ActionsContainer) -> None:
15+
parser.add_argument(*self.args, **self.kwargs)
16+
17+
def add_to_group(self, group: argparse._ArgumentGroup) -> None:
18+
group.add_argument(*self.args, **self.kwargs)
19+
20+
21+
verbose_option = Option(
22+
"-v",
23+
"--verbose",
24+
action="count",
25+
default=0,
26+
help="-v for detailed output and -vv for more detailed",
27+
)
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import argparse
2+
import logging
3+
import os
4+
from pathlib import Path
5+
import signal
6+
import subprocess
7+
import sys
8+
import threading
9+
import time
10+
from flet.cli.commands.base import BaseCommand
11+
from flet.flet import open_flet_view
12+
from flet.utils import get_free_tcp_port, is_windows, open_in_browser
13+
from watchdog.events import FileSystemEventHandler
14+
from watchdog.observers import Observer
15+
16+
17+
class Command(BaseCommand):
18+
"""
19+
Run Flet app
20+
"""
21+
22+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
23+
parser.add_argument("script", type=str, help="path to a Python script")
24+
parser.add_argument(
25+
"-p",
26+
"--port",
27+
dest="port",
28+
type=int,
29+
default=None,
30+
help="custom TCP port to run Flet app on",
31+
)
32+
parser.add_argument(
33+
"-d",
34+
"--directory",
35+
dest="directory",
36+
action="store_true",
37+
default=False,
38+
help="watch script directory",
39+
)
40+
parser.add_argument(
41+
"-r",
42+
"--recursive",
43+
dest="recursive",
44+
action="store_true",
45+
default=False,
46+
help="watch script directory and all sub-directories recursively",
47+
)
48+
parser.add_argument(
49+
"-n",
50+
"--hidden",
51+
dest="hidden",
52+
action="store_true",
53+
default=False,
54+
help="application window is hidden on startup",
55+
)
56+
parser.add_argument(
57+
"-w",
58+
"--web",
59+
dest="web",
60+
action="store_true",
61+
default=False,
62+
help="open app in a web browser",
63+
)
64+
65+
def handle(self, options: argparse.Namespace) -> None:
66+
# print("RUN COMMAND", options)
67+
script_path = options.script
68+
if not os.path.isabs(options.script):
69+
script_path = str(Path(os.getcwd()).joinpath(options.script).resolve())
70+
71+
if not Path(script_path).exists():
72+
print(f"File not found: {script_path}")
73+
exit(1)
74+
75+
script_dir = os.path.dirname(script_path)
76+
77+
port = options.port
78+
if options.port is None:
79+
port = get_free_tcp_port()
80+
81+
my_event_handler = Handler(
82+
[sys.executable, "-u", script_path],
83+
None if options.directory or options.recursive else script_path,
84+
port,
85+
options.web,
86+
options.hidden,
87+
)
88+
89+
my_observer = Observer()
90+
my_observer.schedule(my_event_handler, script_dir, recursive=options.recursive)
91+
my_observer.start()
92+
93+
try:
94+
while True:
95+
if my_event_handler.terminate.wait(1):
96+
break
97+
except KeyboardInterrupt:
98+
pass
99+
100+
if my_event_handler.fvp is not None and not is_windows():
101+
try:
102+
logging.debug(f"Flet View process {my_event_handler.fvp.pid}")
103+
os.kill(my_event_handler.fvp.pid + 1, signal.SIGKILL)
104+
except:
105+
pass
106+
my_observer.stop()
107+
my_observer.join()
108+
109+
110+
class Handler(FileSystemEventHandler):
111+
def __init__(self, args, script_path, port, web, hidden) -> None:
112+
super().__init__()
113+
self.args = args
114+
self.script_path = script_path
115+
self.port = port
116+
self.web = web
117+
self.hidden = hidden
118+
self.last_time = time.time()
119+
self.is_running = False
120+
self.fvp = None
121+
self.page_url_prefix = f"PAGE_URL_{time.time()}"
122+
self.page_url = None
123+
self.terminate = threading.Event()
124+
self.start_process()
125+
126+
def start_process(self):
127+
p_env = {**os.environ}
128+
if self.port is not None:
129+
p_env["FLET_SERVER_PORT"] = str(self.port)
130+
p_env["FLET_DISPLAY_URL_PREFIX"] = self.page_url_prefix
131+
132+
self.p = subprocess.Popen(self.args, env=p_env, stdout=subprocess.PIPE)
133+
self.is_running = True
134+
th = threading.Thread(target=self.print_output, args=[self.p], daemon=True)
135+
th.start()
136+
137+
def on_any_event(self, event):
138+
if (
139+
self.script_path is None or event.src_path == self.script_path
140+
) and not event.is_directory:
141+
current_time = time.time()
142+
if (current_time - self.last_time) > 0.5 and self.is_running:
143+
self.last_time = current_time
144+
th = threading.Thread(target=self.restart_program, args=(), daemon=True)
145+
th.start()
146+
147+
def print_output(self, p):
148+
while True:
149+
line = p.stdout.readline()
150+
if not line:
151+
break
152+
line = line.decode("utf-8").rstrip("\r\n")
153+
if line.startswith(self.page_url_prefix):
154+
if not self.page_url:
155+
self.page_url = line[len(self.page_url_prefix) + 1 :]
156+
print(self.page_url)
157+
if self.web:
158+
open_in_browser(self.page_url)
159+
else:
160+
th = threading.Thread(
161+
target=self.open_flet_view_and_wait, args=(), daemon=True
162+
)
163+
th.start()
164+
else:
165+
print(line)
166+
167+
def open_flet_view_and_wait(self):
168+
self.fvp = open_flet_view(self.page_url, self.hidden)
169+
self.fvp.wait()
170+
self.p.kill()
171+
self.terminate.set()
172+
173+
def restart_program(self):
174+
self.is_running = False
175+
self.p.kill()
176+
self.p.wait()
177+
time.sleep(0.5)
178+
self.start_process()

0 commit comments

Comments
 (0)