From 523daafbfa27cb1af6d0c88382f5cdf386f67e85 Mon Sep 17 00:00:00 2001 From: Ying Chen Date: Tue, 2 Apr 2024 14:12:59 +0800 Subject: [PATCH] Update executable template (#2567) # Description - update executable template - update doc/sample - trace call cli pfs to support detch mode in executable/msi # All Promptflow Contribution checklist: - [ ] **The pull request does not introduce [breaking changes].** - [ ] **CHANGELOG is updated for new features, bug fixes or other significant changes.** - [ ] **I have read the [contribution guidelines](../CONTRIBUTING.md).** - [ ] **Create an issue and link to the pull request to get dedicated review from promptflow team. Learn more: [suggested workflow](../CONTRIBUTING.md#suggested-workflow).** ## General Guidelines and Best Practices - [ ] Title of the pull request is clear and informative. - [ ] There are a small number of commits, each of which have an informative message. This means that previously merged commits do not appear in the history of the PR. For more information on cleaning up the commits in your PR, [see this page](https://github.com/Azure/azure-powershell/blob/master/documentation/development-docs/cleaning-up-commits.md). ### Testing Guidelines - [ ] Pull request includes test coverage for the included changes. --------- Signed-off-by: Brynn Yin Co-authored-by: Ying Chen <2601502859@qq.com> Co-authored-by: Philip Gao Co-authored-by: riddle xu Co-authored-by: Yangtong Xu Co-authored-by: Peiwen Gao <111329184+PeiwenGaoMS@users.noreply.github.com> Co-authored-by: Zhengfei Wang <38847871+zhengfeiwang@users.noreply.github.com> Co-authored-by: Brynn Yin <24237253+brynn-code@users.noreply.github.com> --- .cspell.json | 3 + .../distribute-flow-as-executable-app.md | 95 +------- .../README.md | 91 +------- .../distribute-flow-as-executable-app/app.py | 31 --- .../app.spec | 52 ----- .../distribute-flow-as-executable-app/main.py | 219 ------------------ .../promptflow/_sdk/_service/utils/utils.py | 4 +- .../promptflow/_sdk/_tracing.py | 30 ++- .../promptflow/_sdk/data/executable/app.py | 34 +-- .../_sdk/data/executable/app.spec.jinja2 | 59 +++-- .../promptflow/_sdk/data/executable/pf | 16 ++ .../promptflow/_sdk/data/executable/pf.bat | 16 ++ .../_sdk/data/executable/start_pfs.vbs | 4 + .../_sdk/operations/_flow_operations.py | 62 ++++- 14 files changed, 196 insertions(+), 520 deletions(-) delete mode 100644 examples/tutorials/flow-deploy/distribute-flow-as-executable-app/app.py delete mode 100644 examples/tutorials/flow-deploy/distribute-flow-as-executable-app/app.spec delete mode 100644 examples/tutorials/flow-deploy/distribute-flow-as-executable-app/main.py create mode 100644 src/promptflow-devkit/promptflow/_sdk/data/executable/pf create mode 100644 src/promptflow-devkit/promptflow/_sdk/data/executable/pf.bat create mode 100644 src/promptflow-devkit/promptflow/_sdk/data/executable/start_pfs.vbs diff --git a/.cspell.json b/.cspell.json index c2b2465f586..8f3489a48f2 100644 --- a/.cspell.json +++ b/.cspell.json @@ -190,6 +190,9 @@ "STARTF", "mltable", "setenv", + "cscript", + "nologo", + "wscript", "raisvc", "evals" ], diff --git a/docs/how-to-guides/deploy-a-flow/distribute-flow-as-executable-app.md b/docs/how-to-guides/deploy-a-flow/distribute-flow-as-executable-app.md index 2995b668de3..452e8edf275 100644 --- a/docs/how-to-guides/deploy-a-flow/distribute-flow-as-executable-app.md +++ b/docs/how-to-guides/deploy-a-flow/distribute-flow-as-executable-app.md @@ -43,101 +43,14 @@ Exported files & its dependencies are located in the same folder. The structure ### A template script of the entry file PyInstaller reads a spec file or Python script written by you. It analyzes your code to discover every other module and library your script needs in order to execute. Then it collects copies of all those files, including the active Python interpreter, and puts them with your script in a single folder, or optionally in a single executable file. -We provide a Python entry script named `app.py` as the entry point for the bundled app, which enables you to serve a flow folder as an endpoint. - -```python -import os -import sys - -from promptflow._cli._pf._connection import create_connection -from streamlit.web import cli as st_cli -from streamlit.runtime import exists - -from main import start - -def is_yaml_file(file_path): - _, file_extension = os.path.splitext(file_path) - return file_extension.lower() in ('.yaml', '.yml') - -def create_connections(directory_path) -> None: - for root, dirs, files in os.walk(directory_path): - for file in files: - file_path = os.path.join(root, file) - if is_yaml_file(file_path): - create_connection(file_path) - - -if __name__ == "__main__": - create_connections(os.path.join(os.path.dirname(__file__), "connections")) - if exists(): - start() - else: - main_script = os.path.join(os.path.dirname(__file__), "main.py") - sys.argv = ["streamlit", "run", main_script, "--global.developmentMode=false"] - st_cli.main(prog_name="streamlit") - -``` +We provide a Python entry script named [app.py](https://github.com/microsoft/promptflow/blob/main/src/promptflow-devkit/promptflow/_sdk/data/executable/app.py) as the entry point for the bundled app, which enables you to serve a flow folder as an endpoint. ### A template script of the spec file The spec file tells PyInstaller how to process your script. It encodes the script names and most of the options you give to the pyinstaller command. The spec file is actually executable Python code. PyInstaller builds the app by executing the contents of the spec file. -To streamline this process, we offer a `app.spec` spec file that bundles the application into a single file. For additional information on spec files, you can refer to the [Using Spec Files](https://pyinstaller.org/en/stable/spec-files.html). Please replace `streamlit_runtime_interpreter_path` with the path of streamlit runtime interpreter in your environment. - -```spec -# -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_data_files -from PyInstaller.utils.hooks import copy_metadata - -datas = [('connections', 'connections'), ('flow', 'flow'), ('settings.json', '.'), ('main.py', '.'), ('{{streamlit_runtime_interpreter_path}}', './streamlit/runtime')] -datas += collect_data_files('streamlit') -datas += copy_metadata('streamlit') -datas += collect_data_files('keyrings.alt', include_py_files=True) -datas += copy_metadata('keyrings.alt') -datas += collect_data_files('streamlit_quill') - -block_cipher = None - - -a = Analysis( - ['app.py', 'main.py'], - pathex=[], - binaries=[], - datas=datas, - hiddenimports=['bs4'], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='app', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) -``` +To streamline this process, we offer a [app.spec.jinja2](https://github.com/microsoft/promptflow/blob/main/src/promptflow-devkit/promptflow/_sdk/data/executable/app.spec.jinja2) spec template file that bundles the application into a single file. For additional information on spec files, you can refer to the [Using Spec Files](https://pyinstaller.org/en/stable/spec-files.html). Please replace `streamlit_runtime_interpreter_path` with the path of streamlit runtime interpreter in your environment. + ### The bundled application using Pyinstaller Once you've build a flow as executable format following [Build a flow as executable format](#build-a-flow-as-executable-format). @@ -168,4 +81,4 @@ To your users, the app is self-contained. They do not need to install any partic 1. Note that Python 3.10.0 contains a bug making it unsupportable by PyInstaller. PyInstaller will also not work with beta releases of Python 3.13. ## Next steps -- Try the example [here](https://github.com/microsoft/promptflow/blob/main/examples/tutorials/flow-deploy) \ No newline at end of file +- Try the example [here](https://github.com/microsoft/promptflow/tree/main/examples/tutorials/flow-deploy/distribute-flow-as-executable-app) \ No newline at end of file diff --git a/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/README.md b/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/README.md index 51e4805e4c4..e3b53ac30f8 100644 --- a/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/README.md +++ b/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/README.md @@ -39,101 +39,16 @@ Exported files & its dependencies are located in the same folder. The structure ### A template script of the entry file PyInstaller reads a spec file or Python script written by you. It analyzes your code to discover every other module and library your script needs in order to execute. Then it collects copies of all those files, including the active Python interpreter, and puts them with your script in a single folder, or optionally in a single executable file. -We provide a Python entry script named `app.py` as the entry point for the bundled app, which enables you to serve a flow folder as an endpoint. - -```python -import os -import sys - -from promptflow._cli._pf._connection import create_connection -from streamlit.web import cli as st_cli -from streamlit.runtime import exists - -from main import start - -def is_yaml_file(file_path): - _, file_extension = os.path.splitext(file_path) - return file_extension.lower() in ('.yaml', '.yml') - -def create_connections(directory_path) -> None: - for root, dirs, files in os.walk(directory_path): - for file in files: - file_path = os.path.join(root, file) - if is_yaml_file(file_path): - create_connection(file_path) - - -if __name__ == "__main__": - create_connections(os.path.join(os.path.dirname(__file__), "connections")) - if exists(): - start() - else: - main_script = os.path.join(os.path.dirname(__file__), "main.py") - sys.argv = ["streamlit", "run", main_script, "--global.developmentMode=false"] - st_cli.main(prog_name="streamlit") -``` -::: +We provide a Python entry script named [app.py](https://github.com/microsoft/promptflow/blob/main/src/promptflow-devkit/promptflow/_sdk/data/executable/app.py) as the entry point for the bundled app, which enables you to serve a flow folder as an endpoint. + ### A template script of the spec file The spec file tells PyInstaller how to process your script. It encodes the script names and most of the options you give to the pyinstaller command. The spec file is actually executable Python code. PyInstaller builds the app by executing the contents of the spec file. -To streamline this process, we offer a `app.spec` spec file that bundles the application into a single file. For additional information on spec files, you can refer to the [Using Spec Files](https://pyinstaller.org/en/stable/spec-files.html). +To streamline this process, we offer a [app.spec.jinja2](https://github.com/microsoft/promptflow/blob/main/src/promptflow-devkit/promptflow/_sdk/data/executable/app.spec.jinja2) spec template file that bundles the application into a single file. For additional information on spec files, you can refer to the [Using Spec Files](https://pyinstaller.org/en/stable/spec-files.html). Please replace {{streamlit_runtime_interpreter_path}} with the path of streamlit runtime interpreter in your environment. -```spec -# -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_data_files -from PyInstaller.utils.hooks import copy_metadata - -datas = [('connections', 'connections'), ('flow', 'flow'), ('settings.json', '.'), ('main.py', '.'), ('{{streamlit_runtime_interpreter_path}}', './streamlit/runtime')] -datas += collect_data_files('streamlit') -datas += copy_metadata('streamlit') -datas += collect_data_files('keyrings.alt', include_py_files=True) -datas += copy_metadata('keyrings.alt') - -block_cipher = None - - -a = Analysis( - ['app.py', 'main.py'], - pathex=[], - binaries=[], - datas=datas, - hiddenimports=['bs4'], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='app', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) -``` ### The bundled application using Pyinstaller Once you've build a flow as executable format following [Build a flow as executable format](#build-a-flow-as-executable-format). diff --git a/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/app.py b/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/app.py deleted file mode 100644 index b6a809a2091..00000000000 --- a/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/app.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import sys - -from promptflow._cli._pf._connection import create_connection -from streamlit.web import cli as st_cli -from streamlit.runtime import exists - -from main import start - - -def is_yaml_file(file_path): - _, file_extension = os.path.splitext(file_path) - return file_extension.lower() in ('.yaml', '.yml') - - -def create_connections(directory_path) -> None: - for root, dirs, files in os.walk(directory_path): - for file in files: - file_path = os.path.join(root, file) - if is_yaml_file(file_path): - create_connection(file_path) - - -if __name__ == "__main__": - create_connections(os.path.join(os.path.dirname(__file__), "connections")) - if exists(): - start() - else: - main_script = os.path.join(os.path.dirname(__file__), "main.py") - sys.argv = ["streamlit", "run", main_script, "--global.developmentMode=false"] - st_cli.main(prog_name="streamlit") diff --git a/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/app.spec b/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/app.spec deleted file mode 100644 index f37ca337611..00000000000 --- a/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/app.spec +++ /dev/null @@ -1,52 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_data_files -from PyInstaller.utils.hooks import copy_metadata - -datas = [('connections', 'connections'), ('flow', 'flow'), ('settings.json', '.'), ('main.py', '.'), ('{{streamlit_runtime_interpreter_path}}', './streamlit/runtime')] -datas += collect_data_files('streamlit') -datas += copy_metadata('streamlit') -datas += collect_data_files('keyrings.alt', include_py_files=True) -datas += copy_metadata('keyrings.alt') -datas += collect_data_files('streamlit_quill') - -block_cipher = None - - -a = Analysis( - ['app.py', 'main.py'], - pathex=[], - binaries=[], - datas=datas, - hiddenimports=['bs4'], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='app', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) \ No newline at end of file diff --git a/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/main.py b/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/main.py deleted file mode 100644 index 2f8ee178ff1..00000000000 --- a/examples/tutorials/flow-deploy/distribute-flow-as-executable-app/main.py +++ /dev/null @@ -1,219 +0,0 @@ -import base64 -import json -import os -import re -from pathlib import Path - -import streamlit as st -from bs4 import BeautifulSoup, NavigableString, Tag -from streamlit_quill import st_quill # noqa: F401 - -from promptflow._sdk._utils import print_yellow_warning -from promptflow._utils.multimedia_utils import MIME_PATTERN, BasicMultimediaProcessor -from promptflow.core._serving.flow_invoker import FlowInvoker - -invoker = None - - -def start(): - def clear_chat() -> None: - st.session_state.messages = [] - - def show_image(image, key=None): - if not image.startswith("data:image"): - st.image(key + "," + image) - else: - st.image(image) - - def json_dumps(value): - try: - return json.dumps(value) - except Exception: - return value - - def is_list_contains_rich_text(rich_text): - result = False - for item in rich_text: - if isinstance(item, list): - result |= is_list_contains_rich_text(item) - elif isinstance(item, dict): - result |= is_dict_contains_rich_text(item) - else: - if isinstance(item, str) and item.startswith("data:image"): - result = True - return result - - def is_dict_contains_rich_text(rich_text): - result = False - for rich_text_key, rich_text_value in rich_text.items(): - if isinstance(rich_text_value, list): - result |= is_list_contains_rich_text(rich_text_value) - elif isinstance(rich_text_value, dict): - result |= is_dict_contains_rich_text(rich_text_value) - elif re.match(MIME_PATTERN, rich_text_key) or ( - isinstance(rich_text_value, str) and rich_text_value.startswith("data:image") - ): - result = True - return result - - def render_message(role, message_items): - def item_render_message(value, key=None): - if key and re.match(MIME_PATTERN, key): - show_image(value, key) - elif isinstance(value, str) and value.startswith("data:image"): - show_image(value) - else: - if key is None: - st.markdown(f"`{json_dumps(value)},`") - else: - st.markdown(f"`{key}: {json_dumps(value)},`") - - def list_iter_render_message(message_items): - if is_list_contains_rich_text(message_items): - st.markdown("`[ `") - for item in message_items: - if isinstance(item, list): - list_iter_render_message(item) - if isinstance(item, dict): - dict_iter_render_message(item) - else: - item_render_message(item) - st.markdown("`], `") - else: - st.markdown(f"`{json_dumps(message_items)},`") - - def dict_iter_render_message(message_items): - if BasicMultimediaProcessor.is_multimedia_dict(message_items): - key = list(message_items.keys())[0] - value = message_items[key] - show_image(value, key) - elif is_dict_contains_rich_text(message_items): - st.markdown("`{ `") - for key, value in message_items.items(): - if re.match(MIME_PATTERN, key): - show_image(value, key) - else: - if isinstance(value, list): - st.markdown(f"`{key}: `") - list_iter_render_message(value) - elif isinstance(value, dict): - st.markdown(f"`{key}: `") - dict_iter_render_message(value) - else: - item_render_message(value, key) - st.markdown("`}, `") - else: - st.markdown(f"`{json_dumps(message_items)},`") - - with st.chat_message(role): - dict_iter_render_message(message_items) - - def show_conversation() -> None: - if "messages" not in st.session_state: - st.session_state.messages = [] - st.session_state.history = [] - if st.session_state.messages: - for role, message_items in st.session_state.messages: - render_message(role, message_items) - - def get_chat_history_from_session(): - if "history" in st.session_state: - return st.session_state.history - return [] - - def submit(**kwargs) -> None: - st.session_state.messages.append(("user", kwargs)) - session_state_history = dict() - session_state_history.update({"inputs": kwargs}) - with container: - render_message("user", kwargs) - # Force append chat history to kwargs - response = run_flow(kwargs) - st.session_state.messages.append(("assistant", response)) - session_state_history.update({"outputs": response}) - st.session_state.history.append(session_state_history) - with container: - render_message("assistant", response) - - def run_flow(data: dict) -> dict: - global invoker - if not invoker: - flow = Path(__file__).parent / "flow" - dump_path = flow.parent - if flow.is_dir(): - os.chdir(flow) - else: - os.chdir(flow.parent) - invoker = FlowInvoker(flow, connection_provider="local", dump_to=dump_path) - result, result_output = invoker.invoke(data) - print_yellow_warning(f"Result: {result_output}") - return result - - def extract_content(node): - if isinstance(node, NavigableString): - text = node.strip() - if text: - return [text] - elif isinstance(node, Tag): - if node.name == "img": - prefix, base64_str = node["src"].split(",", 1) - return [{prefix: base64_str}] - else: - result = [] - for child in node.contents: - result.extend(extract_content(child)) - return result - return [] - - def parse_html_content(html_content): - soup = BeautifulSoup(html_content, "html.parser") - result = [] - for p in soup.find_all("p"): - result.extend(extract_content(p)) - return result - - def parse_image_content(image_content, image_type): - if image_content is not None: - file_contents = image_content.read() - image_content = base64.b64encode(file_contents).decode("utf-8") - prefix = f"data:{image_type};base64" - return {prefix: image_content} - - st.title("web-classification APP") - st.chat_message("assistant").write("Hello, please input following flow inputs.") - container = st.container() - with container: - show_conversation() - - with st.form(key="input_form", clear_on_submit=True): - settings_path = os.path.join(os.path.dirname(__file__), "settings.json") - if os.path.exists(settings_path): - with open(settings_path, "r") as file: - json_data = json.load(file) - environment_variables = list(json_data.keys()) - for environment_variable in environment_variables: - secret_input = st.text_input( - label=environment_variable, - type="password", - placeholder=f"Please input {environment_variable} here. If you input before, you can leave it " - f"blank.", - ) - if secret_input != "": - os.environ[environment_variable] = secret_input - - url = st.text_input( - label="url", placeholder="https://play.google.com/store/apps/details?id=com.twitter.android" - ) - cols = st.columns(7) - submit_bt = cols[0].form_submit_button(label="Submit") - clear_bt = cols[1].form_submit_button(label="Clear") - - if submit_bt: - submit(url=url) - - if clear_bt: - clear_chat() - - -if __name__ == "__main__": - start() diff --git a/src/promptflow-devkit/promptflow/_sdk/_service/utils/utils.py b/src/promptflow-devkit/promptflow/_sdk/_service/utils/utils.py index 5da77717b51..0f185b85944 100644 --- a/src/promptflow-devkit/promptflow/_sdk/_service/utils/utils.py +++ b/src/promptflow-devkit/promptflow/_sdk/_service/utils/utils.py @@ -278,7 +278,9 @@ def is_run_from_built_binary(): Allow customer to use environment variable to control the triggering. """ - return sys.executable.endswith("pfcli.exe") or os.environ.get(PF_RUN_AS_BUILT_BINARY, "").lower() == "true" + return (not sys.executable.endswith("python.exe") and not sys.executable.endswith("python")) or os.environ.get( + PF_RUN_AS_BUILT_BINARY, "" + ).lower() == "true" def encrypt_flow_path(flow_path): diff --git a/src/promptflow-devkit/promptflow/_sdk/_tracing.py b/src/promptflow-devkit/promptflow/_sdk/_tracing.py index 0d7f43c3003..bab2c78a049 100644 --- a/src/promptflow-devkit/promptflow/_sdk/_tracing.py +++ b/src/promptflow-devkit/promptflow/_sdk/_tracing.py @@ -4,6 +4,9 @@ import json import os +import platform +import subprocess +import sys import typing import urllib.parse @@ -14,7 +17,6 @@ from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor -from promptflow._cli._pf.entry import entry from promptflow._constants import ( OTEL_RESOURCE_SERVICE_NAME, SpanAttributeFieldName, @@ -29,7 +31,12 @@ AzureMLWorkspaceTriad, ContextAttributeKey, ) -from promptflow._sdk._service.utils.utils import get_port_from_config, is_pfs_service_healthy, is_port_in_use +from promptflow._sdk._service.utils.utils import ( + get_port_from_config, + is_pfs_service_healthy, + is_port_in_use, + is_run_from_built_binary, +) from promptflow._sdk._utils import extract_workspace_triad_from_trace_provider from promptflow._utils.logger_utils import get_cli_sdk_logger from promptflow.tracing._integrations._openai_injector import inject_openai_api @@ -61,9 +68,20 @@ def _inject_attrs_to_op_ctx(attrs: typing.Dict[str, str]) -> None: def _invoke_pf_svc() -> str: port = get_port_from_config(create_if_not_exists=True) port = str(port) - cmd_args = ["service", "start", "--port", port] + if is_run_from_built_binary(): + interpreter_path = os.path.abspath(sys.executable) + pf_path = os.path.join(os.path.dirname(interpreter_path), "pf") + if platform.system() == "Windows": + cmd_args = [pf_path, "service", "start", "--port", port] + else: + cmd_args = f"{pf_path} service start --port {port}" + else: + if platform.system() == "Windows": + cmd_args = ["pf", "service", "start", "--port", port] + else: + cmd_args = f"pf service start --port {port}" hint_stop_message = ( - f"You can stop the Prompt flow Tracing Server with the following command:'\033[1m pf service stop\033[0m'.\n" + f"You can stop the Prompt flow Tracing Server with the following command:'\033[1mpf service stop\033[0m'.\n" f"Alternatively, if no requests are made within {PF_SERVICE_HOUR_TIMEOUT} " f"hours, it will automatically stop." ) @@ -75,7 +93,9 @@ def _invoke_pf_svc() -> str: print(hint_stop_message) return port print("Starting Prompt flow Tracing Server...") - entry(cmd_args) + start_pfs = subprocess.Popen(cmd_args, shell=True) + # Wait for service to be started + start_pfs.wait() logger.debug("Prompt flow service is serving on port %s", port) print(hint_stop_message) return port diff --git a/src/promptflow-devkit/promptflow/_sdk/data/executable/app.py b/src/promptflow-devkit/promptflow/_sdk/data/executable/app.py index a5419dcbc46..d801ccbb372 100644 --- a/src/promptflow-devkit/promptflow/_sdk/data/executable/app.py +++ b/src/promptflow-devkit/promptflow/_sdk/data/executable/app.py @@ -1,3 +1,4 @@ +import multiprocessing import os import sys @@ -25,18 +26,25 @@ def create_connections(directory_path) -> None: if __name__ == "__main__": + multiprocessing.freeze_support() + command = sys.argv[1] if len(sys.argv) > 1 else None + if command == "pf": + sys.argv = sys.argv[1:] + from promptflow._cli._pf.entry import main as pf_main - create_connections(os.path.join(os.path.dirname(__file__), "connections")) - if exists(): - start() + pf_main() else: - main_script = os.path.join(os.path.dirname(__file__), "main.py") - sys.argv = [ - "streamlit", - "run", - main_script, - "--global.developmentMode=false", - "--client.toolbarMode=viewer", - "--browser.gatherUsageStats=false", - ] - st_cli.main(prog_name="streamlit") + create_connections(os.path.join(os.path.dirname(__file__), "connections")) + if exists(): + start() + else: + main_script = os.path.join(os.path.dirname(__file__), "main.py") + sys.argv = [ + "streamlit", + "run", + main_script, + "--global.developmentMode=false", + "--client.toolbarMode=viewer", + "--browser.gatherUsageStats=false", + ] + st_cli.main(prog_name="streamlit") diff --git a/src/promptflow-devkit/promptflow/_sdk/data/executable/app.spec.jinja2 b/src/promptflow-devkit/promptflow/_sdk/data/executable/app.spec.jinja2 index c9a2e849c09..7dd5275f757 100644 --- a/src/promptflow-devkit/promptflow/_sdk/data/executable/app.spec.jinja2 +++ b/src/promptflow-devkit/promptflow/_sdk/data/executable/app.spec.jinja2 @@ -1,25 +1,47 @@ # -*- mode: python ; coding: utf-8 -*- import sys -from PyInstaller.utils.hooks import collect_data_files -from PyInstaller.utils.hooks import copy_metadata +from PyInstaller.utils.hooks import collect_data_files, collect_all, copy_metadata sys.setrecursionlimit(sys.getrecursionlimit() * 5) -datas = [('connections', 'connections'), ('flow', 'flow'), ('settings.json', '.'), ('main.py', '.'), ('utils.py', '.'), ('logo.png', '.'), ('config.json', '.'), ('{{runtime_interpreter_path}}', './streamlit/runtime')] -datas += collect_data_files('streamlit') -datas += copy_metadata('streamlit') +datas = [('connections', 'connections'), ('flow', 'flow'), ('settings.json', '.'), ('main.py', '.'), ('utils.py', '.'), +('logo.png', '.'), ('config.json', '.'), ('{{runtime_interpreter_path}}', './streamlit/runtime')] + + +all_packages = {{all_packages}} +meta_packages = ['opentelemetry-api'] + {{meta_packages}} + +for package in all_packages: + datas += collect_data_files(package) + +for package in meta_packages: + datas += copy_metadata(package) + +opentelemetry_datas, opentelemetry_binaries, opentelemetry_hiddenimports = collect_all('opentelemetry') +promptflow_datas, promptflow_binaries, promptflow_hiddenimports = collect_all('promptflow') + +datas += opentelemetry_datas +datas += promptflow_datas +datas += collect_data_files('streamlit_quill') datas += collect_data_files('keyrings.alt', include_py_files=True) datas += copy_metadata('keyrings.alt') -datas += collect_data_files('streamlit_quill') + +hidden_imports = ['win32timezone', 'opentelemetry.context.contextvars_context', 'streamlit.runtime.scriptrunner.magic_funcs'] + {{hidden_imports}} +hidden_imports += opentelemetry_hiddenimports +hidden_imports += promptflow_hiddenimports + +binaries = [] +binaries += opentelemetry_binaries +binaries += promptflow_binaries block_cipher = None -a = Analysis( +pfcli_a = Analysis( ['app.py', 'main.py', 'utils.py'], pathex=[], - binaries=[], + binaries=binaries, datas=datas, - hiddenimports={{hidden_imports}}, + hiddenimports=hidden_imports, hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -29,26 +51,25 @@ a = Analysis( cipher=block_cipher, noarchive=False, ) -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, +pfcli_pyz = PYZ(pfcli_a.pure, pfcli_a.zipped_data, cipher=block_cipher) + +pfcli_exe = EXE( + pfcli_pyz, + pfcli_a.scripts, + pfcli_a.binaries, + pfcli_a.zipfiles, + pfcli_a.datas, [], name='app', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, - upx_exclude=[], - runtime_tmpdir=None, console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, + contents_directory='.', ) diff --git a/src/promptflow-devkit/promptflow/_sdk/data/executable/pf b/src/promptflow-devkit/promptflow/_sdk/data/executable/pf new file mode 100644 index 00000000000..4bdc33e77fa --- /dev/null +++ b/src/promptflow-devkit/promptflow/_sdk/data/executable/pf @@ -0,0 +1,16 @@ +#!/bin/bash + +SCRIPT_DIR=$(dirname "$0") +MAIN_EXE="$SCRIPT_DIR/app" + +# Check if the first argument is 'service' +if [ "$1" == "service" ]; then + # Check if the second argument is 'start' + if [ "$2" == "start" ]; then + nohup "$MAIN_EXE" pf "${@:1}" > output.txt 2>&1 & + else + "$MAIN_EXE" pf "${@:1}" + fi +else + "$MAIN_EXE" pf "${@:1}" +fi diff --git a/src/promptflow-devkit/promptflow/_sdk/data/executable/pf.bat b/src/promptflow-devkit/promptflow/_sdk/data/executable/pf.bat new file mode 100644 index 00000000000..039c60ce92b --- /dev/null +++ b/src/promptflow-devkit/promptflow/_sdk/data/executable/pf.bat @@ -0,0 +1,16 @@ +@echo off +setlocal + + +set MAIN_EXE=%~dp0.\app.exe +REM Check if the first argument is 'start' +if "%~1"=="service" ( + REM Check if the second argument is 'start' + if "%~2"=="start" ( + cscript //nologo %~dp0.\start_pfs.vbs """%MAIN_EXE%"" pf %*" + ) else ( + "%MAIN_EXE%" pf %* + ) +) else ( + "%MAIN_EXE%" pf %* +) diff --git a/src/promptflow-devkit/promptflow/_sdk/data/executable/start_pfs.vbs b/src/promptflow-devkit/promptflow/_sdk/data/executable/start_pfs.vbs new file mode 100644 index 00000000000..d5d2443a2e7 --- /dev/null +++ b/src/promptflow-devkit/promptflow/_sdk/data/executable/start_pfs.vbs @@ -0,0 +1,4 @@ +DIM objshell +set objshell = wscript.createobject("wscript.shell") +cmd = WScript.Arguments(0) +iReturn = objshell.run(cmd, 0, false) diff --git a/src/promptflow-devkit/promptflow/_sdk/operations/_flow_operations.py b/src/promptflow-devkit/promptflow/_sdk/operations/_flow_operations.py index b7074f55484..5c06ba4edbf 100644 --- a/src/promptflow-devkit/promptflow/_sdk/operations/_flow_operations.py +++ b/src/promptflow-devkit/promptflow/_sdk/operations/_flow_operations.py @@ -2,9 +2,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- import contextlib +import copy import glob import json import os +import shutil +import stat import subprocess import sys import uuid @@ -13,6 +16,8 @@ from pathlib import Path from typing import Dict, Iterable, List, Tuple, Union +from pip._vendor import tomli as toml + from promptflow._constants import FlowLanguage from promptflow._sdk._configuration import Configuration from promptflow._sdk._constants import ( @@ -604,23 +609,78 @@ def _build_as_executable( with open(output_dir / "config.json", "w") as file: json.dump(config_content, file, indent=4) + generate_hidden_imports, all_packages, meta_packages = self._generate_executable_dependency() + hidden_imports.extend(generate_hidden_imports) copy_tree_respect_template_and_ignore_file( source=Path(__file__).parent.parent / "data" / "executable", target=output_dir, render_context={ "hidden_imports": hidden_imports, "runtime_interpreter_path": runtime_interpreter_path, + "all_packages": all_packages, + "meta_packages": meta_packages, }, ) self._run_pyinstaller(output_dir) + def _generate_executable_dependency(self): + def get_git_base_dir(): + return Path( + subprocess.run(["git", "rev-parse", "--show-toplevel"], stdout=subprocess.PIPE) + .stdout.decode("utf-8") + .strip() + ) + + dependencies = ["promptflow-devkit", "promptflow-core", "promptflow-tracing"] + # get promptflow-** required and extra packages + extra_packages = [] + required_packages = [] + for package in dependencies: + with open(get_git_base_dir() / "src" / package / "pyproject.toml", "rb") as file: + data = toml.load(file) + extras = data.get("tool", {}).get("poetry", {}).get("extras", {}) + for _, package in extras.items(): + extra_packages.extend(package) + requires = data.get("tool", {}).get("poetry", {}).get("dependencies", []) + for package, _ in requires.items(): + required_packages.append(package) + + all_packages = list(set(dependencies) | set(required_packages) | set(extra_packages)) + # remove all packages starting with promptflow + all_packages.remove("python") + all_packages = [package for package in all_packages if not package.startswith("promptflow")] + + hidden_imports = copy.deepcopy(all_packages) + meta_packages = copy.deepcopy(all_packages) + special_packages = ["streamlit-quill", "flask-cors", "flask-restx"] + for i in range(len(hidden_imports)): + # need special handeling because it use _ to import + if hidden_imports[i] in special_packages: + hidden_imports[i] = hidden_imports[i].replace("-", "_").lower() + else: + hidden_imports[i] = hidden_imports[i].replace("-", ".").lower() + + return hidden_imports, all_packages, meta_packages + def _run_pyinstaller(self, output_dir): with _change_working_dir(output_dir, mkdir=False): try: subprocess.run(["pyinstaller", "app.spec"], check=True) print("PyInstaller command executed successfully.") + + exe_dir = os.path.join(output_dir, "dist") + for file_name in ["pf.bat", "pf", "start_pfs.vbs"]: + src_file = os.path.join(output_dir, file_name) + dst_file = os.path.join(exe_dir, file_name) + shutil.copy(src_file, dst_file) + st = os.stat(dst_file) + os.chmod(dst_file, st.st_mode | stat.S_IEXEC) except FileNotFoundError as e: - raise UserErrorException(message_format="app.spec not found when run pyinstaller") from e + raise UserErrorException( + message_format="The pyinstaller command was not found. Please ensure that the " + "executable directory of the current python environment has " + "been added to the PATH environment variable." + ) from e @monitor_operation(activity_name="pf.flows.build", activity_type=ActivityType.PUBLICAPI) def build(