Skip to content

Commit f1f9987

Browse files
committed
Create a new script to export python requirements for flatpak
1 parent 9235ea4 commit f1f9987

File tree

2 files changed

+189
-4
lines changed

2 files changed

+189
-4
lines changed

scripts/flatpak-poetry-generator.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ def get_module_sources(parsed_lockfile: dict, include_devel: bool = True) -> lis
6565
for section, packages in parsed_lockfile.items():
6666
if section == "package":
6767
for package in packages:
68+
category = package.get("category", "main")
6869
if (
69-
package.get("category") == "dev"
70+
category == "dev"
7071
and include_devel
7172
and not package["optional"]
72-
or package.get("category") == "main"
73+
or category == "main"
7374
and not package["optional"]
7475
):
7576
# Check for old metadata format (poetry version < 1.0.0b2)
@@ -116,11 +117,14 @@ def get_dep_names(parsed_lockfile: dict, include_devel: bool = True) -> list:
116117
for section, packages in parsed_lockfile.items():
117118
if section == "package":
118119
for package in packages:
120+
category = package.get(
121+
"category", "main"
122+
) # Default to 'main' if missing
119123
if (
120-
package.get("category") == "dev"
124+
category == "dev"
121125
and include_devel
122126
and not package["optional"]
123-
or package.get("category") == "main"
127+
or category == "main"
124128
and not package["optional"]
125129
):
126130
dep_names.append(package["name"])
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Generate python requirements for the flatpak from a requirements.txt file."""
2+
3+
#!/usr/bin/env python3
4+
import argparse
5+
import json
6+
import os
7+
import re
8+
import urllib.request
9+
10+
11+
def join_continued_lines(text):
12+
"""
13+
Replace backslash-newline sequences with a space so that
14+
multi-line requirements become one logical line.
15+
"""
16+
return re.sub(r"\\\s*\n\s*", " ", text)
17+
18+
19+
def parse_requirements(text):
20+
"""
21+
Parse a requirements.txt file into a list of package dictionaries.
22+
Each dictionary contains:
23+
- name: package name (as specified in the requirements file)
24+
- version: package version (as a string)
25+
- hashes: list of sha256 hash strings (without the "sha256:" prefix)
26+
"""
27+
packages = []
28+
for line in text.splitlines():
29+
line = line.strip()
30+
if not line or line.startswith("#"):
31+
continue
32+
33+
# Split the line at the first occurrence of "--hash="
34+
parts = line.split(" --hash=")
35+
main_part = parts[0].strip()
36+
# Remove any environment marker (everything after a ';')
37+
req_spec = main_part.split(";")[0].strip()
38+
39+
if "==" in req_spec:
40+
name, version = req_spec.split("==", 1)
41+
name = name.strip()
42+
version = version.strip()
43+
else:
44+
name = req_spec.strip()
45+
version = ""
46+
47+
# Process each hash token (if present) and remove the "sha256:" prefix.
48+
hashes = []
49+
for hash_part in parts[1:]:
50+
token = hash_part.split()[0].strip()
51+
if token.startswith("sha256:"):
52+
token = token[len("sha256:") :]
53+
hashes.append(token)
54+
55+
packages.append({"name": name, "version": version, "hashes": hashes})
56+
return packages
57+
58+
59+
def clean_package_name(name: str) -> str:
60+
"""
61+
Remove extras from a package name.
62+
E.g., "uvicorn[standard]" becomes "uvicorn".
63+
"""
64+
if "[" in name:
65+
return name.split("[")[0].strip()
66+
return name
67+
68+
69+
def get_pypi_source(name: str, version: str, hashes: list) -> tuple:
70+
"""
71+
Get the source information for a dependency by querying PyPI.
72+
73+
Args:
74+
name (str): The package name (may include extras).
75+
version (str): The package version.
76+
hashes (list): The list of sha256 hashes (without "sha256:" prefix)
77+
provided in the requirements file.
78+
79+
Returns:
80+
tuple: (url, sha256) where url is the download URL and sha256 is the
81+
matching hash.
82+
83+
Raises:
84+
Exception: if no matching release source is found.
85+
"""
86+
# Remove extras before querying PyPI
87+
cleaned_name = clean_package_name(name)
88+
pypi_url = f"https://pypi.org/pypi/{cleaned_name}/json"
89+
print(f"Extracting download url and hash for {name}, version {version}")
90+
91+
with urllib.request.urlopen(pypi_url) as response:
92+
body = json.loads(response.read().decode("utf-8"))
93+
releases = body.get("releases", {})
94+
if version not in releases:
95+
raise Exception(
96+
f"Version {version} not found for package {name} (cleaned as {cleaned_name})"
97+
)
98+
source_list = releases[version]
99+
# First, try to find a wheel (bdist_wheel) that supports py3.
100+
for source in source_list:
101+
if (
102+
source.get("packagetype") == "bdist_wheel"
103+
and "py3" in source.get("python_version", "")
104+
and source["digests"]["sha256"] in hashes
105+
):
106+
return source["url"], source["digests"]["sha256"]
107+
# Fall back to sdist if no suitable wheel is found.
108+
for source in source_list:
109+
if (
110+
source.get("packagetype") == "sdist"
111+
and "source" in source.get("python_version", "")
112+
and source["digests"]["sha256"] in hashes
113+
):
114+
return source["url"], source["digests"]["sha256"]
115+
116+
raise Exception(f"Failed to extract url and hash from {pypi_url}")
117+
118+
119+
def make_build_command(package_names):
120+
"""
121+
Build a pip install command using the package names from requirements.txt.
122+
Note: the command uses the names as given (including extras) so that
123+
pip can install the extra features if required.
124+
"""
125+
base = (
126+
'pip3 install --no-index --find-links="file://${PWD}" --prefix=${FLATPAK_DEST}'
127+
)
128+
return f'{base} {" ".join(package_names)}'
129+
130+
131+
def main():
132+
parser = argparse.ArgumentParser(
133+
description="Convert a requirements.txt file into a JSON file with PyPI sources."
134+
)
135+
parser.add_argument("requirements", help="Path to requirements.txt")
136+
parser.add_argument(
137+
"-o",
138+
"--output",
139+
default="python-requirements.json",
140+
help="Output JSON file name (default: python-requirements.json)",
141+
)
142+
args = parser.parse_args()
143+
144+
if not os.path.exists(args.requirements):
145+
print(f"Error: File {args.requirements} does not exist.")
146+
exit(1)
147+
148+
with open(args.requirements, "r") as f:
149+
raw_text = f.read()
150+
151+
joined_text = join_continued_lines(raw_text)
152+
parsed_packages = parse_requirements(joined_text)
153+
154+
# Build the sources list by querying PyPI for each package.
155+
sources = []
156+
for pkg in parsed_packages:
157+
try:
158+
url, sha256 = get_pypi_source(pkg["name"], pkg["version"], pkg["hashes"])
159+
sources.append({"type": "file", "url": url, "sha256": sha256})
160+
except Exception as e:
161+
print(f"Error processing {pkg['name']}=={pkg['version']}: {e}")
162+
exit(1)
163+
164+
# Use the original package names (including extras) for the build command.
165+
pkg_names = [pkg["name"] for pkg in parsed_packages]
166+
build_command = make_build_command(pkg_names)
167+
168+
output_json = {
169+
"name": "poetry-deps",
170+
"buildsystem": "simple",
171+
"build-commands": [build_command],
172+
"sources": sources,
173+
}
174+
175+
with open(args.output, "w") as outf:
176+
json.dump(output_json, outf, indent=4)
177+
print(f"JSON file written to {args.output}")
178+
179+
180+
if __name__ == "__main__":
181+
main()

0 commit comments

Comments
 (0)