77import  sys 
88from  contextlib  import  asynccontextmanager 
99from  datetime  import  datetime 
10+ from  itertools  import  chain 
1011from  pathlib  import  Path 
1112
1213
@@ -141,10 +142,12 @@ async def log_stream_task(initial_devices):
141142            else :
142143                suppress_dupes  =  False 
143144                sys .stdout .write (line )
145+             sys .stdout .flush ()
144146
145147
146- async  def  xcode_test (location , simulator ):
148+ async  def  xcode_test (location , simulator ,  verbose ):
147149    # Run the test suite on the named simulator 
150+     print ("Starting xcodebuild..." )
148151    args  =  [
149152        "xcodebuild" ,
150153        "test" ,
@@ -159,18 +162,33 @@ async def xcode_test(location, simulator):
159162        "-derivedDataPath" ,
160163        str (location  /  "DerivedData" ),
161164    ]
165+     if  not  verbose :
166+         args  +=  ["-quiet" ]
167+ 
162168    async  with  async_process (
163169        * args ,
164170        stdout = subprocess .PIPE ,
165171        stderr = subprocess .STDOUT ,
166172    ) as  process :
167173        while  line  :=  (await  process .stdout .readline ()).decode (* DECODE_ARGS ):
168174            sys .stdout .write (line )
175+             sys .stdout .flush ()
169176
170177        status  =  await  asyncio .wait_for (process .wait (), timeout = 1 )
171178        exit (status )
172179
173180
181+ # A backport of Path.relative_to(*, walk_up=True) 
182+ def  relative_to (target , other ):
183+     for  step , path  in  enumerate (chain ([other ], other .parents )):
184+         if  path  ==  target  or  path  in  target .parents :
185+             break 
186+     else :
187+         raise  ValueError (f"{ str (target )!r} { str (other )!r}  )
188+     parts  =  ['..' ] *  step  +  list (target .parts [len (path .parts ):])
189+     return  Path ("/" .join (parts ))
190+ 
191+ 
174192def  clone_testbed (
175193    source : Path ,
176194    target : Path ,
@@ -182,7 +200,9 @@ def clone_testbed(
182200        sys .exit (10 )
183201
184202    if  framework  is  None :
185-         if  not  (source  /  "Python.xcframework/ios-arm64_x86_64-simulator/bin" ).is_dir ():
203+         if  not  (
204+             source  /  "Python.xcframework/ios-arm64_x86_64-simulator/bin" 
205+         ).is_dir ():
186206            print (
187207                f"The testbed being cloned ({ source }  
188208                f"a simulator framework. Re-run with --framework" 
@@ -202,33 +222,48 @@ def clone_testbed(
202222            )
203223            sys .exit (13 )
204224
205-     print ("Cloning testbed project..." )
206-     shutil .copytree (source , target )
225+     print ("Cloning testbed project:" )
226+     print (f"  Cloning { source }  , end = "" , flush = True )
227+     shutil .copytree (source , target , symlinks = True )
228+     print (" done" )
207229
208230    if  framework  is  not None :
209231        if  framework .suffix  ==  ".xcframework" :
210-             print ("Installing XCFramework..." )
211-             xc_framework_path  =  target  /  "Python.xcframework" 
212-             shutil .rmtree (xc_framework_path )
213-             shutil .copytree (framework , xc_framework_path )
232+             print ("  Installing XCFramework..." , end = "" , flush = True )
233+             xc_framework_path  =  (target  /  "Python.xcframework" ).resolve ()
234+             if  xc_framework_path .is_dir ():
235+                 shutil .rmtree (xc_framework_path )
236+             else :
237+                 xc_framework_path .unlink ()
238+             xc_framework_path .symlink_to (
239+                 relative_to (framework , xc_framework_path .parent )
240+             )
241+             print (" done" )
214242        else :
215-             print ("Installing simulator Framework ..." )
243+             print ("   Installing simulator framework ..." ,  end = "" ,  flush = True )
216244            sim_framework_path  =  (
217245                target  /  "Python.xcframework"  /  "ios-arm64_x86_64-simulator" 
246+             ).resolve ()
247+             if  sim_framework_path .is_dir ():
248+                 shutil .rmtree (sim_framework_path )
249+             else :
250+                 sim_framework_path .unlink ()
251+             sim_framework_path .symlink_to (
252+                 relative_to (framework , sim_framework_path .parent )
218253            )
219-             shutil .rmtree (sim_framework_path )
220-             shutil .copytree (framework , sim_framework_path )
254+             print (" done" )
221255    else :
222-         print ("Using pre-existing iOS framework." )
256+         print ("   Using pre-existing iOS framework." )
223257
224258    for  app_src  in  apps :
225-         print (f"Installing app { app_src .name !r}  )
259+         print (f"   Installing app { app_src .name !r}  ,  end = "" ,  flush = True )
226260        app_target  =  target  /  f"iOSTestbed/app/{ app_src .name }  
227261        if  app_target .is_dir ():
228262            shutil .rmtree (app_target )
229263        shutil .copytree (app_src , app_target )
264+         print (" done" )
230265
231-     print (f"Testbed project created in  { target }  )
266+     print (f"Successfully cloned testbed:  { target . resolve () }  )
232267
233268
234269def  update_plist (testbed_path , args ):
@@ -243,10 +278,11 @@ def update_plist(testbed_path, args):
243278        plistlib .dump (info , f )
244279
245280
246- async  def  run_testbed (simulator : str , args : list [str ]):
281+ async  def  run_testbed (simulator : str , args : list [str ],  verbose :  bool = False ):
247282    location  =  Path (__file__ ).parent 
248-     print ("Updating plist..." )
283+     print ("Updating plist..." ,  end = "" ,  flush = True )
249284    update_plist (location , args )
285+     print (" done." )
250286
251287    # Get the list of devices that are booted at the start of the test run. 
252288    # The simulator started by the test suite will be detected as the new 
@@ -256,10 +292,10 @@ async def run_testbed(simulator: str, args: list[str]):
256292    try :
257293        async  with  asyncio .TaskGroup () as  tg :
258294            tg .create_task (log_stream_task (initial_devices ))
259-             tg .create_task (xcode_test (location , simulator ))
260-     except*  MySystemExit  as  e :
295+             tg .create_task (xcode_test (location , simulator = simulator ,  verbose = verbose ))
296+     except  MySystemExit  as  e :
261297        raise  SystemExit (* e .exceptions [0 ].args ) from  None 
262-     except*  subprocess .CalledProcessError  as  e :
298+     except  subprocess .CalledProcessError  as  e :
263299        # Extract it from the ExceptionGroup so it can be handled by `main`. 
264300        raise  e .exceptions [0 ]
265301
@@ -315,6 +351,11 @@ def main():
315351        default = "iPhone SE (3rd Generation)" ,
316352        help = "The name of the simulator to use (default: 'iPhone SE (3rd Generation)')" ,
317353    )
354+     run .add_argument (
355+         "-v" , "--verbose" ,
356+         action = "store_true" ,
357+         help = "Enable verbose output" ,
358+     )
318359
319360    try :
320361        pos  =  sys .argv .index ("--" )
@@ -330,7 +371,7 @@ def main():
330371        clone_testbed (
331372            source = Path (__file__ ).parent ,
332373            target = Path (context .location ),
333-             framework = Path (context .framework ) if  context .framework  else  None ,
374+             framework = Path (context .framework ). resolve ()  if  context .framework  else  None ,
334375            apps = [Path (app ) for  app  in  context .apps ],
335376        )
336377    elif  context .subcommand  ==  "run" :
@@ -348,6 +389,7 @@ def main():
348389            asyncio .run (
349390                run_testbed (
350391                    simulator = context .simulator ,
392+                     verbose = context .verbose ,
351393                    args = test_args ,
352394                )
353395            )
0 commit comments