@@ -900,14 +900,13 @@ create_venv(Path, Opts) ->
900900 undefined -> get_python_executable ();
901901 P -> P
902902 end ,
903- Cmd = case Installer of
903+ case Installer of
904904 uv ->
905905 % % uv venv is faster, use --python to match the running interpreter
906- io_lib : format ( " uv venv --python ~s ~s " , [ quote ( Python ), quote ( Path ) ]);
906+ run_cmd ( uv_exe (), [ " venv" , " --python" , Python , Path ], [ ]);
907907 pip ->
908- io_lib :format (" ~s -m venv ~s " , [quote (Python ), quote (Path )])
909- end ,
910- run_cmd (lists :flatten (Cmd )).
908+ run_cmd (Python , [" -m" , " venv" , Path ], [])
909+ end .
911910
912911% % @private Get the Python executable path
913912% % When embedded, sys.executable returns the embedding app (beam.smp)
@@ -927,30 +926,28 @@ get_python_executable() ->
927926-spec install_deps (string (), string (), list ()) -> ok | {error , term ()}.
928927install_deps (Path , RequirementsFile , Opts ) ->
929928 Installer = detect_installer (Opts ),
930- PipPath = pip_path (Path , Installer ),
929+ { Exe , BaseArgs , PortOpts } = pip_command (Path , Installer ),
931930 Extras = proplists :get_value (extras , Opts , []),
932931
933- % % Determine file type and build install command
934- Cmd = case filename :extension (RequirementsFile ) of
932+ % % Determine file type and build the install argument list (no shell).
933+ Args = case filename :extension (RequirementsFile ) of
935934 " .txt" ->
936- % % requirements.txt
937- io_lib :format (" ~s install -r ~s " , [PipPath , quote (RequirementsFile )]);
935+ BaseArgs ++ [" install" , " -r" , RequirementsFile ];
938936 " .toml" ->
939- % % pyproject.toml - install as editable
937+ % % pyproject.toml - install as editable.
940938 % % filename:dirname returns "." for files without directory component
941939 InstallPath = filename :dirname (RequirementsFile ),
942940 case Extras of
943941 [] ->
944- io_lib : format ( " ~s install -e ~s " , [ PipPath , quote ( InstallPath )]) ;
942+ BaseArgs ++ [ " install" , " -e " , InstallPath ] ;
945943 _ ->
946944 ExtrasStr = string :join (Extras , " ," ),
947- io_lib : format ( " ~s install -e \" ~s [ ~s ] \" " , [ PipPath , InstallPath , ExtrasStr ])
945+ BaseArgs ++ [ " install " , " -e " , InstallPath ++ " [ " ++ ExtrasStr ++ " ] " ]
948946 end ;
949947 _ ->
950- % % Assume requirements.txt format
951- io_lib :format (" ~s install -r ~s " , [PipPath , quote (RequirementsFile )])
948+ BaseArgs ++ [" install" , " -r" , RequirementsFile ]
952949 end ,
953- run_cmd (lists : flatten ( Cmd ) ).
950+ run_cmd (Exe , Args , PortOpts ).
954951
955952% % @private Detect which installer to use (uv or pip)
956953-spec detect_installer (list ()) -> uv | pip .
@@ -965,40 +962,74 @@ detect_installer(Opts) ->
965962 Installer
966963 end .
967964
968- % % @private Get pip/uv pip command path
969- - spec pip_path ( string (), uv | pip ) -> string ().
970- pip_path ( VenvPath , uv ) ->
971- % % uv pip uses venv from env var or --python flag
972- " VIRTUAL_ENV= " ++ quote (VenvPath ) ++ " uv pip " ;
973- pip_path ( VenvPath , pip ) ->
974- % % Use pip from the venv
975- case os :type () of
965+ % % @private Resolve the installer into {Executable, BaseArgs, PortOpts}.
966+ % % For uv the venv is selected via the VIRTUAL_ENV port env option (not a shell
967+ % % prefix); for pip we use the venv's own pip binary.
968+ - spec pip_command ( string (), uv | pip ) -> { string (), [ string ()], list ()}.
969+ pip_command (VenvPath , uv ) ->
970+ { uv_exe (), [ " pip" ], [{ env , [{ " VIRTUAL_ENV " , VenvPath }]}]};
971+ pip_command ( VenvPath , pip ) ->
972+ PipExe = case os :type () of
976973 {win32 , _ } ->
977974 filename :join ([VenvPath , " Scripts" , " pip" ]);
978975 _ ->
979976 filename :join ([VenvPath , " bin" , " pip" ])
977+ end ,
978+ {PipExe , [], []}.
979+
980+ % % @private Full path to the uv executable (falls back to the bare name).
981+ -spec uv_exe () -> string ().
982+ uv_exe () ->
983+ case os :find_executable (" uv" ) of
984+ false -> " uv" ;
985+ P -> P
980986 end .
981987
982- % % @private Quote a path for shell
983- -spec quote (string ()) -> string ().
984- quote (S ) ->
985- " '" ++ S ++ " '" .
986-
987- % % @private Run a shell command and return ok or error
988- -spec run_cmd (string ()) -> ok | {error , term ()}.
989- run_cmd (Cmd ) ->
990- % % Use os:cmd but check for errors
991- Result = os :cmd (Cmd ++ " 2>&1; echo \" ::exitcode::$?\" " ),
992- % % Parse exit code from end of output
993- case string :split (Result , " ::exitcode::" , trailing ) of
994- [Output , ExitCodeStr ] ->
995- case string :trim (ExitCodeStr ) of
996- " 0" -> ok ;
997- Code -> {error , {exit_code , list_to_integer (Code ), string :trim (Output )}}
988+ % % @private Run an executable with an argv list (no shell) and return ok or error.
989+ -spec run_cmd (string (), [string ()], list ()) -> ok | {error , term ()}.
990+ run_cmd (Exe , Args , ExtraOpts ) ->
991+ case resolve_exe (Exe ) of
992+ {error , _ } = Err ->
993+ Err ;
994+ ExeFull ->
995+ try open_port ({spawn_executable , ExeFull },
996+ [exit_status , stderr_to_stdout , binary , {args , Args } | ExtraOpts ]) of
997+ Port -> collect_port (Port , [])
998+ catch
999+ error :Reason -> {error , {spawn_failed , Exe , Reason }}
1000+ end
1001+ end .
1002+
1003+ % % @private Resolve an executable name/path to a full path (spawn_executable does
1004+ % % not search PATH).
1005+ -spec resolve_exe (string ()) -> string () | {error , term ()}.
1006+ resolve_exe (Exe ) ->
1007+ case filename :pathtype (Exe ) of
1008+ absolute ->
1009+ case filelib :is_file (Exe ) of
1010+ true -> Exe ;
1011+ false -> {error , {executable_not_found , Exe }}
9981012 end ;
9991013 _ ->
1000- % % Fallback - assume success if no error marker
1001- ok
1014+ case os :find_executable (Exe ) of
1015+ false -> {error , {executable_not_found , Exe }};
1016+ Found -> Found
1017+ end
1018+ end .
1019+
1020+ % % @private Collect a spawned port's output and exit status.
1021+ -spec collect_port (port (), [binary ()]) -> ok | {error , term ()}.
1022+ collect_port (Port , Acc ) ->
1023+ receive
1024+ {Port , {data , Data }} ->
1025+ collect_port (Port , [Data | Acc ]);
1026+ {Port , {exit_status , 0 }} ->
1027+ ok ;
1028+ {Port , {exit_status , Code }} ->
1029+ {error , {exit_code , Code , iolist_to_binary (lists :reverse (Acc ))}}
1030+ after 300000 ->
1031+ try port_close (Port ) catch _ :_ -> ok end ,
1032+ {error , timeout }
10021033 end .
10031034
10041035% % @private Convert to string
0 commit comments