Skip to content

Commit d9dd821

Browse files
committed
feat(cli,templates): bundle offline templates; add run --prepare-only
1 parent 9c61757 commit d9dd821

File tree

7 files changed

+129
-11
lines changed

7 files changed

+129
-11
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pythonnative

docs/guides/android.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
11
# Android Guide
22

33
Basic steps to build and run an Android project generated by `pn`.
4+
5+
## What gets generated
6+
7+
`pn run android` unpacks the bundled Android template (Kotlin + Chaquopy) into `build/android/android_template` and copies your `app/` into the template's `app/src/main/python/app/`.
8+
9+
No network is required for the template itself; the template zip is bundled with the package.
10+
11+
## Run
12+
13+
```bash
14+
pn run android
15+
```
16+
17+
Or to only prepare the project without building:
18+
19+
```bash
20+
pn run android --prepare-only
21+
```
22+
23+
This will stage files under `build/android/android_template` so you can open it in Android Studio if you prefer.

docs/guides/ios.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
11
# iOS Guide
22

33
Basic steps to build and run an iOS project generated by `pn`.
4+
5+
## What gets generated
6+
7+
`pn run ios` unpacks the bundled iOS template (Swift + PythonKit, with optional Rubicon-ObjC) into `build/ios/ios_template` and copies your `app/` under `build/ios/app/` for later integration steps. The template zip is bundled with the package, so no network is required to scaffold.
8+
9+
The default `ViewController.swift` initializes PythonKit, prints the Python version, and attempts to import `rubicon.objc` if present.
10+
11+
## Run / Prepare
12+
13+
```bash
14+
pn run ios
15+
```
16+
17+
Or prepare without building:
18+
19+
```bash
20+
pn run ios --prepare-only
21+
```
22+
23+
You can then open `build/ios/ios_template/ios_template.xcodeproj` in Xcode.

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ where = ["src"]
6666
[tool.setuptools]
6767
license-files = ["LICENSE*"]
6868

69+
# Ensure template zip files are bundled with the wheel so the CLI can run offline.
70+
[tool.setuptools.data-files]
71+
"pythonnative/templates" = [
72+
"templates/android_template.zip",
73+
"templates/ios_template.zip",
74+
]
75+
6976
[tool.ruff]
7077
target-version = "py39"
7178
line-length = 120

src/pythonnative/cli/pn.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import shutil
55
import subprocess
66
import sys
7+
import sysconfig
78
import zipfile
89
from importlib import resources
910

@@ -93,15 +94,39 @@ def _extract_bundled_template(zip_name: str, destination: str) -> None:
9394
Extract a bundled template zip into the destination directory.
9495
Tries package resources first; falls back to repo root `templates/` at dev time.
9596
"""
96-
# Try to load from installed package resources first
97+
# Try to load from installed package resources first (if templates are packaged inside the module)
9798
try:
98-
pkg_templates = resources.files("pythonnative").joinpath("templates")
99-
resource_path = str(pkg_templates.joinpath(zip_name))
100-
if os.path.exists(resource_path):
101-
_extract_zip_to_destination(resource_path, destination)
102-
return
99+
cand = resources.files("pythonnative").joinpath("templates").joinpath(zip_name)
100+
with resources.as_file(cand) as p:
101+
resource_path = str(p)
102+
if os.path.exists(resource_path):
103+
_extract_zip_to_destination(resource_path, destination)
104+
return
105+
except Exception:
106+
# Not packaged inside the module; try data-files installation locations next
107+
pass
108+
109+
# Try sysconfig data dir (where data-files are typically installed)
110+
try:
111+
data_dir = sysconfig.get_paths().get("data")
112+
if data_dir:
113+
candidate = os.path.join(data_dir, "pythonnative", "templates", zip_name)
114+
if os.path.exists(candidate):
115+
_extract_zip_to_destination(candidate, destination)
116+
return
117+
except Exception:
118+
pass
119+
120+
# Try site-packages purelib/platlib (some environments place data files here)
121+
try:
122+
purelib = sysconfig.get_paths().get("purelib")
123+
platlib = sysconfig.get_paths().get("platlib")
124+
for base in filter(None, [purelib, platlib]):
125+
candidate = os.path.join(base, "pythonnative", "templates", zip_name)
126+
if os.path.exists(candidate):
127+
_extract_zip_to_destination(candidate, destination)
128+
return
103129
except Exception:
104-
# Fall back to repo layout
105130
pass
106131

107132
# Fallback: use repository-level templates directory
@@ -143,6 +168,7 @@ def run_project(args: argparse.Namespace) -> None:
143168
"""
144169
# Determine the platform
145170
platform: str = args.platform
171+
prepare_only: bool = getattr(args, "prepare_only", False)
146172

