Skip to content

Commit cc4c17c

Browse files
authored
Add python specific logic (#200)
1 parent d37fefb commit cc4c17c

File tree

5 files changed

+288
-1
lines changed

5 files changed

+288
-1
lines changed

BUILD

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ py_library(
1515
"src/common/*.py",
1616
"src/crawl/*.py",
1717
"src/generate/*.py",
18-
"src/generate/impl/*.py"]),
18+
"src/generate/impl/*.py",
19+
"src/generate/impl/py/*.py"]),
1920
data = ["src/config/pom_template.xml"],
2021
visibility = ["//misc:__pkg__",],
2122
)
@@ -230,6 +231,15 @@ py_test(
230231
python_version = python_version,
231232
)
232233

234+
py_test(
235+
name = "requirementsparsertest",
236+
srcs = ["tests/generate/impl/py/requirementsparsertest.py"],
237+
deps = [":pomgen_lib"],
238+
imports = ["src"],
239+
size = "small",
240+
python_version = python_version,
241+
)
242+
233243
py_test(
234244
name = "workspacetest",
235245
srcs = ["tests/workspacetest.py"],

src/generate/impl/py/dependency.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
class Dependency:
2+
"""
3+
Represents a Python package dependency.
4+
"""
5+
6+
def __init__(self, name, version, extras=None):
7+
self.name = name
8+
self.version = version
9+
self.extras = tuple(extras) if extras else ()
10+
11+
self.child_dependencies = []
12+
13+
def to_pyproject_format(self):
14+
"""Convert the dependency to pyproject.toml format."""
15+
if self.extras:
16+
# Format as TOML table with extras
17+
extras_str = ', '.join(f'"{extra}"' for extra in self.extras)
18+
return f'{{ version = "{self.version}", extras = [{extras_str}] }}'
19+
else:
20+
# Simple version string for packages without extras
21+
return f'"{self.version}"'
22+
23+
def __eq__(self, other):
24+
if not isinstance(other, Dependency):
25+
return False
26+
return (self.name == other.name and
27+
self.version == other.version and
28+
self.extras == other.extras)
29+
30+
def __hash__(self):
31+
return hash((self.name, self.version, self.extras))
32+
33+
def __str__(self):
34+
extras_str = f"[{','.join(self.extras)}]" if self.extras else ""
35+
return f"{self.name}{extras_str}{self.version}"
36+
37+
def __repr__(self):
38+
extras_str = f"[{','.join(self.extras)}]" if self.extras else ""
39+
return f"Dependency(name='{self.name}{extras_str}', version='{self.version}')"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from .dependency import Dependency
2+
3+
4+
class RequirementsParser:
5+
"""
6+
Requirements lock file parser.
7+
"""
8+
def parse_requirements_lock_file(self, content):
9+
"""
10+
Parse requirements lock file content into a list of Dependency
11+
instances. Ignores hash information and comments.
12+
13+
Args:
14+
content: Content of a requirements lock file
15+
16+
Returns:
17+
List of Dependency instances representing top-level dependencies
18+
"""
19+
dependencies, name_to_dependency, name_to_vias = self._parse_dependencies_and_vias(content)
20+
self._attach_dependencies(name_to_dependency, name_to_vias)
21+
return dependencies
22+
23+
def _parse_dependencies_and_vias(self, content):
24+
# return values:
25+
dependencies = [] # list of Dependeny instances to preserve order
26+
name_to_dependency = {} # dict of dependency name -> Dependency inst
27+
name_to_vias = {} # dict of dependency name -> list of via values
28+
29+
current_vias = None
30+
for line in content.splitlines():
31+
line = line.strip()
32+
if len(line) == 0:
33+
pass
34+
elif line.startswith("--"):
35+
pass
36+
elif line.startswith("#"):
37+
line = line[1:].strip()
38+
if line.startswith("via"):
39+
assert current_vias is None
40+
current_vias = []
41+
line = line[3:].strip()
42+
if len(line) > 0:
43+
if line.startswith("-r tools"):
44+
# via -r tools/pip/requirements.in
45+
# directly referenced in requirements, no via
46+
pass
47+
else:
48+
current_vias.append(line)
49+
else:
50+
if current_vias is None:
51+
pass
52+
else:
53+
current_vias.append(line)
54+
else:
55+
version_sep = "==" # we should support other comparison binops?
56+
version_sep_index = line.index(version_sep)
57+
name = line[0:version_sep_index]
58+
extras = ()
59+
if name.endswith("]"):
60+
extras_start_index = name.index("[")
61+
extras = name[extras_start_index+1:-1].split(",")
62+
name = name[:extras_start_index]
63+
space_index = line.find(" ", version_sep_index)
64+
version = line[version_sep_index + len(version_sep):space_index]
65+
dependency = Dependency(name, version, extras)
66+
dependencies.append(dependency)
67+
assert name not in name_to_dependency
68+
name_to_dependency[name] = dependency
69+
assert name not in name_to_vias
70+
# current_vias is None if there is no # via comment at all
71+
name_to_vias[name] = () if current_vias is None else current_vias
72+
current_vias = None
73+
return dependencies, name_to_dependency, name_to_vias
74+
75+
def _attach_dependencies(self, name_to_dependency, name_to_vias):
76+
"""Attach dependencies based on via relationships."""
77+
for name, vias in name_to_vias.items():
78+
child_dependency = name_to_dependency[name]
79+
for via in vias:
80+
parent_dependency = name_to_dependency[via]
81+
parent_dependency.child_dependencies.append(child_dependency)
82+
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import generate
2+
3+
4+
class PyGenerationStrategy(generate.AbstractGenerationStrategy):
5+
"""
6+
Strategy for generating Python package metadat as pyproject.toml.
7+
8+
[build-system]
9+
requires = ["setuptools>=42", "wheel"]
10+
build-backend = "setuptools.build_meta"
11+
12+
[project]
13+
name = "hello-world"
14+
version = "0.1.0"
15+
description = "A sample Python project"
16+
readme = "README.md"
17+
requires-python = ">=3.8"
18+
license = {text = "Apache-2.0"}
19+
authors = [
20+
{name = "Your Name", email = "[email protected]"}
21+
]
22+
classifiers = [
23+
"Development Status :: 4 - Beta",
24+
"Intended Audience :: Developers",
25+
"Programming Language :: Python :: 3",
26+
"Programming Language :: Python :: 3.8",
27+
"Programming Language :: Python :: 3.9",
28+
"Programming Language :: Python :: 3.10",
29+
"Programming Language :: Python :: 3.11",
30+
]
31+
32+
dependencies = [
33+
"requests>=2.28.0",
34+
"pytest>=7.0.0",
35+
"black>=22.0.0",
36+
]
37+
"""
38+
39+
def __init__(self, workspace, template):
40+
assert workspace is not None
41+
assert template is not None
42+
self.workspace = workspace
43+
self.template = template
44+
45+
def load_dependency(self, label, artifact_def):
46+
"""
47+
Load a dependency for a Python package.
48+
49+
Args:
50+
label: The label for the dependency
51+
artifact_def: The artifact definition for source dependencies
52+
53+
Returns:
54+
A dependency instance appropriate for Python packages
55+
"""
56+
pass
57+
58+
def load_transitive_closure(self, dependency):
59+
pass
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import unittest
2+
from generate.impl.py.requirementsparser import RequirementsParser
3+
4+
CONTENT = """
5+
#
6+
# This file is autogenerated by pip-compile with Python 3.11
7+
# by the following command:
8+
#
9+
# bazel run //:requirements.update
10+
#
11+
--extra-index-url https://proxy.net/nexus/repository/pypi/
12+
--trusted-host proxy.net
13+
14+
# via pydantic
15+
# httpx
16+
ansi==0.3.7 \
17+
--hash=sha256:7e59108922259e03c54e4d93fc611bba0e756513086849708b86b6c80f8d4cd4 \
18+
--hash=sha256:bdd9e3c2dc3e4c8df8c2b745ca6f07f715aa4edee5ed4a5bcb29065da06a3f71
19+
# via cffi
20+
pydantic==2.10.5 \
21+
--hash=sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff \
22+
--hash=sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53
23+
# via
24+
# httpx
25+
# llama-cloud
26+
cffi==1.17.1 \
27+
--hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8
28+
--hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
29+
# via uvicorn
30+
httpx==0.27.2 \
31+
--hash=sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0 \
32+
--hash=sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2
33+
uvicorn[standard]==0.34.0 \
34+
--hash=sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4 \
35+
--hash=sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9
36+
# via -r tools/pip/requirements.in
37+
uvloop==0.21.0 \
38+
--hash=sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0
39+
llama-cloud==0.1.15 \
40+
--hash=sha256:3ec98422072ee4290c1e7597bc33889af3f03edb2a50ab09c15d5586d9f8635c \
41+
--hash=sha256:8e7376346d580b244a87d023eb7493506f06b53c898fb2a2e712b9fe7eb5b310
42+
"""
43+
44+
class RequirementsParserTest(unittest.TestCase):
45+
46+
def setUp(self):
47+
self.parser = RequirementsParser()
48+
49+
def test_parse_requirements_lock_file(self):
50+
dependencies = self.parser.parse_requirements_lock_file(CONTENT)
51+
52+
self.assertEqual(7, len(dependencies))
53+
ansi = dependencies[0]
54+
self.assertEqual("ansi", ansi.name)
55+
self.assertEqual("0.3.7", ansi.version)
56+
self.assertEqual(0, len(ansi.extras))
57+
pydantic = dependencies[1]
58+
self.assertEqual("pydantic", pydantic.name)
59+
self.assertEqual("2.10.5", pydantic.version)
60+
self.assertEqual(0, len(pydantic.extras))
61+
cffi = dependencies[2]
62+
self.assertEqual("cffi", cffi.name)
63+
self.assertEqual("1.17.1", cffi.version)
64+
self.assertEqual(0, len(cffi.extras))
65+
httpx = dependencies[3]
66+
self.assertEqual("httpx", httpx.name)
67+
self.assertEqual("0.27.2", httpx.version)
68+
self.assertEqual(0, len(httpx.extras))
69+
uvicorn = dependencies[4]
70+
self.assertEqual("uvicorn", uvicorn.name)
71+
self.assertEqual("0.34.0", uvicorn.version)
72+
self.assertEqual("standard", uvicorn.extras[0])
73+
uvloop = dependencies[5]
74+
self.assertEqual("uvloop", uvloop.name)
75+
self.assertEqual("0.21.0", uvloop.version)
76+
self.assertEqual(0, len(uvloop.extras))
77+
llama_cloud = dependencies[6]
78+
self.assertEqual("llama-cloud", llama_cloud.name)
79+
self.assertEqual("0.1.15", llama_cloud.version)
80+
self.assertEqual(0, len(llama_cloud.extras))
81+
82+
self.assertEqual(1, len(pydantic.child_dependencies))
83+
self.assertIs(pydantic.child_dependencies[0], ansi)
84+
self.assertEqual(1, len(cffi.child_dependencies))
85+
self.assertIs(cffi.child_dependencies[0], pydantic)
86+
self.assertEqual(2, len(httpx.child_dependencies))
87+
self.assertIs(httpx.child_dependencies[0], ansi)
88+
self.assertIs(httpx.child_dependencies[1], cffi)
89+
self.assertEqual(1, len(llama_cloud.child_dependencies))
90+
self.assertIs(llama_cloud.child_dependencies[0], cffi)
91+
self.assertEqual(1, len(uvicorn.child_dependencies))
92+
self.assertIs(uvicorn.child_dependencies[0], httpx)
93+
self.assertEqual(0, len(uvloop.child_dependencies))
94+
95+
96+
if __name__ == '__main__':
97+
unittest.main()

0 commit comments

Comments
 (0)