Skip to content

Commit 9cbf801

Browse files
bnbongclaude
andcommitted
[FIX] parse PEP 508 requirements in PoetryManager.generate_dependency_file
The previous implementation only handled pinned (`==`) versions; other pip specifiers (`>=`, `~=`, `<=`, `<`, `>`, `!=`, `===`) and environment markers (`; sys_platform != 'win32'`) were concatenated into the TOML key, producing invalid `pyproject.toml` that `poetry install` could not parse. Introduce `_parse_pip_requirement` to split name, extras, version specifier, and environment marker, and emit Poetry-compatible TOML — inline tables when extras or markers are present, bare version strings otherwise. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 10d6436 commit 9cbf801

2 files changed

Lines changed: 108 additions & 28 deletions

File tree

src/fastapi_fastkit/backend/package_managers/poetry_manager.py

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# @author bnbong bbbong9@gmail.com
55
# --------------------------------------------------------------------------
66
import subprocess
7-
from typing import List
7+
from typing import List, Tuple
88

99
from fastapi_fastkit.core.exceptions import BackendExceptions
1010
from fastapi_fastkit.utils.logging import debug_log, get_logger
@@ -19,6 +19,47 @@
1919

2020
logger = get_logger(__name__)
2121

22+
# PEP 440 version specifier operators, longest-match first so "===" beats "==".
23+
_PEP440_OPERATORS = ("===", "==", "!=", "<=", ">=", "~=", "<", ">")
24+
25+
26+
def _parse_pip_requirement(
27+
requirement: str,
28+
) -> Tuple[str, List[str], str, str]:
29+
"""Parse a pip/PEP 508 requirement string.
30+
31+
Returns ``(name, extras, version_spec, marker)`` where ``version_spec``
32+
includes the operator (e.g. ``">=1.2.3"``) or is empty when unspecified,
33+
and ``marker`` is the environment marker without the leading semicolon.
34+
"""
35+
req = requirement.strip()
36+
37+
marker = ""
38+
if ";" in req:
39+
req, marker = req.split(";", 1)
40+
req = req.strip()
41+
marker = marker.strip()
42+
43+
version_spec = ""
44+
split_at = len(req)
45+
for op in _PEP440_OPERATORS:
46+
idx = req.find(op)
47+
if idx != -1 and idx < split_at:
48+
split_at = idx
49+
if split_at < len(req):
50+
version_spec = req[split_at:].strip()
51+
req = req[:split_at].strip()
52+
53+
extras: List[str] = []
54+
if "[" in req and req.endswith("]"):
55+
bare_name, _, extras_part = req.partition("[")
56+
extras = [
57+
extra.strip() for extra in extras_part[:-1].split(",") if extra.strip()
58+
]
59+
req = bare_name.strip()
60+
61+
return req, extras, version_spec, marker
62+
2263

2364
class PoetryManager(BasePackageManager):
2465
"""Poetry package manager implementation."""
@@ -182,36 +223,33 @@ def generate_dependency_file(
182223
pyproject_path = self.get_dependency_file_path()
183224

184225
try:
185-
# Create dependencies section for Poetry
226+
# Create dependencies section for Poetry. Parse each requirement as
227+
# PEP 508 so non-``==`` specifiers (``>=``, ``~=``, ...), extras, and
228+
# environment markers all round-trip to valid TOML.
186229
deps_section = ""
187230
for dep in dependencies:
188-
# Split off version specifier (only == is converted to Poetry's pinned
189-
# form; other specifiers fall back to "*").
190-
if "==" in dep:
191-
name_part, version = dep.split("==", 1)
192-
version_str = version.strip()
231+
name_part, extras, version_spec, marker = _parse_pip_requirement(dep)
232+
if not name_part:
233+
continue
234+
235+
if not version_spec:
236+
version_str = "*"
237+
elif version_spec.startswith("=="):
238+
# Poetry treats a bare version as a pin; keep the old
239+
# formatting to avoid churn in generated files.
240+
version_str = version_spec[2:].strip()
193241
else:
194-
name_part, version_str = dep, "*"
195-
196-
name_part = name_part.strip()
197-
198-
# Extract optional extras, e.g. "redis[hiredis]" -> ("redis", ["hiredis"]).
199-
extras: list[str] = []
200-
if "[" in name_part and name_part.endswith("]"):
201-
bare_name, _, extras_part = name_part.partition("[")
202-
extras = [
203-
extra.strip()
204-
for extra in extras_part[:-1].split(",")
205-
if extra.strip()
206-
]
207-
name_part = bare_name.strip()
208-
209-
if extras:
210-
extras_str = ", ".join(f'"{extra}"' for extra in extras)
211-
deps_section += (
212-
f'{name_part} = {{version = "{version_str}", '
213-
f"extras = [{extras_str}]}}\n"
214-
)
242+
version_str = version_spec
243+
244+
if extras or marker:
245+
parts = [f'version = "{version_str}"']
246+
if extras:
247+
extras_str = ", ".join(f'"{extra}"' for extra in extras)
248+
parts.append(f"extras = [{extras_str}]")
249+
if marker:
250+
escaped_marker = marker.replace('"', '\\"')
251+
parts.append(f'markers = "{escaped_marker}"')
252+
deps_section += f"{name_part} = {{{', '.join(parts)}}}\n"
215253
else:
216254
deps_section += f'{name_part} = "{version_str}"\n'
217255

tests/test_backends/test_package_managers.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,48 @@ def test_generate_dependency_file_with_extras_is_valid_toml(self) -> None:
950950
assert deps_table["fastapi"] == "0.104.1"
951951
assert deps_table["uvicorn"] == "*"
952952

953+
def test_generate_dependency_file_with_pep508_specifiers(self) -> None:
954+
"""Non-``==`` pip specifiers and PEP 508 markers must round-trip to valid TOML.
955+
956+
Regression test for the Copilot review that flagged ``requests>=2.33``,
957+
``httpx~=0.27``, and ``uvloop; sys_platform != 'win32'`` producing
958+
invalid ``pyproject.toml`` because the whole string was emitted as a key.
959+
"""
960+
import tomllib
961+
962+
deps = [
963+
"requests>=2.33",
964+
"httpx~=0.27",
965+
"sqlalchemy>=2.0,<3.0",
966+
"uvloop; sys_platform != 'win32'",
967+
"redis[hiredis]>=4.0",
968+
]
969+
self.manager.generate_dependency_file(
970+
deps,
971+
project_name="test-project",
972+
author="Test Author",
973+
author_email="test@example.com",
974+
description="Test description",
975+
)
976+
977+
pyproject_file = Path(self.temp_dir) / "pyproject.toml"
978+
content = pyproject_file.read_text()
979+
980+
parsed = tomllib.loads(content)
981+
deps_table = parsed["tool"]["poetry"]["dependencies"]
982+
983+
assert deps_table["requests"] == ">=2.33"
984+
assert deps_table["httpx"] == "~=0.27"
985+
assert deps_table["sqlalchemy"] == ">=2.0,<3.0"
986+
assert deps_table["uvloop"] == {
987+
"version": "*",
988+
"markers": "sys_platform != 'win32'",
989+
}
990+
assert deps_table["redis"] == {
991+
"version": ">=4.0",
992+
"extras": ["hiredis"],
993+
}
994+
953995
@patch("subprocess.run")
954996
def test_add_dependency_success(self, mock_run: Mock) -> None:
955997
"""Test successful dependency addition with Poetry."""

0 commit comments

Comments
 (0)