147173
# Define the build directory
148174
build_dir: str = os.path.join(os.getcwd(), "build", platform)
@@ -171,11 +197,17 @@ def run_project(args: argparse.Namespace) -> None:
171197
shutil.copytree(src_dir, dest_dir, dirs_exist_ok=True)
172198

173199
# Install any necessary Python packages into the project environment
174-
requirements_path = os.path.join(os.getcwd(), "requirements.txt")
175-
if os.path.exists(requirements_path):
176-
subprocess.run([sys.executable, "-m", "pip", "install", "-r", requirements_path], check=False)
200+
# Skip installation during prepare-only to avoid network access and speed up scaffolding
201+
if not prepare_only:
202+
requirements_path = os.path.join(os.getcwd(), "requirements.txt")
203+
if os.path.exists(requirements_path):
204+
subprocess.run([sys.executable, "-m", "pip", "install", "-r", requirements_path], check=False)
177205

178206
# Run the project
207+
if prepare_only:
208+
print("Prepared project in build/ without building (prepare-only).")
209+
return
210+
179211
if platform == "android":
180212
# Change to the Android project directory
181213
android_project_dir: str = os.path.join(build_dir, "android_template")
@@ -261,6 +293,11 @@ def main() -> None:
261293
# Create a new command 'run' that calls run_project
262294
parser_run = subparsers.add_parser("run")
263295
parser_run.add_argument("platform", choices=["android", "ios"])
296+
parser_run.add_argument(
297+
"--prepare-only",
298+
action="store_true",
299+
help="Extract templates and stage app without building",
300+
)
264301
parser_run.set_defaults(func=run_project)
265302

266303
# Create a new command 'clean' that calls clean_project

templates/ios_template/ios_template/ViewController.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,25 @@
66
//
77

88
import UIKit
9+
import PythonKit
910

1011
class ViewController: UIViewController {
1112

1213
override func viewDidLoad() {
1314
super.viewDidLoad()
14-
// Do any additional setup after loading the view.
15+
// Minimal PythonKit bootstrap: print Python version and attempt to import Rubicon.
16+
let sys = Python.import("sys")
17+
print("Python Version: \(sys.version_info.major).\(sys.version_info.minor)")
18+
print("Python Path: \(sys.path)")
19+
20+
// Try to import Rubicon-ObjC if available. Safe no-op if not present.
21+
do {
22+
let rubiconObjC = try Python.attemptImport("rubicon.objc")
23+
let ObjCClass = rubiconObjC.ObjCClass
24+
print("Rubicon available: \(ObjCClass)")
25+
} catch {
26+
print("Rubicon not available; continuing without it.")
27+
}
1528
}
1629

1730

tests/test_cli.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,23 @@ def test_cli_init_and_clean():
3232
assert not os.path.exists(os.path.join(tmpdir, "build"))
3333
finally:
3434
shutil.rmtree(tmpdir, ignore_errors=True)
35+
36+
37+
def test_cli_run_prepare_only_android_and_ios():
38+
tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
39+
try:
40+
# init to create app scaffold
41+
result = run_pn(["init", "MyApp"], tmpdir)
42+
assert result.returncode == 0, result.stderr
43+
44+
# prepare-only android
45+
result = run_pn(["run", "android", "--prepare-only"], tmpdir)
46+
assert result.returncode == 0, result.stderr
47+
assert os.path.isdir(os.path.join(tmpdir, "build", "android", "android_template"))
48+
49+
# prepare-only ios
50+
result = run_pn(["run", "ios", "--prepare-only"], tmpdir)
51+
assert result.returncode == 0, result.stderr
52+
assert os.path.isdir(os.path.join(tmpdir, "build", "ios", "ios_template"))
53+
finally:
54+
shutil.rmtree(tmpdir, ignore_errors=True)

0 commit comments

Comments
 (0)