From 0ac7f683d1ac146566c1d0c5a34b99752a7db520 Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Wed, 8 Apr 2026 15:19:06 +0000 Subject: [PATCH 1/8] test: add hybrid Poetry v2 pyproject.toml fixtures Add two fixture files for testing hybrid Poetry v2 projects: - pep621_hybrid_version_in_both.toml: dep in both project.dependencies and tool.poetry.dependencies with version specifiers in both - pep621_hybrid_enrichment_only.toml: dep in project.dependencies with enrichment-only entry (no version key) in tool.poetry.dependencies --- .../pep621_hybrid_enrichment_only.toml | 24 +++++++++++++++++++ .../pep621_hybrid_version_in_both.toml | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 python/spec/fixtures/pyproject_files/pep621_hybrid_enrichment_only.toml create mode 100644 python/spec/fixtures/pyproject_files/pep621_hybrid_version_in_both.toml diff --git a/python/spec/fixtures/pyproject_files/pep621_hybrid_enrichment_only.toml b/python/spec/fixtures/pyproject_files/pep621_hybrid_enrichment_only.toml new file mode 100644 index 00000000000..ee953c97572 --- /dev/null +++ b/python/spec/fixtures/pyproject_files/pep621_hybrid_enrichment_only.toml @@ -0,0 +1,24 @@ +[project] +name = "test" +version = "0.1.0" +description = "" +requires-python = ">=3.9" + +dependencies = [ + "requests>=2.13.0", +] + +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +requests = { source = "private-source" } + +[[tool.poetry.source]] +name = "private-source" +url = "https://private.example.com/simple" +priority = "supplemental" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/python/spec/fixtures/pyproject_files/pep621_hybrid_version_in_both.toml b/python/spec/fixtures/pyproject_files/pep621_hybrid_version_in_both.toml new file mode 100644 index 00000000000..a18345b0199 --- /dev/null +++ b/python/spec/fixtures/pyproject_files/pep621_hybrid_version_in_both.toml @@ -0,0 +1,24 @@ +[project] +name = "test" +version = "0.1.0" +description = "" +requires-python = ">=3.9" + +dependencies = [ + "requests>=2.13.0", +] + +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +requests = { version = "^2.13", source = "private-source" } + +[[tool.poetry.source]] +name = "private-source" +url = "https://private.example.com/simple" +priority = "supplemental" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" From cc4088ba999e492f358c1247b038071aca7918d7 Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Wed, 8 Apr 2026 15:19:16 +0000 Subject: [PATCH 2/8] feat: handle hybrid Poetry v2 dependency updates When a dependency exists in both project.dependencies (PEP 621) and tool.poetry.dependencies, Dependabot must update both consistently. Changes: - Add include? guard in replace_dep so the Poetry declaration_regex falls through to the PEP 621 regex when the matched line doesn't contain the old requirement string - Add freeze_pep621_deps! class method to PyprojectPreparer to pin PEP 621 deps during lockfile regeneration - Add freeze_pep621_top_level_deps! to freeze non-target PEP 621 deps to their locked versions in freeze_top_level_dependencies_except - Call freeze_pep621_deps! from freeze_dependencies_being_updated Closes github/dependabot-updates#12980 --- .../file_updater/poetry_file_updater.rb | 26 ++++--- .../python/file_updater/pyproject_preparer.rb | 78 +++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/python/lib/dependabot/python/file_updater/poetry_file_updater.rb b/python/lib/dependabot/python/file_updater/poetry_file_updater.rb index d8861b796dd..38fb888880b 100644 --- a/python/lib/dependabot/python/file_updater/poetry_file_updater.rb +++ b/python/lib/dependabot/python/file_updater/poetry_file_updater.rb @@ -124,8 +124,10 @@ def replace_dep(dep, content, new_r, old_r) declaration_match = content.match(declaration_regex) if declaration_match declaration = declaration_match[:declaration] - new_declaration = T.must(declaration).sub(old_req, new_req) - return content.sub(T.must(declaration), new_declaration) + if T.must(declaration).include?(old_req) + new_declaration = T.must(declaration).sub(old_req, new_req) + return content.sub(T.must(declaration), new_declaration) + end end # Try Poetry table format @@ -220,15 +222,13 @@ def prepared_pyproject sig { params(pyproject_content: String).returns(String) } def freeze_other_dependencies(pyproject_content) - PyprojectPreparer - .new(pyproject_content: pyproject_content, lockfile: lockfile) - .freeze_top_level_dependencies_except(dependencies) + PyprojectPreparer.new(pyproject_content: pyproject_content, lockfile: lockfile) + .freeze_top_level_dependencies_except(dependencies) end sig { params(pyproject_content: String).returns(String) } def freeze_dependencies_being_updated(pyproject_content) pyproject_object = TomlRB.parse(pyproject_content) - poetry_object = pyproject_object.dig("tool", "poetry") if poetry_object @@ -244,14 +244,18 @@ def freeze_dependencies_being_updated(pyproject_content) end end + # Freeze PEP 621 project.dependencies and project.optional-dependencies + PyprojectPreparer.freeze_pep621_deps!(pyproject_object, dependencies) do |dep| + !git_dependency_being_updated?(dep) && dep.version + end + TomlRB.dump(pyproject_object) end sig { params(pyproject_content: String).returns(String) } def update_python_requirement(pyproject_content) - PyprojectPreparer - .new(pyproject_content: pyproject_content) - .update_python_requirement(language_version_manager.python_version) + PyprojectPreparer.new(pyproject_content: pyproject_content) + .update_python_requirement(language_version_manager.python_version) end sig { params(poetry_object: T::Hash[String, T.untyped], dep: Dependabot::Dependency).returns(T::Array[String]) } @@ -284,9 +288,7 @@ def git_dependency_being_updated?(dep) sig { params(pyproject_content: String).returns(String) } def sanitize(pyproject_content) - PyprojectPreparer - .new(pyproject_content: pyproject_content) - .sanitize + PyprojectPreparer.new(pyproject_content: pyproject_content).sanitize end sig { params(pyproject_content: String).returns(String) } diff --git a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb index 126eca676d2..a37d1d9f7b4 100644 --- a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb +++ b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb @@ -16,6 +16,46 @@ class FileUpdater class PyprojectPreparer extend T::Sig + # Freezes PEP 621 dependencies in-place within a parsed pyproject object. + # Replaces version specifiers with ==dep.version for each matching dep. + # Accepts an optional block to filter which dependencies to freeze. + sig do + params( + pyproject_object: T::Hash[String, T.untyped], + deps: T::Array[Dependabot::Dependency] + ).void + end + def self.freeze_pep621_deps!(pyproject_object, deps) + project_object = pyproject_object["project"] + return unless project_object + + dep_arrays = [project_object["dependencies"]] + project_object["optional-dependencies"]&.each_value { |opt| dep_arrays << opt } + dep_arrays.compact! + + deps.each do |dep| + next if block_given? && !yield(dep) + next unless dep.version + + pin_pep621_dep_in_arrays!(dep_arrays, dep) + end + end + + sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void } + def self.pin_pep621_dep_in_arrays!(dep_arrays, dep) + name_pattern = Regexp.escape(dep.name).gsub("\\-", "[-_.]") + dep_arrays.each do |arr| + arr.each_with_index do |entry, i| + next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*[><=!~;]/i) + + arr[i] = entry.sub( + /(?
#{name_pattern}(?:\[.*?\])?)\s*(?:[><=!~][^;]*)/i,
+                "\\k
==#{dep.version}"
+              )
+            end
+          end
+        end
+
         sig { params(pyproject_content: String, lockfile: T.nilable(Dependabot::DependencyFile)).void }
         def initialize(pyproject_content:, lockfile: nil)
           @pyproject_content = pyproject_content
