@@ -16,6 +16,80 @@ class FileUpdater
1616 class PyprojectPreparer
1717 extend T ::Sig
1818
19+ # Builds a regex pattern that matches a PEP 508 package name,
20+ # treating hyphens, underscores, and dots as interchangeable per PEP 508.
21+ sig { params ( name : String ) . returns ( String ) }
22+ def self . pep508_name_pattern ( name )
23+ Regexp . escape ( name ) . gsub ( "\\ -" , "[-_.]" ) . gsub ( "_" , "[-_.]" ) . gsub ( "\\ ." , "[-_.]" )
24+ end
25+ private_class_method :pep508_name_pattern
26+
27+ # Pins a single PEP 508 dependency entry string to a specific version,
28+ # preserving extras and environment markers.
29+ sig { params ( entry : String , name_pattern : String , version : String ) . returns ( String ) }
30+ def self . pin_pep508_entry ( entry , name_pattern , version )
31+ if entry . match? ( /\A #{ name_pattern } (\[ .*?\] )?\s *[><=!~]/i )
32+ entry . sub (
33+ /(?<pre>#{ name_pattern } (?:\[ .*?\] )?)\s *[><=!~][^;]*?(?=\s *;|\s *\z )/i ,
34+ "\\ k<pre>==#{ version } "
35+ )
36+ else
37+ entry . sub (
38+ /(?<pre>#{ name_pattern } (?:\[ .*?\] )?)(?<rest>\s *(?:;.*)?)/i ,
39+ "\\ k<pre>==#{ version } \\ k<rest>"
40+ )
41+ end
42+ end
43+ private_class_method :pin_pep508_entry
44+
45+ # Freezes PEP 621 dependencies in-place within a parsed pyproject object.
46+ # Replaces version specifiers with ==dep.version for each matching dep.
47+ # Accepts an optional block to filter which dependencies to freeze.
48+ sig do
49+ params (
50+ pyproject_object : T ::Hash [ String , T . untyped ] ,
51+ deps : T ::Array [ Dependabot ::Dependency ] ,
52+ blk : T . nilable ( T . proc . params ( dep : Dependabot ::Dependency ) . returns ( T ::Boolean ) )
53+ ) . void
54+ end
55+ def self . freeze_pep621_deps! ( pyproject_object , deps , &blk )
56+ dep_arrays = collect_pep621_dep_arrays ( pyproject_object )
57+ return if dep_arrays . empty?
58+
59+ deps . each do |dep |
60+ next if blk && !yield ( dep )
61+ next unless dep . version
62+
63+ pin_pep621_dep_in_arrays! ( dep_arrays , dep )
64+ end
65+ end
66+
67+ sig { params ( pyproject_object : T ::Hash [ String , T . untyped ] ) . returns ( T ::Array [ T ::Array [ String ] ] ) }
68+ def self . collect_pep621_dep_arrays ( pyproject_object )
69+ project_object = pyproject_object [ "project" ]
70+ return [ ] unless project_object
71+
72+ dep_arrays = [ project_object [ "dependencies" ] ]
73+ project_object [ "optional-dependencies" ] &.each_value { |opt | dep_arrays << opt }
74+ dep_arrays . compact!
75+ dep_arrays . select! { |arr | arr . is_a? ( Array ) && arr . all? ( String ) }
76+ dep_arrays
77+ end
78+ private_class_method :collect_pep621_dep_arrays
79+
80+ sig { params ( dep_arrays : T ::Array [ T ::Array [ String ] ] , dep : Dependabot ::Dependency ) . void }
81+ def self . pin_pep621_dep_in_arrays! ( dep_arrays , dep )
82+ name_pattern = pep508_name_pattern ( dep . name )
83+ dep_arrays . each do |arr |
84+ arr . each_with_index do |entry , i |
85+ next unless entry . match? ( /\A #{ name_pattern } (\[ .*?\] )?\s *(\z |[><=!~;,])/i )
86+
87+ arr [ i ] = pin_pep508_entry ( entry , name_pattern , T . must ( dep . version ) )
88+ end
89+ end
90+ end
91+ private_class_method :pin_pep621_dep_in_arrays!
92+
1993 sig { params ( pyproject_content : String , lockfile : T . nilable ( Dependabot ::DependencyFile ) ) . void }
2094 def initialize ( pyproject_content :, lockfile : nil )
2195 @pyproject_content = pyproject_content
@@ -109,6 +183,9 @@ def freeze_top_level_dependencies_except(dependencies)
109183 end
110184 end
111185
186+ # Freeze PEP 621 project.dependencies and project.optional-dependencies
187+ freeze_pep621_top_level_deps! ( pyproject_object , excluded_names )
188+
112189 TomlRB . dump ( pyproject_object )
113190 end
114191 # rubocop:enable Metrics/AbcSize
@@ -122,6 +199,38 @@ def freeze_top_level_dependencies_except(dependencies)
122199 sig { returns ( T . nilable ( Dependabot ::DependencyFile ) ) }
123200 attr_reader :lockfile
124201
202+ sig { params ( pyproject_object : T ::Hash [ String , T . untyped ] , excluded_names : T ::Array [ String ] ) . void }
203+ def freeze_pep621_top_level_deps! ( pyproject_object , excluded_names )
204+ project_object = pyproject_object [ "project" ]
205+ return unless project_object
206+
207+ freeze_pep621_dep_array! ( project_object [ "dependencies" ] , excluded_names )
208+
209+ project_object [ "optional-dependencies" ] &.each_value do |opt_deps |
210+ freeze_pep621_dep_array! ( opt_deps , excluded_names )
211+ end
212+ end
213+
214+ sig { params ( dep_array : T . nilable ( T ::Array [ String ] ) , excluded_names : T ::Array [ String ] ) . void }
215+ def freeze_pep621_dep_array! ( dep_array , excluded_names )
216+ return unless dep_array
217+
218+ dep_array . each_with_index do |entry , index |
219+ # Extract dependency name from PEP 508 string
220+ match = entry . match ( /\A ([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i )
221+ next unless match
222+
223+ dep_name = normalise ( T . must ( match [ 1 ] ) )
224+ next if excluded_names . include? ( dep_name )
225+
226+ locked_details = locked_details ( dep_name )
227+ next unless ( locked_version = locked_details &.fetch ( "version" ) )
228+
229+ name_pattern = self . class . send ( :pep508_name_pattern , T . must ( match [ 1 ] ) )
230+ dep_array [ index ] = self . class . send ( :pin_pep508_entry , entry , name_pattern , locked_version )
231+ end
232+ end
233+
125234 sig { params ( dep_name : String ) . returns ( T . nilable ( T ::Hash [ String , T . untyped ] ) ) }
126235 def locked_details ( dep_name )
127236 parsed_lockfile . fetch ( "package" )
0 commit comments