33Use Docker container to run nix-build.
44Using Docker approach to ensure a consistent and isolated build environment.
55
6+ Requirement: `toml` and `requests` Python packages and Docker installed.
7+
8+ pip install toml requests
9+
610To run the script:
711
812 python build_nix_docker.py
913
14+ or
15+
16+ python build_nix_docker.py --generate
17+
18+ The --generate flag is optional and can be used to generate the
19+ default.nix file if needed.
20+
1021This script will run nix-build and place the built results in the
1122dist/nix/ directory, it will then run nix-collect-garbage for cleanup.
1223"""
1324
25+ import argparse
1426import os
27+ import requests
1528import shutil
1629import subprocess
1730import sys
1831from pathlib import Path
1932
2033
34+ def read_pyproject_toml ():
35+ """
36+ Read the pyproject.toml file to extract project metadata.
37+ """
38+ import toml
39+
40+ pyproject_path = Path ('pyproject.toml' )
41+ if not pyproject_path .exists ():
42+ print ("Error: pyproject.toml not found in current directory" , file = sys .stderr )
43+ sys .exit (1 )
44+
45+ with pyproject_path .open ('r' ) as f :
46+ pyproject_data = toml .load (f )
47+
48+ return pyproject_data
49+
50+ def extract_project_meta (pyproject_data ):
51+ """
52+ Extract project metadata from pyproject.toml data.
53+ """
54+ project_data = pyproject_data ['project' ]
55+ name = project_data .get ('name' )
56+ version = project_data .get ('version' )
57+ description = project_data .get ('description' )
58+ authors = project_data .get ('authors' )
59+ author_names = [author .get ('name' , '' ) for author in authors if 'name' in author ]
60+ author_str = ', ' .join (author_names )
61+
62+ meta_dict = {
63+ 'name' : name ,
64+ 'version' : version ,
65+ 'description' : description ,
66+ 'author' : author_str
67+ }
68+
69+ return meta_dict
70+
71+
72+ def extract_project_dependencies (pyproject_data ):
73+ """
74+ Extract project dependencies from pyproject.toml data.
75+ """
76+ project_data = pyproject_data ['project' ]
77+ dependencies = project_data .get ('dependencies' , [])
78+ optional_dependencies = project_data .get ('optional-dependencies' , {})
79+ dev_optional_deps = optional_dependencies .get ('dev' , [])
80+ all_dep = dependencies + dev_optional_deps
81+ dependencies_list = []
82+
83+ for dep in all_dep :
84+ name_version = dep .split ('==' )
85+ name = name_version [0 ]
86+ version = name_version [1 ]
87+ tmp_dict = {}
88+ tmp_dict ['name' ] = name
89+ tmp_dict ['version' ] = version
90+ dependencies_list .append (tmp_dict )
91+
92+ assert len (all_dep ) == len (dependencies_list ), "Dependency extraction mismatch"
93+ return dependencies_list
94+
95+
96+ def create_defualt_nix (dependencies_list , meta_dict ):
97+ """
98+ Create a default.nix
99+ """
100+ nix_content = """
101+ {
102+ pkgs ? import <nixpkgs> { },
103+ }:
104+
105+ let
106+ python = pkgs.python313;
107+
108+ # Helper function to override a package to disable tests
109+ disableAllTests =
110+ package: extraAttrs:
111+ package.overrideAttrs (
112+ old:
113+ {
114+ doCheck = false;
115+ doInstallCheck = false;
116+ doPytestCheck = false;
117+ pythonImportsCheck = [];
118+ checkPhase = "echo 'Tests disabled'";
119+ installCheckPhase = "echo 'Install checks disabled'";
120+ pytestCheckPhase = "echo 'Pytest checks disabled'";
121+ __intentionallyOverridingVersion = old.__intentionallyOverridingVersion or false;
122+ }
123+ // extraAttrs
124+ );
125+
126+ pythonOverlay = self: super: {
127+ """
128+ need_review_packages_list = []
129+ deps_size = len (dependencies_list )
130+ for idx , dep in enumerate (dependencies_list ):
131+ print ("Processing {}/{}: {}" .format (idx + 1 , deps_size , dep ['name' ]))
132+ name = dep ['name' ]
133+ version = dep ['version' ]
134+ # Handle 'django_notifications_patched','django-rest-hooks' and 'funcparserlib' separately
135+ if not name == 'django-rest-hooks' and not name == 'django_notifications_patched' and not name == 'funcparserlib' :
136+ url = "https://pypi.org/pypi/{name}/{version}/json" .format (name = name , version = version )
137+ try :
138+ response = requests .get (url )
139+ response .raise_for_status ()
140+ data = response .json ()
141+
142+ url_section = data .get ("urls" , [])
143+ build_from_src = True
144+ package_added = False
145+ for component in url_section :
146+ if component .get ("packagetype" ) == "bdist_wheel" :
147+ whl_url = component .get ("url" )
148+ whl_sha256 = get_sha256_hash (whl_url )
149+ nix_content += ' ' + name + ' = python.pkgs.buildPythonPackage {\n '
150+ nix_content += ' pname = "' + name + '";\n '
151+ nix_content += ' version = "' + version + '";\n '
152+ nix_content += ' format = "wheel";\n '
153+ nix_content += ' src = pkgs.fetchurl {\n '
154+ nix_content += ' url = "' + whl_url + '";\n '
155+ nix_content += ' sha256 = "' + whl_sha256 + '";\n '
156+ nix_content += ' };\n '
157+ nix_content += ' };\n '
158+ build_from_src = False
159+ package_added = True
160+ break
161+
162+ if build_from_src :
163+ for component in url_section :
164+ if component .get ("packagetype" ) == "sdist" :
165+ sdist_url = component .get ("url" )
166+ sdist_sha256 = get_sha256_hash (sdist_url )
167+ nix_content += ' ' + name + ' = disableAllTests super.' + name + ' {\n '
168+ nix_content += ' pname = "' + name + '";\n '
169+ nix_content += ' version = "' + version + '";\n '
170+ nix_content += ' __intentionallyOverridingVersion = true;\n '
171+ nix_content += ' src = pkgs.fetchurl {\n '
172+ nix_content += ' url = "' + sdist_url + '";\n '
173+ nix_content += ' sha256 = "' + sdist_sha256 + '";\n '
174+ nix_content += ' };\n '
175+ nix_content += ' };\n '
176+ package_added = True
177+ break
178+ if not package_added :
179+ need_review_packages_list .append (dep )
180+ except requests .exceptions .RequestException as e :
181+ need_review_packages_list .append (dep )
182+ else :
183+ if name == 'django-rest-hooks' and version == '1.6.1' :
184+ nix_content += ' ' + name + ' = python.pkgs.buildPythonPackage {\n '
185+ nix_content += ' pname = "django-rest-hooks";\n '
186+ nix_content += ' version = "1.6.1";\n '
187+ nix_content += ' format = "wheel";\n '
188+ nix_content += ' src = pkgs.fetchurl {\n '
189+ nix_content += ' url = "https://github.com/aboutcode-org/django-rest-hooks/releases/download/1.6.1/django_rest_hooks-1.6.1-py2.py3-none-any.whl";\n '
190+ nix_content += ' sha256 = "1byakq3ghpqhm0mjjkh8v5y6g3wlnri2vvfifyi9ky36l12vqx74";\n '
191+ nix_content += ' };\n '
192+ nix_content += ' };\n '
193+ elif name == 'django_notifications_patched' and version == '2.0.0' :
194+ nix_content += ' ' + name + ' = self.buildPythonPackage rec {\n '
195+ nix_content += ' pname = "django_notifications_patched";\n '
196+ nix_content += ' version = "2.0.0";\n '
197+ nix_content += ' format = "setuptools";\n '
198+ nix_content += ' doCheck = false;\n '
199+ nix_content += ' src = pkgs.fetchFromGitHub {\n '
200+ nix_content += ' owner = "dejacode";\n '
201+ nix_content += ' repo = "django-notifications-patched";\n '
202+ nix_content += ' rev = "2.0.0";\n '
203+ nix_content += ' url = "https://github.com/dejacode/django-notifications-patched/archive/refs/tags/2.0.0.tar.gz";\n '
204+ nix_content += ' sha256 = "sha256-RDAp2PKWa2xA5ge25VqkmRm8HCYVS4/fq2xKc80LDX8=";\n '
205+ nix_content += ' };\n '
206+ nix_content += ' };\n '
207+ elif name == 'funcparserlib' and version == '0.3.6' :
208+ nix_content += ' ' + name + ' = self.buildPythonPackage rec {\n '
209+ nix_content += ' pname = "funcparserlib";\n '
210+ nix_content += ' version = "0.3.6";\n '
211+ nix_content += ' format = "setuptools";\n '
212+ nix_content += ' doCheck = false;\n '
213+ nix_content += ' src = pkgs.fetchurl {\n '
214+ nix_content += ' url = "https://files.pythonhosted.org/packages/cb/f7/b4a59c3ccf67c0082546eaeb454da1a6610e924d2e7a2a21f337ecae7b40/funcparserlib-0.3.6.tar.gz";\n '
215+ nix_content += ' sha256 = "07f9cgjr3h4j2m67fhwapn8fja87vazl58zsj4yppf9y3an2x6dp";\n '
216+ nix_content += ' };\n \n '
217+ # Original setpy.py: https://github.com/vlasovskikh/funcparserlib/blob/0.3.6/setup.py
218+ # funcparserlib version 0.3.6 uses use_2to3 which is no longer supported in modern setuptools.
219+ # Remove the "use_2to3" from the setup.py
220+ nix_content += " postPatch = ''\n "
221+ nix_content += ' cat > setup.py << EOF\n '
222+ nix_content += ' # -*- coding: utf-8 -*-\n '
223+ nix_content += ' from setuptools import setup\n '
224+ nix_content += ' setup(\n '
225+ nix_content += ' name="funcparserlib",\n '
226+ nix_content += ' version="0.3.6",\n '
227+ nix_content += ' packages=["funcparserlib", "funcparserlib.tests"],\n '
228+ nix_content += ' author="Andrey Vlasovskikh",\n '
229+ nix_content += ' description="Recursive descent parsing library based on functional combinators",\n '
230+ nix_content += ' license="MIT",\n '
231+ nix_content += ' url="http://code.google.com/p/funcparserlib/",\n '
232+ nix_content += ' )\n '
233+ nix_content += ' EOF\n '
234+ nix_content += " '';\n "
235+ nix_content += ' propagatedBuildInputs = with self; [];\n '
236+ nix_content += ' checkPhase = "echo \' Tests disabled for funcparserlib\' ";\n '
237+ nix_content += ' };\n '
238+ else :
239+ need_review_packages_list .append (dep )
240+ nix_content += """
241+ };
242+ pythonWithOverlay = python.override {
243+ packageOverrides =
244+ self: super:
245+ let
246+ # Override buildPythonPackage to disable tests for ALL packages
247+ base = {
248+ buildPythonPackage =
249+ attrs:
250+ super.buildPythonPackage (
251+ attrs
252+ // {
253+ doCheck = false;
254+ doInstallCheck = false;
255+ doPytestCheck = false;
256+ pythonImportsCheck = [];
257+ }
258+ );
259+ };
260+
261+ # Apply custom package overrides
262+ custom = pythonOverlay self super;
263+ in
264+ base // custom;
265+ };
266+
267+ pythonApp = pythonWithOverlay.pkgs.buildPythonApplication {
268+ """
269+
270+ nix_content += ' name = "' + meta_dict ['name' ] + '";\n '
271+ nix_content += ' version = "' + meta_dict ['version' ] + '";\n '
272+
273+ nix_content += """
274+ src = ./.;
275+ doCheck = false;
276+ doInstallCheck = false;
277+ doPytestCheck = false;
278+ pythonImportsCheck = [];
279+
280+ format = "pyproject";
281+
282+ nativeBuildInputs = with pythonWithOverlay.pkgs; [
283+ setuptools
284+ wheel
285+ pip
286+ ];
287+
288+ propagatedBuildInputs = with pythonWithOverlay.pkgs; [
289+ """
290+
291+ for dep in dependencies_list :
292+ name = dep ['name' ]
293+ nix_content += ' ' + name + '\n '
294+
295+ nix_content += """
296+ ];
297+
298+ meta = with pkgs.lib; {
299+ description = "Automate open source license compliance and ensure supply chain integrity";
300+ license = "AGPL-3.0-only";
301+ maintainers = ["AboutCode.org"];
302+ platforms = platforms.linux;
303+ };
304+ };
305+
306+ in
307+ {
308+ # Default output is the Python application
309+ app = pythonApp;
310+
311+ # Default to the application
312+ default = pythonApp;
313+ }
314+ """
315+ return nix_content , need_review_packages_list
316+
317+
318+ def get_sha256_hash (url ):
319+ """
320+ Get SHA256 hash of a file using nix-prefetch-url.
321+ """
322+ try :
323+ result = subprocess .run (
324+ ['nix-prefetch-url' , url ],
325+ capture_output = True ,
326+ text = True ,
327+ check = True
328+ )
329+ return result .stdout .strip ()
330+ except subprocess .CalledProcessError as e :
331+ print (f"Error running nix-prefetch-url for { url } : { e } " )
332+ return None
333+ except FileNotFoundError :
334+ print ("Error: nix-prefetch-url command not found. Make sure nix is installed." )
335+ return None
336+
337+
21338def cleanup_nix_store ():
22339 """
23340 Remove the nix-store volume to ensure clean state
@@ -81,7 +398,6 @@ def build_nix_with_docker():
81398
82399 try :
83400 subprocess .run (docker_cmd , check = True )
84-
85401 # Verify if the output directory contains any files or
86402 # subdirectories.
87403 if any (output_dir .iterdir ()):
@@ -94,18 +410,42 @@ def build_nix_with_docker():
94410 sys .exit (1 )
95411
96412
97- if __name__ == '__main__' :
413+ def main () :
98414 # Check if "docker" is available
99415 if not shutil .which ('docker' ):
100416 print ("Error: Docker not found. Please install Docker first." , file = sys .stderr )
101417 sys .exit (1 )
102418
103- # Check if default.nix exists
104- if not Path ('default.nix' ).exists ():
105- print ("Error: default.nix not found in current directory" , file = sys .stderr )
106- sys .exit (1 )
419+ parser = argparse .ArgumentParser (description = "Package to Nix using Docker." )
420+ parser .add_argument ("--generate" , action = "store_true" , help = "Generate the default.nix file." )
421+
422+ args = parser .parse_args ()
423+
424+ if args .generate or not Path ('default.nix' ).exists ():
425+ # Check if "nix-prefetch-url" is available
426+ if not shutil .which ("nix-prefetch-url" ):
427+ print ("nix-prefetch-url is NOT installed." )
428+ sys .exit (1 )
429+
430+ print ("Generating default.nix" )
431+ pyproject_data = read_pyproject_toml ()
432+ meta_dict = extract_project_meta (pyproject_data )
433+ dependencies_list = extract_project_dependencies (pyproject_data )
434+ defualt_nix_content , need_review = create_defualt_nix (dependencies_list , meta_dict )
435+ with open ("default.nix" , "w" ) as file :
436+ file .write (defualt_nix_content )
437+
438+ print ("default.nix file created successfully." )
439+ if need_review :
440+ print ("\n The following packages need manual review as they were not found on PyPI or had issues:" )
441+ for pkg in need_review :
442+ print (f" - { pkg ['name' ]} =={ pkg ['version' ]} " )
443+ print ("\n Please review and add them manually to default.nix and re-run without the --generate.\n " )
444+ sys .exit (1 )
107445
108446 # Clean up the volume to ensure consistent state
109447 cleanup_nix_store ()
110-
111448 build_nix_with_docker ()
449+
450+ if __name__ == '__main__' :
451+ main ()
0 commit comments