@@ -109,6 +149,9 @@ def freeze_top_level_dependencies_except(dependencies)
             end
           end
 
+          # Freeze PEP 621 project.dependencies and project.optional-dependencies
+          freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
+
           TomlRB.dump(pyproject_object)
         end
         # rubocop:enable Metrics/AbcSize
@@ -122,6 +165,41 @@ def freeze_top_level_dependencies_except(dependencies)
         sig { returns(T.nilable(Dependabot::DependencyFile)) }
         attr_reader :lockfile
 
+        sig { params(pyproject_object: T::Hash[String, T.untyped], excluded_names: T::Array[String]).void }
+        def freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
+          project_object = pyproject_object["project"]
+          return unless project_object
+
+          freeze_pep621_dep_array!(project_object["dependencies"], excluded_names)
+
+          project_object["optional-dependencies"]&.each_value do |opt_deps|
+            freeze_pep621_dep_array!(opt_deps, excluded_names)
+          end
+        end
+
+        sig { params(dep_array: T.nilable(T::Array[String]), excluded_names: T::Array[String]).void }
+        def freeze_pep621_dep_array!(dep_array, excluded_names)
+          return unless dep_array
+
+          dep_array.each_with_index do |entry, index|
+            # Extract dependency name from PEP 508 string
+            match = entry.match(/\A([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i)
+            next unless match
+
+            dep_name = normalise(match[1])
+            next if excluded_names.include?(dep_name)
+
+            locked_details = locked_details(dep_name)
+            next unless (locked_version = locked_details&.fetch("version"))
+
+            # Replace version specifier with pinned version, preserving markers
+            dep_array[index] = entry.sub(
+              /(?[a-zA-Z0-9][a-zA-Z0-9._-]*(?:\[.*?\])?)\s*(?[><=!~][^;]*)/i,
+              "\\k==#{locked_version}"
+            )
+          end
+        end
+
         sig { params(dep_name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
         def locked_details(dep_name)
           parsed_lockfile.fetch("package")

From 3227c5f2560b7e91d079466911fb6c93157e4edb Mon Sep 17 00:00:00 2001
From: Mark Allen 
Date: Wed, 8 Apr 2026 15:19:26 +0000
Subject: [PATCH 3/8] test: add hybrid Poetry v2 project tests

Add tests for hybrid projects in poetry_file_updater_spec.rb:
- Version in both sections: both project.dependencies and
  tool.poetry.dependencies are updated consistently
- Enrichment-only: only project.dependencies is updated,
  tool.poetry.dependencies enrichment entry is preserved

Add tests for PEP 621 freeze in pyproject_preparer_spec.rb:
- Freezes PEP 621 deps to locked versions
- Freezes tool.poetry.dependencies alongside PEP 621
- Respects excluded dependencies
---
 .../file_updater/poetry_file_updater_spec.rb  | 109 ++++++++++++++++++
 .../file_updater/pyproject_preparer_spec.rb   |  42 +++++++
 2 files changed, 151 insertions(+)

diff --git a/python/spec/dependabot/python/file_updater/poetry_file_updater_spec.rb b/python/spec/dependabot/python/file_updater/poetry_file_updater_spec.rb
index 6f478575a2e..f8ce4eee66b 100644
--- a/python/spec/dependabot/python/file_updater/poetry_file_updater_spec.rb
+++ b/python/spec/dependabot/python/file_updater/poetry_file_updater_spec.rb
@@ -848,6 +848,115 @@
     end
   end
 
+  describe "hybrid Poetry v2 projects" do
+    let(:dependency_files) { [pyproject] }
+
+    context "when dependency has version in both project.dependencies and tool.poetry.dependencies" do
+      let(:pyproject_fixture_name) { "pep621_hybrid_version_in_both.toml" }
+      let(:dependency) do
+        Dependabot::Dependency.new(
+          name: "requests",
+          version: "2.19.1",
+          previous_version: "2.13.0",
+          package_manager: "pip",
+          requirements: [
+            {
+              requirement: ">=2.19.1",
+              file: "pyproject.toml",
+              source: nil,
+              groups: ["dependencies"]
+            },
+            {
+              requirement: "^2.19",
+              file: "pyproject.toml",
+              source: nil,
+              groups: ["dependencies"]
+            }
+          ],
+          previous_requirements: [
+            {
+              requirement: ">=2.13.0",
+              file: "pyproject.toml",
+              source: nil,
+              groups: ["dependencies"]
+            },
+            {
+              requirement: "^2.13",
+              file: "pyproject.toml",
+              source: nil,
+              groups: ["dependencies"]
+            }
+          ]
+        )
+      end
+
+      describe "#updated_dependency_files" do
+        subject(:updated_files) { updater.updated_dependency_files }
+
+        it "updates the version in project.dependencies" do
+          updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
+          expect(updated_pyproject.content).to include('"requests>=2.19.1"')
+          expect(updated_pyproject.content).not_to include('"requests>=2.13.0"')
+        end
+
+        it "updates the version in tool.poetry.dependencies" do
+          updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
+          parsed = TomlRB.parse(updated_pyproject.content)
+          poetry_req = parsed.dig("tool", "poetry", "dependencies", "requests")
+          expect(poetry_req["version"]).to eq("^2.19")
+        end
+
+        it "preserves enrichment metadata in tool.poetry.dependencies" do
+          updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
+          parsed = TomlRB.parse(updated_pyproject.content)
+          poetry_req = parsed.dig("tool", "poetry", "dependencies", "requests")
+          expect(poetry_req["source"]).to eq("private-source")
+        end
+      end
+    end
+
+    context "when tool.poetry.dependencies has enrichment only (no version key)" do
+      let(:pyproject_fixture_name) { "pep621_hybrid_enrichment_only.toml" }
+      let(:dependency) do
+        Dependabot::Dependency.new(
+          name: "requests",
+          version: "2.19.1",
+          previous_version: "2.13.0",
+          package_manager: "pip",
+          requirements: [{
+            requirement: ">=2.19.1",
+            file: "pyproject.toml",
+            source: nil,
+            groups: ["dependencies"]
+          }],
+          previous_requirements: [{
+            requirement: ">=2.13.0",
+            file: "pyproject.toml",
+            source: nil,
+            groups: ["dependencies"]
+          }]
+        )
+      end
+
+      describe "#updated_dependency_files" do
+        subject(:updated_files) { updater.updated_dependency_files }
+
+        it "updates the version in project.dependencies" do
+          updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
+          expect(updated_pyproject.content).to include('"requests>=2.19.1"')
+          expect(updated_pyproject.content).not_to include('"requests>=2.13.0"')
+        end
+
+        it "leaves the enrichment-only entry in tool.poetry.dependencies unchanged" do
+          updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
+          parsed = TomlRB.parse(updated_pyproject.content)
+          poetry_req = parsed.dig("tool", "poetry", "dependencies", "requests")
+          expect(poetry_req).to eq({ "source" => "private-source" })
+        end
+      end
+    end
+  end
+
   describe "#prepared_project_file" do
     subject(:prepared_project) { updater.send(:prepared_pyproject) }
 
diff --git a/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb b/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
index 6751c0d0c1e..5a2c0a93d59 100644
--- a/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
+++ b/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
@@ -220,5 +220,47 @@
 
       it { is_expected.to include("subdirectory = \"python\"\n") }
     end
+
+    context "with PEP 621 project.dependencies" do
+      let(:dependencies) { [] }
+      let(:pyproject_fixture_name) { "pep621_hybrid_version_in_both.toml" }
+      let(:poetry_lock_fixture_name) { "caret_version.lock" }
+
+      it "freezes PEP 621 dependencies to their locked versions" do
+        result = freeze_top_level_dependencies_except
+        parsed = TomlRB.parse(result)
+        project_deps = parsed.dig("project", "dependencies")
+        requests_dep = project_deps.find { |d| d.start_with?("requests") }
+        expect(requests_dep).to eq("requests==1.2.3")
+      end
+
+      it "also freezes tool.poetry.dependencies" do
+        result = freeze_top_level_dependencies_except
+        parsed = TomlRB.parse(result)
+        poetry_req = parsed.dig("tool", "poetry", "dependencies", "requests")
+        expect(poetry_req["version"]).to eq("1.2.3")
+      end
+
+      context "when excluding a dependency" do
+        let(:dependencies) do
+          [
+            Dependabot::Dependency.new(
+              name: "requests",
+              version: "2.19.1",
+              package_manager: "pip",
+              requirements: []
+            )
+          ]
+        end
+
+        it "does not freeze the excluded PEP 621 dependency" do
+          result = freeze_top_level_dependencies_except
+          parsed = TomlRB.parse(result)
+          project_deps = parsed.dig("project", "dependencies")
+          requests_dep = project_deps.find { |d| d.start_with?("requests") }
+          expect(requests_dep).to eq("requests>=2.13.0")
+        end
+      end
+    end
   end
 end

From 5489a1da8f4c51686def73db26cac88d6edb6aa0 Mon Sep 17 00:00:00 2001
From: Mark Allen 
Date: Wed, 8 Apr 2026 15:37:22 +0000
Subject: [PATCH 4/8] Improve PEP 621 hybrid freeze: bare deps, markers,
 private helper

- Handle bare deps without version specifiers by appending ==version
- Preserve whitespace before environment markers using non-greedy regex
- Make pin_pep621_dep_in_arrays! a private class method
- Use consistent name-matching regex in freeze_pep621_dep_array!
- Remove unused named capture group and redundant && dep.version
- Add tests for environment markers and optional-dependencies
---
 .../file_updater/poetry_file_updater.rb       |  2 +-
 .../python/file_updater/pyproject_preparer.rb | 41 +++++++++++++------
 .../file_updater/pyproject_preparer_spec.rb   | 28 +++++++++++++
 .../pep621_hybrid_optional_deps.toml          | 27 ++++++++++++
 .../pep621_hybrid_with_markers.toml           | 24 +++++++++++
 5 files changed, 109 insertions(+), 13 deletions(-)
 create mode 100644 python/spec/fixtures/pyproject_files/pep621_hybrid_optional_deps.toml
 create mode 100644 python/spec/fixtures/pyproject_files/pep621_hybrid_with_markers.toml

diff --git a/python/lib/dependabot/python/file_updater/poetry_file_updater.rb b/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
index 38fb888880b..c3f16a0ee03 100644
--- a/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
+++ b/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
@@ -246,7 +246,7 @@ def freeze_dependencies_being_updated(pyproject_content)
 
           # Freeze PEP 621 project.dependencies and project.optional-dependencies
           PyprojectPreparer.freeze_pep621_deps!(pyproject_object, dependencies) do |dep|
-            !git_dependency_being_updated?(dep) && dep.version
+            !git_dependency_being_updated?(dep)
           end
 
           TomlRB.dump(pyproject_object)
diff --git a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
index a37d1d9f7b4..335795f2651 100644
--- a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
+++ b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
@@ -46,15 +46,23 @@ def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
           name_pattern = Regexp.escape(dep.name).gsub("\\-", "[-_.]")
           dep_arrays.each do |arr|
             arr.each_with_index do |entry, i|
-              next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*[><=!~;]/i)
-
-              arr[i] = entry.sub(
-                /(?
#{name_pattern}(?:\[.*?\])?)\s*(?:[><=!~][^;]*)/i,
-                "\\k
==#{dep.version}"
-              )
+              next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*(\z|[><=!~;,])/i)
+
+              arr[i] = if entry.match?(/\A#{name_pattern}(\[.*?\])?\s*[><=!~]/i)
+                         entry.sub(
+                           /(?
#{name_pattern}(?:\[.*?\])?)\s*[><=!~][^;]*?(?=\s*;|\s*\z)/i,
+                           "\\k
==#{dep.version}"
+                         )
+                       else
+                         entry.sub(
+                           /(?
#{name_pattern}(?:\[.*?\])?)(?\s*(?:;.*)?)/i,
+                           "\\k
==#{dep.version}\\k"
+                         )
+                       end
             end
           end
         end
+        private_class_method :pin_pep621_dep_in_arrays!
 
         sig { params(pyproject_content: String, lockfile: T.nilable(Dependabot::DependencyFile)).void }
         def initialize(pyproject_content:, lockfile: nil)
@@ -183,7 +191,7 @@ def freeze_pep621_dep_array!(dep_array, excluded_names)
 
           dep_array.each_with_index do |entry, index|
             # Extract dependency name from PEP 508 string
-            match = entry.match(/\A([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i)
+            match = entry.match(/\A([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i)
             next unless match
 
             dep_name = normalise(match[1])
@@ -192,11 +200,20 @@ def freeze_pep621_dep_array!(dep_array, excluded_names)
             locked_details = locked_details(dep_name)
             next unless (locked_version = locked_details&.fetch("version"))
 
-            # Replace version specifier with pinned version, preserving markers
-            dep_array[index] = entry.sub(
-              /(?[a-zA-Z0-9][a-zA-Z0-9._-]*(?:\[.*?\])?)\s*(?[><=!~][^;]*)/i,
-              "\\k==#{locked_version}"
-            )
+            # Build a name-specific pattern consistent with pin_pep621_dep_in_arrays!
+            name_pattern = Regexp.escape(match[1]).gsub("\\-", "[-_.]")
+
+            dep_array[index] = if entry.match?(/\A#{name_pattern}(?:\[.*?\])?\s*[><=!~]/i)
+                                 entry.sub(
+                                   /(?
#{name_pattern}(?:\[.*?\])?)\s*[><=!~][^;]*?(?=\s*;|\s*\z)/i,
+                                   "\\k
==#{locked_version}"
+                                 )
+                               else
+                                 entry.sub(
+                                   /(?
#{name_pattern}(?:\[.*?\])?)(?\s*(?:;.*)?)/i,
+                                   "\\k
==#{locked_version}\\k"
+                                 )
+                               end
           end
         end
 
diff --git a/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb b/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
index 5a2c0a93d59..3bd05f98637 100644
--- a/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
+++ b/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
@@ -262,5 +262,33 @@
         end
       end
     end
+
+    context "with PEP 621 dependencies containing environment markers" do
+      let(:dependencies) { [] }
+      let(:pyproject_fixture_name) { "pep621_hybrid_with_markers.toml" }
+      let(:poetry_lock_fixture_name) { "caret_version.lock" }
+
+      it "freezes the version while preserving markers" do
+        result = freeze_top_level_dependencies_except
+        parsed = TomlRB.parse(result)
+        project_deps = parsed.dig("project", "dependencies")
+        requests_dep = project_deps.find { |d| d.start_with?("requests") }
+        expect(requests_dep).to eq("requests==1.2.3 ; python_version >= '3.7'")
+      end
+    end
+
+    context "with PEP 621 project.optional-dependencies" do
+      let(:dependencies) { [] }
+      let(:pyproject_fixture_name) { "pep621_hybrid_optional_deps.toml" }
+      let(:poetry_lock_fixture_name) { "caret_version.lock" }
+
+      it "freezes optional dependencies to their locked versions" do
+        result = freeze_top_level_dependencies_except
+        parsed = TomlRB.parse(result)
+        opt_deps = parsed.dig("project", "optional-dependencies", "networking")
+        requests_dep = opt_deps.find { |d| d.start_with?("requests") }
+        expect(requests_dep).to eq("requests==1.2.3")
+      end
+    end
   end
 end
diff --git a/python/spec/fixtures/pyproject_files/pep621_hybrid_optional_deps.toml b/python/spec/fixtures/pyproject_files/pep621_hybrid_optional_deps.toml
new file mode 100644
index 00000000000..90f5534cda8
--- /dev/null
+++ b/python/spec/fixtures/pyproject_files/pep621_hybrid_optional_deps.toml
@@ -0,0 +1,27 @@
+[project]
+name = "test"
+version = "0.1.0"
+description = ""
+requires-python = ">=3.9"
+
+dependencies = []
+
+[project.optional-dependencies]
+networking = [
+    "requests>=2.13.0",
+]
+
+[tool.poetry]
+package-mode = false
+
+[tool.poetry.dependencies]
+requests = { version = "^2.13", source = "private-source" }
+
+[[tool.poetry.source]]
+name = "private-source"
+url = "https://private.example.com/simple"
+priority = "supplemental"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/python/spec/fixtures/pyproject_files/pep621_hybrid_with_markers.toml b/python/spec/fixtures/pyproject_files/pep621_hybrid_with_markers.toml
new file mode 100644
index 00000000000..e75d6b50e4c
--- /dev/null
+++ b/python/spec/fixtures/pyproject_files/pep621_hybrid_with_markers.toml
@@ -0,0 +1,24 @@
+[project]
+name = "test"
+version = "0.1.0"
+description = ""
+requires-python = ">=3.9"
+
+dependencies = [
+    "requests>=2.13.0 ; python_version >= '3.7'",
+]
+
+[tool.poetry]
+package-mode = false
+
+[tool.poetry.dependencies]
+requests = { version = "^2.13", source = "private-source" }
+
+[[tool.poetry.source]]
+name = "private-source"
+url = "https://private.example.com/simple"
+priority = "supplemental"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"

From 885db24886640750f4f4ec54c94237dc92235cb1 Mon Sep 17 00:00:00 2001
From: Mark Allen 
Date: Thu, 9 Apr 2026 10:31:47 +0000
Subject: [PATCH 5/8] Fix Sorbet type errors in PEP 621 freeze methods

- Add explicit block parameter to freeze_pep621_deps! sig and def
- Wrap match[1] in T.must for nilability checks in freeze_pep621_dep_array!
---
 .../dependabot/python/file_updater/pyproject_preparer.rb | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
index 335795f2651..50e9c1c5f2c 100644
--- a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
+++ b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
@@ -22,10 +22,11 @@ class PyprojectPreparer
         sig do
           params(
             pyproject_object: T::Hash[String, T.untyped],
-            deps: T::Array[Dependabot::Dependency]
+            deps: T::Array[Dependabot::Dependency],
+            blk: T.nilable(T.proc.params(dep: Dependabot::Dependency).returns(T::Boolean))
           ).void
         end
-        def self.freeze_pep621_deps!(pyproject_object, deps)
+        def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
           project_object = pyproject_object["project"]
           return unless project_object
 
@@ -194,14 +195,14 @@ def freeze_pep621_dep_array!(dep_array, excluded_names)
             match = entry.match(/\A([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i)
             next unless match
 
-            dep_name = normalise(match[1])
+            dep_name = normalise(T.must(match[1]))
             next if excluded_names.include?(dep_name)
 
             locked_details = locked_details(dep_name)
             next unless (locked_version = locked_details&.fetch("version"))
 
             # Build a name-specific pattern consistent with pin_pep621_dep_in_arrays!
-            name_pattern = Regexp.escape(match[1]).gsub("\\-", "[-_.]")
+            name_pattern = Regexp.escape(T.must(match[1])).gsub("\\-", "[-_.]")
 
             dep_array[index] = if entry.match?(/\A#{name_pattern}(?:\[.*?\])?\s*[><=!~]/i)
                                  entry.sub(

From 8ceaca7811d6beca98a3d8bfbb1a24a4c1efac49 Mon Sep 17 00:00:00 2001
From: Mark Allen 
Date: Thu, 9 Apr 2026 11:24:25 +0000
Subject: [PATCH 6/8] refactor: deduplicate PEP 621 pinning logic and fix name
 normalization

- Extract shared pep508_name_pattern and pin_pep508_entry helpers
- Handle underscores and dots in PEP 508 name matching (not just hyphens)
- Guard enrichment-only Poetry entries from version injection
- Restore original method chain formatting
---
 .../file_updater/poetry_file_updater.rb       | 18 ++++--
 .../python/file_updater/pyproject_preparer.rb | 56 ++++++++++---------
 2 files changed, 43 insertions(+), 31 deletions(-)

diff --git a/python/lib/dependabot/python/file_updater/poetry_file_updater.rb b/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
index c3f16a0ee03..81e73143c95 100644
--- a/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
+++ b/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
@@ -222,13 +222,15 @@ def prepared_pyproject
 
         sig { params(pyproject_content: String).returns(String) }
         def freeze_other_dependencies(pyproject_content)
-          PyprojectPreparer.new(pyproject_content: pyproject_content, lockfile: lockfile)
-                           .freeze_top_level_dependencies_except(dependencies)
+          PyprojectPreparer
+            .new(pyproject_content: pyproject_content, lockfile: lockfile)
+            .freeze_top_level_dependencies_except(dependencies)
         end
 
         sig { params(pyproject_content: String).returns(String) }
         def freeze_dependencies_being_updated(pyproject_content)
           pyproject_object = TomlRB.parse(pyproject_content)
+
           poetry_object = pyproject_object.dig("tool", "poetry")
 
           if poetry_object
@@ -254,8 +256,9 @@ def freeze_dependencies_being_updated(pyproject_content)
 
         sig { params(pyproject_content: String).returns(String) }
         def update_python_requirement(pyproject_content)
-          PyprojectPreparer.new(pyproject_content: pyproject_content)
-                           .update_python_requirement(language_version_manager.python_version)
+          PyprojectPreparer
+            .new(pyproject_content: pyproject_content)
+            .update_python_requirement(language_version_manager.python_version)
         end
 
         sig { params(poetry_object: T::Hash[String, T.untyped], dep: Dependabot::Dependency).returns(T::Array[String]) }
@@ -266,6 +269,9 @@ def lock_declaration_to_new_version!(poetry_object, dep)
             next unless pkg_name
 
             if poetry_object[type][pkg_name].is_a?(Hash)
+              # Skip enrichment-only entries that have no version key (e.g., source-only metadata)
+              next unless poetry_object[type][pkg_name].key?("version")
+
               poetry_object[type][pkg_name]["version"] = dep.version
             else
               poetry_object[type][pkg_name] = dep.version
@@ -288,7 +294,9 @@ def git_dependency_being_updated?(dep)
 
         sig { params(pyproject_content: String).returns(String) }
         def sanitize(pyproject_content)
-          PyprojectPreparer.new(pyproject_content: pyproject_content).sanitize
+          PyprojectPreparer
+            .new(pyproject_content: pyproject_content)
+            .sanitize
         end
 
         sig { params(pyproject_content: String).returns(String) }
diff --git a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
index 50e9c1c5f2c..228c61d0081 100644
--- a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
+++ b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
@@ -16,6 +16,32 @@ class FileUpdater
       class PyprojectPreparer
         extend T::Sig
 
+        # Builds a regex pattern that matches a PEP 508 package name,
+        # treating hyphens, underscores, and dots as interchangeable per PEP 508.
+        sig { params(name: String).returns(String) }
+        def self.pep508_name_pattern(name)
+          Regexp.escape(name).gsub("\\-", "[-_.]").gsub("_", "[-_.]").gsub("\\.", "[-_.]")
+        end
+        private_class_method :pep508_name_pattern
+
+        # Pins a single PEP 508 dependency entry string to a specific version,
+        # preserving extras and environment markers.
+        sig { params(entry: String, name_pattern: String, version: String).returns(String) }
+        def self.pin_pep508_entry(entry, name_pattern, version)
+          if entry.match?(/\A#{name_pattern}(\[.*?\])?\s*[><=!~]/i)
+            entry.sub(
+              /(?
#{name_pattern}(?:\[.*?\])?)\s*[><=!~][^;]*?(?=\s*;|\s*\z)/i,
+              "\\k
==#{version}"
+            )
+          else
+            entry.sub(
+              /(?
#{name_pattern}(?:\[.*?\])?)(?\s*(?:;.*)?)/i,
+              "\\k
==#{version}\\k"
+            )
+          end
+        end
+        private_class_method :pin_pep508_entry
+
         # Freezes PEP 621 dependencies in-place within a parsed pyproject object.
         # Replaces version specifiers with ==dep.version for each matching dep.
         # Accepts an optional block to filter which dependencies to freeze.
@@ -44,22 +70,12 @@ def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
 
         sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
         def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
-          name_pattern = Regexp.escape(dep.name).gsub("\\-", "[-_.]")
+          name_pattern = pep508_name_pattern(dep.name)
           dep_arrays.each do |arr|
             arr.each_with_index do |entry, i|
               next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*(\z|[><=!~;,])/i)
 
-              arr[i] = if entry.match?(/\A#{name_pattern}(\[.*?\])?\s*[><=!~]/i)
-                         entry.sub(
-                           /(?
#{name_pattern}(?:\[.*?\])?)\s*[><=!~][^;]*?(?=\s*;|\s*\z)/i,
-                           "\\k
==#{dep.version}"
-                         )
-                       else
-                         entry.sub(
-                           /(?
#{name_pattern}(?:\[.*?\])?)(?\s*(?:;.*)?)/i,
-                           "\\k
==#{dep.version}\\k"
-                         )
-                       end
+              arr[i] = pin_pep508_entry(entry, name_pattern, T.must(dep.version))
             end
           end
         end
@@ -201,20 +217,8 @@ def freeze_pep621_dep_array!(dep_array, excluded_names)
             locked_details = locked_details(dep_name)
             next unless (locked_version = locked_details&.fetch("version"))
 
-            # Build a name-specific pattern consistent with pin_pep621_dep_in_arrays!
-            name_pattern = Regexp.escape(T.must(match[1])).gsub("\\-", "[-_.]")
-
-            dep_array[index] = if entry.match?(/\A#{name_pattern}(?:\[.*?\])?\s*[><=!~]/i)
-                                 entry.sub(
-                                   /(?
#{name_pattern}(?:\[.*?\])?)\s*[><=!~][^;]*?(?=\s*;|\s*\z)/i,
-                                   "\\k
==#{locked_version}"
-                                 )
-                               else
-                                 entry.sub(
-                                   /(?
#{name_pattern}(?:\[.*?\])?)(?\s*(?:;.*)?)/i,
-                                   "\\k
==#{locked_version}\\k"
-                                 )
-                               end
+            name_pattern = self.class.send(:pep508_name_pattern, T.must(match[1]))
+            dep_array[index] = self.class.send(:pin_pep508_entry, entry, name_pattern, locked_version)
           end
         end
 

From d267906f262503763e197cd69e4526acc23c7e84 Mon Sep 17 00:00:00 2001
From: Mark Allen 
Date: Thu, 9 Apr 2026 11:45:43 +0000
Subject: [PATCH 7/8] fix: resolve RuboCop lint offenses

- Use explicit block variable check with yield to satisfy both
  Performance/BlockGivenWithExplicitBlock and RedundantBlockCall
- Compact delegation methods to reduce PoetryFileUpdater class length
  below Metrics/ClassLength limit (350)
---
 .../python/file_updater/poetry_file_updater.rb  | 17 ++++++-----------
 .../python/file_updater/pyproject_preparer.rb   |  2 +-
 2 files changed, 7 insertions(+), 12 deletions(-)

diff --git a/python/lib/dependabot/python/file_updater/poetry_file_updater.rb b/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
index 81e73143c95..00d0024c716 100644
--- a/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
+++ b/python/lib/dependabot/python/file_updater/poetry_file_updater.rb
@@ -222,9 +222,8 @@ def prepared_pyproject
 
         sig { params(pyproject_content: String).returns(String) }
         def freeze_other_dependencies(pyproject_content)
-          PyprojectPreparer
-            .new(pyproject_content: pyproject_content, lockfile: lockfile)
-            .freeze_top_level_dependencies_except(dependencies)
+          PyprojectPreparer.new(pyproject_content: pyproject_content, lockfile: lockfile)
+                           .freeze_top_level_dependencies_except(dependencies)
         end
 
         sig { params(pyproject_content: String).returns(String) }
@@ -256,9 +255,8 @@ def freeze_dependencies_being_updated(pyproject_content)
 
         sig { params(pyproject_content: String).returns(String) }
         def update_python_requirement(pyproject_content)
-          PyprojectPreparer
-            .new(pyproject_content: pyproject_content)
-            .update_python_requirement(language_version_manager.python_version)
+          PyprojectPreparer.new(pyproject_content: pyproject_content)
+                           .update_python_requirement(language_version_manager.python_version)
         end
 
         sig { params(poetry_object: T::Hash[String, T.untyped], dep: Dependabot::Dependency).returns(T::Array[String]) }
@@ -269,8 +267,7 @@ def lock_declaration_to_new_version!(poetry_object, dep)
             next unless pkg_name
 
             if poetry_object[type][pkg_name].is_a?(Hash)
-              # Skip enrichment-only entries that have no version key (e.g., source-only metadata)
-              next unless poetry_object[type][pkg_name].key?("version")
+              next unless poetry_object[type][pkg_name].key?("version") # skip enrichment-only entries
 
               poetry_object[type][pkg_name]["version"] = dep.version
             else
@@ -294,9 +291,7 @@ def git_dependency_being_updated?(dep)
 
         sig { params(pyproject_content: String).returns(String) }
         def sanitize(pyproject_content)
-          PyprojectPreparer
-            .new(pyproject_content: pyproject_content)
-            .sanitize
+          PyprojectPreparer.new(pyproject_content: pyproject_content).sanitize
         end
 
         sig { params(pyproject_content: String).returns(String) }
diff --git a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
index 228c61d0081..909fb25ad61 100644
--- a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
+++ b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
@@ -61,7 +61,7 @@ def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
           dep_arrays.compact!
 
           deps.each do |dep|
-            next if block_given? && !yield(dep)
+            next if blk && !yield(dep)
             next unless dep.version
 
             pin_pep621_dep_in_arrays!(dep_arrays, dep)

From 47d6395fdeabc831bc913b64b7b7a69e69832cc4 Mon Sep 17 00:00:00 2001
From: Mark Allen 
Date: Thu, 9 Apr 2026 12:43:51 +0000
Subject: [PATCH 8/8] Address review feedback: validate dep_arrays, add
 optional-deps exclusion test

- Extract collect_pep621_dep_arrays to validate that dep_arrays entries
  guarding against unexpected TOML values at runtime.
- Add test coverage for excluded dependencies within
  project.optional-dependencies.
---
 .../python/file_updater/pyproject_preparer.rb | 21 +++++++++++++------
 .../file_updater/pyproject_preparer_spec.rb   | 21 +++++++++++++++++++
 2 files changed, 36 insertions(+), 6 deletions(-)

diff --git a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
index 909fb25ad61..ded3c6d70c5 100644
--- a/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
+++ b/python/lib/dependabot/python/file_updater/pyproject_preparer.rb
@@ -53,12 +53,8 @@ def self.pin_pep508_entry(entry, name_pattern, version)
           ).void
         end
         def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
-          project_object = pyproject_object["project"]
-          return unless project_object
-
-          dep_arrays = [project_object["dependencies"]]
-          project_object["optional-dependencies"]&.each_value { |opt| dep_arrays << opt }
-          dep_arrays.compact!
+          dep_arrays = collect_pep621_dep_arrays(pyproject_object)
+          return if dep_arrays.empty?
 
           deps.each do |dep|
             next if blk && !yield(dep)
@@ -68,6 +64,19 @@ def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
           end
         end
 
+        sig { params(pyproject_object: T::Hash[String, T.untyped]).returns(T::Array[T::Array[String]]) }
+        def self.collect_pep621_dep_arrays(pyproject_object)
+          project_object = pyproject_object["project"]
+          return [] unless project_object
+
+          dep_arrays = [project_object["dependencies"]]
+          project_object["optional-dependencies"]&.each_value { |opt| dep_arrays << opt }
+          dep_arrays.compact!
+          dep_arrays.select! { |arr| arr.is_a?(Array) && arr.all?(String) }
+          dep_arrays
+        end
+        private_class_method :collect_pep621_dep_arrays
+
         sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
         def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
           name_pattern = pep508_name_pattern(dep.name)
diff --git a/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb b/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
index 3bd05f98637..d2fc7056890 100644
--- a/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
+++ b/python/spec/dependabot/python/file_updater/pyproject_preparer_spec.rb
@@ -289,6 +289,27 @@
         requests_dep = opt_deps.find { |d| d.start_with?("requests") }
         expect(requests_dep).to eq("requests==1.2.3")
       end
+
+      context "when excluding a dependency" do
+        let(:dependencies) do
+          [
+            Dependabot::Dependency.new(
+              name: "requests",
+              version: "2.19.1",
+              package_manager: "pip",
+              requirements: []
+            )
+          ]
+        end
+
+        it "does not freeze the excluded optional dependency" do
+          result = freeze_top_level_dependencies_except
+          parsed = TomlRB.parse(result)
+          opt_deps = parsed.dig("project", "optional-dependencies", "networking")
+          requests_dep = opt_deps.find { |d| d.start_with?("requests") }
+          expect(requests_dep).to eq("requests>=2.13.0")
+        end
+      end
     end
   end
 end