Skip to content

Commit a529834

Browse files
committed
feat(cli,templates): add pythonVersion config, fix Android build, and wire pip requirements
1 parent ab162c5 commit a529834

File tree

7 files changed

+106
-16
lines changed

7 files changed

+106
-16
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
python-version: ['3.10', '3.11']
14+
python-version: ['3.10', '3.11', '3.12']
1515

1616
steps:
1717
- name: Checkout

examples/hello-world/app/main_page.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from typing import Any
22

3+
import emoji
4+
35
import pythonnative as pn
46

7+
MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"]
8+
59

610
class MainPage(pn.Page):
711
def __init__(self, native_instance: Any) -> None:
@@ -12,10 +16,13 @@ def increment(self) -> None:
1216
self.set_state(count=self.state["count"] + 1)
1317

1418
def render(self) -> pn.Element:
19+
count = self.state["count"]
20+
medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:")
1521
return pn.ScrollView(
1622
pn.Column(
1723
pn.Text("Hello from PythonNative Demo!", font_size=24, bold=True),
18-
pn.Text(f"Tapped {self.state['count']} times", font_size=16),
24+
pn.Text(f"Tapped {count} times", font_size=16),
25+
pn.Text(medal, font_size=32),
1926
pn.Button("Tap me", on_click=self.increment, background_color="#FF1E88E5"),
2027
pn.Button(
2128
"Go to Second Page",

examples/hello-world/pythonnative.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "PythonNative Demo",
33
"appId": "com.pythonnative.demo",
44
"entryPoint": "app/main_page.py",
5+
"pythonVersion": "3.11",
56
"ios": {},
67
"android": {}
78
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
pythonnative
1+
emoji

src/pythonnative/cli/pn.py

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import hashlib
33
import json
44
import os
5+
import re
56
import shutil
67
import subprocess
78
import 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+
218257
def 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:

src/pythonnative/templates/android_template/app/build.gradle

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,9 @@ android {
2020
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
2121
}
2222
python {
23-
version "3.8"
23+
version "3.11"
2424
pip {
25-
install "matplotlib"
26-
install "pythonnative"
27-
28-
// "-r"` followed by a requirements filename, relative to the
29-
// project directory:
30-
// install "-r", "requirements.txt"
25+
install "-r", "requirements.txt"
3126
}
3227
}
3328
}

src/pythonnative/templates/android_template/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ plugins {
33
id 'com.android.application' version '8.2.2' apply false
44
id 'com.android.library' version '8.2.2' apply false
55
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
6-
id 'com.chaquo.python' version '14.0.2' apply false
6+
id 'com.chaquo.python' version '15.0.1' apply false
77
}

0 commit comments

Comments
 (0)