@@ -720,6 +720,74 @@ pub(super) fn still_references_artifact(restored: &str, original: &str, uuid: &s
720720 restored. contains ( & needle) && !original. contains ( & needle)
721721}
722722
723+ /// Whether `name` (PEP 503) left the live lock `text` altogether: no unit
724+ /// of that name, and no unit lists it among its `dependencies` (#1287).
725+ /// For a script lock, `script` is the live script, which must not declare
726+ /// it either. That is a dependency the user dropped (`uv remove --script`
727+ /// of the parent of a transitive package), not a hand edit of its unit.
728+ fn package_vanished ( text : & str , name : & str , script : Option < & str > ) -> bool {
729+ let Ok ( doc) = text. parse :: < DocumentMut > ( ) else {
730+ return false ;
731+ } ;
732+ let ( collection, _) = crate :: utils:: python_lock:: lock_package_collection ( & doc) ;
733+ let names = |item : Option < & Item > | -> bool {
734+ item. and_then ( Item :: as_array) . is_some_and ( |deps| {
735+ deps. iter ( ) . any ( |dep| {
736+ dep. as_inline_table ( )
737+ . and_then ( |t| t. get ( "name" ) )
738+ . and_then ( Value :: as_str)
739+ . is_some_and ( |n| canonicalize_pypi_name ( n) == name)
740+ } )
741+ } )
742+ } ;
743+ let in_lock = doc
744+ . get ( collection)
745+ . and_then ( Item :: as_array_of_tables)
746+ . is_some_and ( |packages| {
747+ packages. iter ( ) . any ( |unit| {
748+ unit. get ( "name" )
749+ . and_then ( Item :: as_str)
750+ . is_some_and ( |n| canonicalize_pypi_name ( n) == name)
751+ || names ( unit. get ( "dependencies" ) )
752+ } )
753+ } ) ;
754+ let declared = script. is_some_and ( |script| {
755+ script_metadata ( script) . ok ( ) . is_some_and ( |( _, metadata) | {
756+ metadata. parse :: < DocumentMut > ( ) . ok ( ) . is_some_and ( |doc| {
757+ doc. get ( "dependencies" )
758+ . and_then ( Item :: as_array)
759+ . is_some_and ( |deps| {
760+ deps. iter ( ) . filter_map ( Value :: as_str) . any ( |spec| {
761+ canonicalize_pypi_name ( crate :: vendor:: common:: pep508_name ( spec) ) == name
762+ } )
763+ } )
764+ } )
765+ } )
766+ } ) ;
767+ !in_lock && !declared
768+ }
769+
770+ /// `text` with every lock unit named `name` (PEP 503) dropped: the vendored
771+ /// unit of a dependency that has since left the lock, which the document
772+ /// restore then no longer tries to pair (#1287).
773+ fn without_package ( text : & str , name : & str ) -> Result < String , String > {
774+ let mut doc: DocumentMut = text
775+ . parse ( )
776+ . map_err ( |error| format ! ( "invalid recorded TOML: {error}" ) ) ?;
777+ let ( collection, _) = crate :: utils:: python_lock:: lock_package_collection ( & doc) ;
778+ if let Some ( packages) = doc
779+ . get_mut ( collection)
780+ . and_then ( Item :: as_array_of_tables_mut)
781+ {
782+ packages. retain ( |unit| {
783+ unit. get ( "name" )
784+ . and_then ( Item :: as_str)
785+ . is_none_or ( |n| canonicalize_pypi_name ( n) != name)
786+ } ) ;
787+ }
788+ Ok ( doc. to_string ( ) )
789+ }
790+
723791pub ( super ) async fn revert_python_locks (
724792 entry : & VendorEntry ,
725793 root : & Path ,
@@ -752,6 +820,25 @@ pub(super) async fn revert_python_locks(
752820 Err ( ( _, error) ) => return RevertOutcome :: failed ( error) ,
753821 }
754822 }
823+ // The vendored package's PEP 503 name, and each script lock's live
824+ // script (read once, before any record is reverted).
825+ let package_name = entry
826+ . base_purl
827+ . strip_prefix ( "pkg:pypi/" )
828+ . and_then ( |rest| rest. rsplit_once ( '@' ) )
829+ . map ( |( name, _) | canonicalize_pypi_name ( name) ) ;
830+ let mut script_texts = std:: collections:: BTreeMap :: new ( ) ;
831+ for record in entry. wiring . iter ( ) . filter ( |r| r. kind == KIND ) {
832+ if let Some ( script) = record
833+ . file
834+ . strip_suffix ( ".lock" )
835+ . filter ( |s| s. ends_with ( ".py" ) )
836+ {
837+ if let Ok ( text) = read_file ( & root. join ( script) ) . await {
838+ script_texts. insert ( record. file . as_str ( ) , text) ;
839+ }
840+ }
841+ }
755842 for record in entry. wiring . iter ( ) . rev ( ) {
756843 if !allowed_file ( & record. file , & record. kind ) {
757844 warnings. push ( VendorWarning :: new (
@@ -788,6 +875,40 @@ pub(super) async fn revert_python_locks(
788875 Ok ( live) => live,
789876 Err ( ( _, error) ) => return RevertOutcome :: failed ( error) ,
790877 } ;
878+ // #1287: the vendored package's own unit is gone from the lock along
879+ // with every dependent (`uv remove` of the parent of a transitive
880+ // package), while the overrides it was wired through survive. That
881+ // unit has nothing left to restore: drop it from both recorded
882+ // documents so the rest of the record reverts normally.
883+ let ( original_owned, new_owned) ;
884+ let ( original, new) = if record. kind == KIND
885+ && package_name. as_deref ( ) . is_some_and ( |name| {
886+ package_vanished (
887+ & live,
888+ name,
889+ script_texts. get ( record. file . as_str ( ) ) . map ( String :: as_str) ,
890+ )
891+ } ) {
892+ let name = package_name. as_deref ( ) . unwrap_or_default ( ) ;
893+ match ( without_package ( original, name) , without_package ( new, name) ) {
894+ ( Ok ( o) , Ok ( n) ) => {
895+ warnings. push ( VendorWarning :: new (
896+ super :: LOCK_ENTRY_REMOVED_CODE ,
897+ format ! (
898+ "{}: the {name} unit no longer exists and nothing depends on it \
899+ (the dependency was removed); its other wiring is restored",
900+ record. file
901+ ) ,
902+ ) ) ;
903+ original_owned = o;
904+ new_owned = n;
905+ ( original_owned. as_str ( ) , new_owned. as_str ( ) )
906+ }
907+ ( Err ( error) , _) | ( _, Err ( error) ) => return RevertOutcome :: failed ( error) ,
908+ }
909+ } else {
910+ ( original, new)
911+ } ;
791912 let restored = if record. kind == SCRIPT_KIND {
792913 ( || {
793914 if live == new || live == original {
@@ -1710,6 +1831,71 @@ mod tests {
17101831 ( script. to_string ( ) , lock. to_string ( ) )
17111832 }
17121833
1834+ /// A script whose `one` arrives only TRANSITIVELY (through `parent`),
1835+ /// vendored: wired through the script's `[tool.uv]` override + source
1836+ /// and the lock's `[manifest] overrides`.
1837+ async fn vendor_transitive_script_pair ( root : & Path ) -> ( VendorEntry , String , String ) {
1838+ let lock = "version = 1\n revision = 3\n requires-python = \" >=3.9\" \n \n [manifest]\n requirements = [{name = \" attrs\" , specifier = \" >=20\" }, {name = \" parent\" , specifier = \" ==1\" }]\n \n [[package]]\n name = \" attrs\" \n version = \" 25.3.0\" \n source = {registry = \" https://pypi.org/simple\" }\n \n [[package]]\n name = \" one\" \n version = \" 1\" \n source = {registry = \" https://pypi.org/simple\" }\n \n [[package]]\n name = \" parent\" \n version = \" 1\" \n source = {registry = \" https://pypi.org/simple\" }\n dependencies = [{name = \" one\" }]\n " ;
1839+ let script = "# /// script\n # requires-python = \" >=3.9\" \n # dependencies = [\" parent==1\" , \" attrs>=20\" ]\n # ///\n import one\n " ;
1840+ write_pylock ( root, "job.py.lock" , lock) . await ;
1841+ tokio:: fs:: write ( root. join ( "job.py" ) , script) . await . unwrap ( ) ;
1842+ let project = load_python_locks ( root, "one" , "1" , UUID ) . await . unwrap ( ) ;
1843+ let wheel =
1844+ ".socket/vendor/pypi/11111111-1111-4111-8111-111111111111/one-1-py3-none-any.whl" ;
1845+ let records = wire_python_locks ( & project, root, "one" , "1" , wheel, & "a" . repeat ( 64 ) )
1846+ . await
1847+ . unwrap ( ) ;
1848+ let entry: VendorEntry = serde_json:: from_value ( serde_json:: json!( {
1849+ "ecosystem" : "pypi" ,
1850+ "basePurl" : "pkg:pypi/one@1" ,
1851+ "uuid" : UUID ,
1852+ "artifact" : { "path" : wheel, "sha256" : "a" . repeat( 64 ) } ,
1853+ "wiring" : serde_json:: to_value( & records) . unwrap( ) ,
1854+ "flavor" : "python-lock" ,
1855+ } ) )
1856+ . unwrap ( ) ;
1857+ let wired_script = std:: fs:: read_to_string ( root. join ( "job.py" ) ) . unwrap ( ) ;
1858+ let wired_lock = std:: fs:: read_to_string ( root. join ( "job.py.lock" ) ) . unwrap ( ) ;
1859+ ( entry, wired_script, wired_lock)
1860+ }
1861+
1862+ /// #1287: `uv remove --script job.py parent` drops the transitive
1863+ /// `one`'s unit (and its parent's) but keeps the script's `[tool.uv]`
1864+ /// override + source and the lock's `[manifest] overrides`, which still
1865+ /// name the uuid. The vanished unit is removed, not drift, and the
1866+ /// surviving wiring reverts, so the script and lock end up as uv writes
1867+ /// them for the project without `one`.
1868+ #[ tokio:: test]
1869+ async fn script_revert_after_uv_remove_of_the_transitive_parent ( ) {
1870+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
1871+ let root = temp. path ( ) ;
1872+ let ( entry, wired_script, wired_lock) = vendor_transitive_script_pair ( root) . await ;
1873+ let removed_script = wired_script. replace ( "\" parent==1\" , " , "" ) ;
1874+ let mut removed_lock = wired_lock. replace ( ", {name = \" parent\" , specifier = \" ==1\" }" , "" ) ;
1875+ let one = removed_lock. find ( "\n [[package]]\n name = \" one\" " ) . unwrap ( ) ;
1876+ removed_lock. truncate ( one) ;
1877+ tokio:: fs:: write ( root. join ( "job.py" ) , & removed_script)
1878+ . await
1879+ . unwrap ( ) ;
1880+ write_pylock ( root, "job.py.lock" , & removed_lock) . await ;
1881+
1882+ let outcome = revert_python_locks ( & entry, root, false ) . await ;
1883+ assert ! ( outcome. success, "{outcome:?}" ) ;
1884+ assert ! ( !outcome. drift_skipped( ) , "{:?}" , outcome. warnings) ;
1885+ assert ! ( outcome. lock_entry_removed( ) , "{:?}" , outcome. warnings) ;
1886+ let script = std:: fs:: read_to_string ( root. join ( "job.py" ) ) . unwrap ( ) ;
1887+ let lock = std:: fs:: read_to_string ( root. join ( "job.py.lock" ) ) . unwrap ( ) ;
1888+ assert_eq ! (
1889+ script,
1890+ "# /// script\n # requires-python = \" >=3.9\" \n # dependencies = [\" attrs>=20\" ]\n # ///\n import one\n "
1891+ ) ;
1892+ assert ! (
1893+ !lock. contains( UUID ) && !lock. contains( "overrides" ) ,
1894+ "{lock}"
1895+ ) ;
1896+ assert ! ( lock. contains( "name = \" attrs\" " ) , "{lock}" ) ;
1897+ }
1898+
17131899 /// #1214: after `uv remove --script` drops the vendored dependency from a
17141900 /// PEP 723 script and its lock, the revert has nothing left to restore.
17151901 /// It must warn `vendor_lock_entry_removed` and finish (the caller then
0 commit comments