@@ -14,35 +14,52 @@ use std::fs;
1414use std:: io:: Write ;
1515use std:: path:: { Path , PathBuf } ;
1616use std:: process:: { Command , Stdio } ;
17+ use std:: sync:: Mutex ;
18+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
1719
1820fn mdtest_dir ( ) -> PathBuf {
1921 Path :: new ( env ! ( "CARGO_MANIFEST_DIR" ) ) . join ( "../ty_python_semantic/resources/mdtest" )
2022}
2123
22- fn python ( ) -> String {
24+ /// A CPython 3.13 interpreter, provisioned through uv so the harness runs the
25+ /// same interpreter everywhere instead of riding on whatever `python3` the host
26+ /// happens to ship. The transpiler emits modern syntax — PEP 695 generics, PEP
27+ /// 696 type-parameter defaults (`class C[T = int]`), PEP 646 unpacking — whose
28+ /// runtime floor is 3.13; CI runners range from 3.10 upward, so a checker-clean
29+ /// block can fail to even parse on an older interpreter. Returns `None` (the
30+ /// test then skips) when uv or the interpreter can't be obtained.
31+ #[ cfg( not( windows) ) ]
32+ fn python ( ) -> Option < String > {
2333 if let Ok ( p) = std:: env:: var ( "PYTHON" ) {
24- return p ;
34+ return Some ( p ) ;
2535 }
26- // newest available interpreter: blocks exercise modern syntax (`match`,
27- // subclassable `Any`) that the system `python3` may predate
28- for candidate in [
29- "python3.14" ,
30- "python3.13" ,
31- "python3.12" ,
32- "python3.11" ,
33- "python3.10" ,
34- "python3" ,
35- ] {
36- if Command :: new ( candidate)
37- . arg ( "-c" )
38- . arg ( "pass" )
36+ let find = || {
37+ let out = Command :: new ( "uv" )
38+ . args ( [ "python" , "find" , "3.13" ] )
3939 . output ( )
40- . is_ok_and ( |o| o. status . success ( ) )
41- {
42- return candidate. to_owned ( ) ;
43- }
40+ . ok ( ) ?;
41+ out. status
42+ . success ( )
43+ . then ( || String :: from_utf8_lossy ( & out. stdout ) . trim ( ) . to_owned ( ) )
44+ } ;
45+ if let Some ( path) = find ( ) {
46+ return Some ( path) ;
4447 }
45- "python3" . to_owned ( )
48+ // not discoverable yet — let uv download a managed build, then locate it
49+ Command :: new ( "uv" )
50+ . args ( [ "python" , "install" , "3.13" ] )
51+ . output ( )
52+ . ok ( ) ?;
53+ find ( )
54+ }
55+
56+ /// On windows the harness is skipped unless `PYTHON` is set explicitly: it drives
57+ /// a python subprocess whose interpreter discovery and stdout encoding differ
58+ /// from unix, and the checker/runtime contract it validates is platform
59+ /// independent, so unix coverage suffices.
60+ #[ cfg( windows) ]
61+ fn python ( ) -> Option < String > {
62+ std:: env:: var ( "PYTHON" ) . ok ( )
4663}
4764
4865/// `major.minor` of the interpreter the blocks will execute on, so the
@@ -59,7 +76,7 @@ fn python_version(python: &str) -> Option<String> {
5976 . then ( || String :: from_utf8_lossy ( & output. stdout ) . trim ( ) . to_owned ( ) )
6077}
6178
62- /// Extract the ```by fenced blocks of a markdown file, in order, with a flag
79+ /// Extract the `by` fenced code blocks of a markdown file, in order, with a flag
6380/// for blocks living in a multi-file section (one that declares companion
6481/// modules via a `` `name.py`: `` marker) — those import section-local modules
6582/// and cannot run standalone.
@@ -133,8 +150,15 @@ fn with_reveal_stub(transpiled: &str) -> String {
133150}
134151
135152#[ test]
153+ #[ expect(
154+ clippy:: print_stderr,
155+ reason = "skip diagnostic when python is unavailable"
156+ ) ]
136157fn clean_mdtest_blocks_run ( ) {
137- let python = python ( ) ;
158+ let Some ( python) = python ( ) else {
159+ eprintln ! ( "skipping: no python 3.13 interpreter available (uv on unix, or set PYTHON)" ) ;
160+ return ;
161+ } ;
138162 let Some ( version) = python_version ( & python) else {
139163 eprintln ! ( "skipping: `{python}` not runnable" ) ;
140164 return ;
@@ -147,65 +171,97 @@ fn clean_mdtest_blocks_run() {
147171 . output ( )
148172 . is_ok_and ( |o| o. status . success ( ) ) ;
149173
150- let mut failures: Vec < String > = Vec :: new ( ) ;
151- let mut total = 0usize ;
152174 let dir = mdtest_dir ( ) ;
153175 let mut files: Vec < PathBuf > = fs:: read_dir ( & dir)
154176 . expect ( "mdtest dir" )
155177 . filter_map ( |e| e. ok ( ) . map ( |e| e. path ( ) ) )
156178 . filter ( |p| {
157- p. file_name ( )
158- . and_then ( |n| n. to_str ( ) )
159- . is_some_and ( |n| n. starts_with ( "basedpython_" ) && n. ends_with ( ".md" ) )
179+ p. extension ( )
180+ . is_some_and ( |ext| ext. eq_ignore_ascii_case ( "md" ) )
181+ && p. file_name ( )
182+ . and_then ( |n| n. to_str ( ) )
183+ . is_some_and ( |n| n. starts_with ( "basedpython_" ) )
160184 } )
161185 . collect ( ) ;
162186 files. sort ( ) ;
163187 assert ! ( !files. is_empty( ) , "no basedpython mdtests found in {dir:?}" ) ;
164188
165- let tmp = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
166- for file in files {
189+ // gather every runnable block up front — skipping ones with expected
190+ // diagnostics (only checker-clean blocks carry the contract) or that import
191+ // section-local companion modules this harness doesn't materialize — so the
192+ // work can be spread across a pool of workers rather than run serially. each
193+ // block is an independent `by transpile` + python subprocess pair, so the
194+ // harness is dominated by process spawn latency and parallelises cleanly.
195+ let mut items: Vec < ( String , usize , String ) > = Vec :: new ( ) ;
196+ for file in & files {
167197 let name = file. file_name ( ) . unwrap ( ) . to_string_lossy ( ) . into_owned ( ) ;
168- let markdown = fs:: read_to_string ( & file) . expect ( "read mdtest" ) ;
169- for ( i, ( block, multi_file) ) in by_blocks ( & markdown) . iter ( ) . enumerate ( ) {
170- // a block with expected diagnostics is allowed to misbehave at
171- // runtime (only checker-clean blocks carry the contract), and a
172- // block in a multi-file section imports section-local modules this
173- // harness doesn't materialize
174- if block. contains ( "# error:" ) || * multi_file {
198+ let markdown = fs:: read_to_string ( file) . expect ( "read mdtest" ) ;
199+ for ( i, ( block, multi_file) ) in by_blocks ( & markdown) . into_iter ( ) . enumerate ( ) {
200+ if block. contains ( "# error:" ) || multi_file {
175201 continue ;
176202 }
177- total += 1 ;
178- let transpiled = match transpile ( block, & version) {
179- Ok ( t) => t,
180- Err ( e) => {
181- failures. push ( format ! (
182- "{name} block {i}: transpile failed:\n {e}\n --- block ---\n {block}"
203+ items. push ( ( name. clone ( ) , i, block) ) ;
204+ }
205+ }
206+ let total = items. len ( ) ;
207+ assert ! ( total > 0 , "no checker-clean by blocks found" ) ;
208+
209+ let tmp = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
210+ let failures: Mutex < Vec < String > > = Mutex :: new ( Vec :: new ( ) ) ;
211+ let next = AtomicUsize :: new ( 0 ) ;
212+ // each block alternates a cpu-bound `by transpile` with a wait on the python
213+ // subprocess, and `by` startup also reads typeshed from disk — so a little
214+ // oversubscription past the core count overlaps that i/o and keeps the cores
215+ // busy, while the cap bounds how many processes are ever live at once
216+ let workers = std:: thread:: available_parallelism ( )
217+ . map_or ( 4 , |n| n. get ( ) * 2 )
218+ . clamp ( 1 , 16 )
219+ . min ( total) ;
220+
221+ // work-stealing pool: each worker claims the next index and processes it
222+ // until the list is drained. the closures borrow the shared state (the
223+ // work list, the cursor, the failure sink) so `failures` stays owned for
224+ // the drain below. temp files are keyed by `name_i`, unique per block, so
225+ // concurrent writes never collide.
226+ std:: thread:: scope ( |scope| {
227+ for _ in 0 ..workers {
228+ scope. spawn ( || {
229+ while let Some ( ( name, i, block) ) =
230+ items. get ( next. fetch_add ( 1 , Ordering :: Relaxed ) )
231+ {
232+ let transpiled = match transpile ( block, & version) {
233+ Ok ( t) => t,
234+ Err ( e) => {
235+ failures. lock ( ) . unwrap ( ) . push ( format ! (
236+ "{name} block {i}: transpile failed:\n {e}\n --- block ---\n {block}"
237+ ) ) ;
238+ continue ;
239+ }
240+ } ;
241+ if !has_typing_extensions && transpiled. contains ( "typing_extensions" ) {
242+ continue ;
243+ }
244+ let py = tmp. path ( ) . join ( format ! (
245+ "{}_{i}.py" ,
246+ name. trim_end_matches( ".md" ) . replace( '-' , "_" )
183247 ) ) ;
184- continue ;
248+ fs:: write ( & py, with_reveal_stub ( & transpiled) ) . unwrap ( ) ;
249+ let run = Command :: new ( & python)
250+ . arg ( & py)
251+ . output ( )
252+ . expect ( "failed to run python" ) ;
253+ if !run. status . success ( ) {
254+ failures. lock ( ) . unwrap ( ) . push ( format ! (
255+ "{name} block {i}: checker-clean block crashed at runtime:\n {}\n --- block ---\n {block}" ,
256+ String :: from_utf8_lossy( & run. stderr)
257+ ) ) ;
258+ }
185259 }
186- } ;
187- if !has_typing_extensions && transpiled. contains ( "typing_extensions" ) {
188- continue ;
189- }
190- let py = tmp. path ( ) . join ( format ! (
191- "{}_{i}.py" ,
192- name. trim_end_matches( ".md" ) . replace( '-' , "_" )
193- ) ) ;
194- fs:: write ( & py, with_reveal_stub ( & transpiled) ) . unwrap ( ) ;
195- let run = Command :: new ( & python)
196- . arg ( & py)
197- . output ( )
198- . expect ( "failed to run python" ) ;
199- if !run. status . success ( ) {
200- failures. push ( format ! (
201- "{name} block {i}: checker-clean block crashed at runtime:\n {}\n --- block ---\n {block}" ,
202- String :: from_utf8_lossy( & run. stderr)
203- ) ) ;
204- }
260+ } ) ;
205261 }
206- }
262+ } ) ;
207263
208- assert ! ( total > 0 , "no checker-clean by blocks found ") ;
264+ let failures = failures . into_inner ( ) . expect ( "failures mutex poisoned ") ;
209265 assert ! (
210266 failures. is_empty( ) ,
211267 "{} of {} checker-clean blocks diverge at runtime:\n \n {}" ,
0 commit comments