22import hashlib
33import json
44import os
5+ import re
56import shutil
67import subprocess
78import sys
@@ -73,16 +74,17 @@ def render(self):
7374 "name" : project_name ,
7475 "appId" : "com.example." + project_name .replace (" " , "" ).lower (),
7576 "entryPoint" : "app/main_page.py" ,
77+ "pythonVersion" : "3.11" ,
7678 "ios" : {},
7779 "android" : {},
7880 }
7981 with open (config_path , "w" , encoding = "utf-8" ) as f :
8082 json .dump (config , f , indent = 2 )
8183
82- # Requirements
84+ # Requirements (third-party packages only; pythonnative itself is bundled by the CLI)
8385 if not os .path .exists (requirements_path ) or args .force :
8486 with open (requirements_path , "w" , encoding = "utf-8" ) as f :
85- f .write ("pythonnative \n " )
87+ f .write ("" )
8688
8789 # .gitignore
8890 default_gitignore = "# PythonNative\n " "__pycache__/\n " "*.pyc\n " ".venv/\n " "build/\n " ".DS_Store\n "
@@ -215,6 +217,43 @@ def create_ios_project(project_name: str, destination: str) -> None:
215217 _copy_bundled_template_dir ("ios_template" , destination )
216218
217219
220+ def _read_project_config () -> dict :
221+ """Read pythonnative.json from the current working directory."""
222+ config_path = os .path .join (os .getcwd (), "pythonnative.json" )
223+ if os .path .exists (config_path ):
224+ with open (config_path , encoding = "utf-8" ) as f :
225+ return json .load (f )
226+ return {}
227+
228+
229+ def _read_requirements (requirements_path : str ) -> list [str ]:
230+ """Read a requirements file and return non-empty, non-comment lines.
231+
232+ Exits with an error if pythonnative is listed — the CLI bundles it
233+ directly, so it must not be installed separately via pip/Chaquopy.
234+ """
235+ if not os .path .exists (requirements_path ):
236+ return []
237+ with open (requirements_path , encoding = "utf-8" ) as f :
238+ lines = f .readlines ()
239+ result : list [str ] = []
240+ for line in lines :
241+ stripped = line .strip ()
242+ if not stripped or stripped .startswith ("#" ) or stripped .startswith ("-" ):
243+ continue
244+ pkg_name = re .split (r"[\[><=!;]" , stripped )[0 ].strip ()
245+ if pkg_name .lower ().replace ("-" , "_" ) == "pythonnative" :
246+ print (
247+ "Error: 'pythonnative' must not be in requirements.txt.\n "
248+ "The pn CLI automatically bundles the installed pythonnative into your app.\n "
249+ "requirements.txt is for third-party packages only (e.g. humanize, requests).\n "
250+ "Remove the pythonnative line from requirements.txt and try again."
251+ )
252+ sys .exit (1 )
253+ result .append (stripped )
254+ return result
255+
256+
218257def run_project (args : argparse .Namespace ) -> None :
219258 """
220259 Run the specified project.
@@ -223,8 +262,13 @@ def run_project(args: argparse.Namespace) -> None:
223262 platform : str = args .platform
224263 prepare_only : bool = getattr (args , "prepare_only" , False )
225264
265+ # Read project configuration and save project root before any chdir
266+ project_dir : str = os .getcwd ()
267+ config = _read_project_config ()
268+ python_version : str = config .get ("pythonVersion" , "3.11" )
269+
226270 # Define the build directory
227- build_dir : str = os .path .join (os . getcwd () , "build" , platform )
271+ build_dir : str = os .path .join (project_dir , "build" , platform )
228272
229273 # Create the build directory if it doesn't exist
230274 os .makedirs (build_dir , exist_ok = True )
@@ -268,10 +312,30 @@ def run_project(args: argparse.Namespace) -> None:
268312 # Non-fatal; fallback to the packaged PyPI dependency if present
269313 pass
270314
271- # Install any necessary Python packages into the project environment
315+ # Validate and read the user's requirements.txt
316+ requirements_path = os .path .join (project_dir , "requirements.txt" )
317+ pip_reqs = _read_requirements (requirements_path )
318+
319+ if platform == "android" :
320+ # Patch the Android build.gradle with the configured Python version
321+ app_build_gradle = os .path .join (build_dir , "android_template" , "app" , "build.gradle" )
322+ if os .path .exists (app_build_gradle ):
323+ with open (app_build_gradle , encoding = "utf-8" ) as f :
324+ content = f .read ()
325+ content = content .replace ('version "3.11"' , f'version "{ python_version } "' )
326+ with open (app_build_gradle , "w" , encoding = "utf-8" ) as f :
327+ f .write (content )
328+ # Copy requirements.txt into the Android project for Chaquopy
329+ android_reqs_path = os .path .join (build_dir , "android_template" , "app" , "requirements.txt" )
330+ if os .path .exists (requirements_path ):
331+ shutil .copy2 (requirements_path , android_reqs_path )
332+ else :
333+ with open (android_reqs_path , "w" , encoding = "utf-8" ) as f :
334+ f .write ("" )
335+
336+ # Install any necessary Python packages into the host environment
272337 # Skip installation during prepare-only to avoid network access and speed up scaffolding
273338 if not prepare_only :
274- requirements_path = os .path .join (os .getcwd (), "requirements.txt" )
275339 if os .path .exists (requirements_path ):
276340 subprocess .run ([sys .executable , "-m" , "pip" , "install" , "-r" , requirements_path ], check = False )
277341
@@ -523,6 +587,29 @@ def run_project(args: argparse.Namespace) -> None:
523587 except Exception :
524588 # Non-fatal; if metadata isn't present, rubicon import may fail and fallback UI will appear
525589 pass
590+ # Install user's pip requirements (pure-Python packages) into the app bundle
591+ if pip_reqs :
592+ try :
593+ reqs_tmp = os .path .join (build_dir , "ios_requirements.txt" )
594+ with open (reqs_tmp , "w" , encoding = "utf-8" ) as f :
595+ f .write ("\n " .join (pip_reqs ) + "\n " )
596+ tmp_reqs_dir = os .path .join (build_dir , "ios_user_packages" )
597+ if os .path .isdir (tmp_reqs_dir ):
598+ shutil .rmtree (tmp_reqs_dir )
599+ os .makedirs (tmp_reqs_dir , exist_ok = True )
600+ subprocess .run (
601+ [sys .executable , "-m" , "pip" , "install" , "-t" , tmp_reqs_dir , "-r" , reqs_tmp ],
602+ check = False ,
603+ )
604+ for entry in os .listdir (tmp_reqs_dir ):
605+ src_entry = os .path .join (tmp_reqs_dir , entry )
606+ dst_entry = os .path .join (platform_site_dir , entry )
607+ if os .path .isdir (src_entry ):
608+ shutil .copytree (src_entry , dst_entry , dirs_exist_ok = True )
609+ else :
610+ shutil .copy2 (src_entry , dst_entry )
611+ except Exception :
612+ pass
526613 # Note: Python.xcframework provides a static library for Simulator; it must be linked at build time.
527614 # We copy the XCFramework into the project directory above so Xcode can link it.
528615 except Exception :
0 commit comments