Skip to content

Commit 6c8f269

Browse files
committed
feat(cli,mkdocs,tests): add pn init/run/clean; use bundled templates
1 parent b243b5f commit 6c8f269

File tree

6 files changed

+238
-57
lines changed

6 files changed

+238
-57
lines changed

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ BREAKING CHANGE: API renamed; update app code and templates.
159159
- Comma‑separate scopes without spaces: `type(scope1,scope2): ...`
160160
- Prefer a single scope when possible; use multiple only when the change genuinely spans tightly related areas.
161161

162+
Scope ordering (house style):
163+
164+
- Put the most impacted scope first (e.g., `repo`), then any secondary scopes.
165+
- For extra consistency, alphabetize the remaining scopes after the primary.
166+
- Keep it to 1–3 scopes max.
167+
162168
Example:
163169

164170
```text
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "PythonNative Demo",
3+
"appId": "com.pythonnative.demo",
4+
"entryPoint": "app/main_page.py",
5+
"ios": {},
6+
"android": {}
7+
}

docs/getting-started.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ pn --help
77

88
- Install: `pip install pythonnative`
99
- Create a project: `pn init my_app`
10+
- Scaffolds `app/`, `pythonnative.json`, `requirements.txt`, `.gitignore`
1011
- Run: `pn run android` or `pn run ios`
12+
- Uses bundled templates; copies your `app/` into the platform project
13+
- Clean: `pn clean`
14+
- Removes the `build/` directory safely

src/pythonnative/__init__.py

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,5 @@
1-
from .activity_indicator_view import ActivityIndicatorView
2-
from .button import Button
3-
from .date_picker import DatePicker
4-
from .image_view import ImageView
5-
from .label import Label
6-
from .list_view import ListView
7-
from .material_activity_inidicator_view import MaterialActivityIndicatorView
8-
from .material_button import MaterialButton
9-
from .material_date_picker import MaterialDatePicker
10-
from .material_progress_view import MaterialProgressView
11-
from .material_search_bar import MaterialSearchBar
12-
from .material_switch import MaterialSwitch
13-
from .material_time_picker import MaterialTimePicker
14-
from .page import Page
15-
from .picker_view import PickerView
16-
from .progress_view import ProgressView
17-
from .scroll_view import ScrollView
18-
from .search_bar import SearchBar
19-
from .stack_view import StackView
20-
from .switch import Switch
21-
from .text_field import TextField
22-
from .text_view import TextView
23-
from .time_picker import TimePicker
24-
from .web_view import WebView
1+
from importlib import import_module
2+
from typing import Any, Dict
253

264
__all__ = [
275
"ActivityIndicatorView",
@@ -49,3 +27,42 @@
4927
"TimePicker",
5028
"WebView",
5129
]
30+
31+
_NAME_TO_MODULE: Dict[str, str] = {
32+
"ActivityIndicatorView": ".activity_indicator_view",
33+
"Button": ".button",
34+
"DatePicker": ".date_picker",
35+
"ImageView": ".image_view",
36+
"Label": ".label",
37+
"ListView": ".list_view",
38+
"MaterialActivityIndicatorView": ".material_activity_inidicator_view",
39+
"MaterialButton": ".material_button",
40+
"MaterialDatePicker": ".material_date_picker",
41+
"MaterialProgressView": ".material_progress_view",
42+
"MaterialSearchBar": ".material_search_bar",
43+
"MaterialSwitch": ".material_switch",
44+
"MaterialTimePicker": ".material_time_picker",
45+
"Page": ".page",
46+
"PickerView": ".picker_view",
47+
"ProgressView": ".progress_view",
48+
"ScrollView": ".scroll_view",
49+
"SearchBar": ".search_bar",
50+
"StackView": ".stack_view",
51+
"Switch": ".switch",
52+
"TextField": ".text_field",
53+
"TextView": ".text_view",
54+
"TimePicker": ".time_picker",
55+
"WebView": ".web_view",
56+
}
57+
58+
59+
def __getattr__(name: str) -> Any:
60+
module_path = _NAME_TO_MODULE.get(name)
61+
if not module_path:
62+
raise AttributeError(f"module 'pythonnative' has no attribute {name!r}")
63+
module = import_module(module_path, package=__name__)
64+
return getattr(module, name)
65+
66+
67+
def __dir__() -> Any:
68+
return sorted(list(globals().keys()) + __all__)

src/pythonnative/cli/pn.py

Lines changed: 146 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,118 @@
11
import argparse
2-
import io
2+
import json
33
import os
44
import shutil
55
import subprocess
6+
import sys
67
import zipfile
7-
8-
import requests
8+
from importlib import resources
99

1010

1111
def init_project(args: argparse.Namespace) -> None:
1212
"""
1313
Initialize a new PythonNative project.
14+
Creates `app/`, `pythonnative.json`, `requirements.txt`, `.gitignore`.
1415
"""
15-
# TODO: Implementation
16-
17-
18-
def download_template_project(template_url: str, destination: str) -> None:
16+
project_name: str = getattr(args, "name", None) or os.path.basename(os.getcwd())
17+
cwd: str = os.getcwd()
18+
19+
app_dir = os.path.join(cwd, "app")
20+
config_path = os.path.join(cwd, "pythonnative.json")
21+
requirements_path = os.path.join(cwd, "requirements.txt")
22+
gitignore_path = os.path.join(cwd, ".gitignore")
23+
24+
# Prevent accidental overwrite unless --force is provided
25+
if not getattr(args, "force", False):
26+
exists = []
27+
if os.path.exists(app_dir):
28+
exists.append("app/")
29+
if os.path.exists(config_path):
30+
exists.append("pythonnative.json")
31+
if os.path.exists(requirements_path):
32+
exists.append("requirements.txt")
33+
if os.path.exists(gitignore_path):
34+
exists.append(".gitignore")
35+
if exists:
36+
print(f"Refusing to overwrite existing: {', '.join(exists)}. Use --force to overwrite.")
37+
sys.exit(1)
38+
39+
os.makedirs(app_dir, exist_ok=True)
40+
41+
# Minimal hello world app scaffold
42+
main_page_py = os.path.join(app_dir, "main_page.py")
43+
if not os.path.exists(main_page_py) or args.force:
44+
with open(main_page_py, "w", encoding="utf-8") as f:
45+
f.write(
46+
"""import pythonnative as pn
47+
48+
49+
class MainPage(pn.Page):
50+
def __init__(self, native_instance):
51+
super().__init__(native_instance)
52+
53+
def on_create(self):
54+
super().on_create()
55+
stack = pn.StackView(self.native_instance)
56+
stack.add_view(pn.Label(self.native_instance, \"Hello from PythonNative!\"))
57+
self.set_root_view(stack)
58+
"""
59+
)
60+
61+
# Create config
62+
config = {
63+
"name": project_name,
64+
"appId": "com.example." + project_name.replace(" ", "").lower(),
65+
"entryPoint": "app/main_page.py",
66+
"ios": {},
67+
"android": {},
68+
}
69+
with open(config_path, "w", encoding="utf-8") as f:
70+
json.dump(config, f, indent=2)
71+
72+
# Requirements
73+
if not os.path.exists(requirements_path) or args.force:
74+
with open(requirements_path, "w", encoding="utf-8") as f:
75+
f.write("pythonnative\n")
76+
77+
# .gitignore
78+
default_gitignore = "# PythonNative\n" "__pycache__/\n" "*.pyc\n" ".venv/\n" "build/\n" ".DS_Store\n"
79+
if not os.path.exists(gitignore_path) or args.force:
80+
with open(gitignore_path, "w", encoding="utf-8") as f:
81+
f.write(default_gitignore)
82+
83+
print("Initialized PythonNative project.")
84+
85+
86+
def _extract_zip_to_destination(zip_path: str, destination: str) -> None:
87+
with zipfile.ZipFile(zip_path, "r") as zf:
88+
zf.extractall(destination)
89+
90+
91+
def _extract_bundled_template(zip_name: str, destination: str) -> None:
1992
"""
20-
Download and extract a template project from a URL.
21-
22-
:param template_url: The URL of the template project.
23-
:param destination: The directory where the project will be created.
93+
Extract a bundled template zip into the destination directory.
94+
Tries package resources first; falls back to repo root `templates/` at dev time.
2495
"""
25-
response: requests.Response = requests.get(template_url, stream=True)
26-
27-
if response.status_code == 200:
28-
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
29-
z.extractall(destination)
96+
# Try to load from installed package resources first
97+
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
103+
except Exception:
104+
# Fall back to repo layout
105+
pass
106+
107+
# Fallback: use repository-level templates directory
108+
repo_templates = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "..", "..", "templates")
109+
repo_templates = os.path.abspath(repo_templates)
110+
candidate = os.path.join(repo_templates, zip_name)
111+
if os.path.exists(candidate):
112+
_extract_zip_to_destination(candidate, destination)
113+
return
114+
115+
raise FileNotFoundError(f"Could not find bundled template {zip_name}. Ensure templates are packaged.")
30116

31117

32118
def create_android_project(project_name: str, destination: str) -> None:
@@ -36,12 +122,8 @@ def create_android_project(project_name: str, destination: str) -> None:
36122
:param project_name: The name of the project.
37123
:param destination: The directory where the project will be created.
38124
"""
39-
android_template_url = (
40-
"https://github.com/owenthcarey/pythonnative-workspace/blob/main/libs/templates/android_template.zip?raw=true"
41-
)
42-
43-
# Download and extract the Android template project
44-
download_template_project(android_template_url, destination)
125+
# Extract the Android template project from bundled zip
126+
_extract_bundled_template("android_template.zip", destination)
45127

46128

47129
def create_ios_project(project_name: str, destination: str) -> None:
@@ -51,12 +133,8 @@ def create_ios_project(project_name: str, destination: str) -> None:
51133
:param project_name: The name of the project.
52134
:param destination: The directory where the project will be created.
53135
"""
54-
ios_template_url = (
55-
"https://github.com/owenthcarey/pythonnative-workspace/blob/main/libs/templates/ios_template.zip?raw=true"
56-
)
57-
58-
# Download and extract the iOS template project
59-
download_template_project(ios_template_url, destination)
136+
# Extract the iOS template project from bundled zip
137+
_extract_bundled_template("ios_template.zip", destination)
60138

61139

62140
def run_project(args: argparse.Namespace) -> None:
@@ -85,7 +163,8 @@ def run_project(args: argparse.Namespace) -> None:
85163
if platform == "android":
86164
dest_dir: str = os.path.join(build_dir, "android_template", "app", "src", "main", "python", "app")
87165
else:
88-
dest_dir = os.path.join(build_dir, "app") # Adjust this based on your iOS project structure
166+
# For iOS, stage the Python app in a top-level folder for later integration scripts
167+
dest_dir = os.path.join(build_dir, "app")
89168

90169
# Create the destination directory if it doesn't exist
91170
os.makedirs(dest_dir, exist_ok=True)
@@ -94,7 +173,7 @@ def run_project(args: argparse.Namespace) -> None:
94173
# Install any necessary Python packages into the project environment
95174
requirements_path = os.path.join(os.getcwd(), "requirements.txt")
96175
if os.path.exists(requirements_path):
97-
subprocess.run(["pip", "install", "-r", requirements_path], check=False)
176+
subprocess.run([sys.executable, "-m", "pip", "install", "-r", requirements_path], check=False)
98177

99178
# Run the project
100179
if platform == "android":
@@ -107,9 +186,14 @@ def run_project(args: argparse.Namespace) -> None:
107186
os.chmod(gradlew_path, 0o755) # this makes the file executable for the user
108187

109188
# Build the Android project and install it on the device
110-
jdk_path: str = subprocess.check_output(["brew", "--prefix", "openjdk@17"]).decode().strip()
111189
env: dict[str, str] = os.environ.copy()
112-
env["JAVA_HOME"] = jdk_path
190+
# Respect JAVA_HOME if set; otherwise, attempt a best-effort on macOS via Homebrew
191+
if sys.platform == "darwin" and not env.get("JAVA_HOME"):
192+
try:
193+
jdk_path: str = subprocess.check_output(["brew", "--prefix", "openjdk@17"]).decode().strip()
194+
env["JAVA_HOME"] = jdk_path
195+
except Exception:
196+
pass
113197
subprocess.run(["./gradlew", "installDebug"], check=True, env=env)
114198

115199
# Run the Android app
@@ -126,6 +210,27 @@ def run_project(args: argparse.Namespace) -> None:
126210
],
127211
check=True,
128212
)
213+
elif platform == "ios":
214+
# Attempt to build the iOS project for Simulator (best-effort)
215+
ios_project_dir: str = os.path.join(build_dir, "ios_template")
216+
if os.path.isdir(ios_project_dir):
217+
os.chdir(ios_project_dir)
218+
try:
219+
subprocess.run(
220+
[
221+
"xcodebuild",
222+
"-project",
223+
"ios_template.xcodeproj",
224+
"-scheme",
225+
"ios_template",
226+
"-destination",
227+
"platform=iOS Simulator,name=iPhone 15",
228+
"build",
229+
],
230+
check=False,
231+
)
232+
except FileNotFoundError:
233+
print("xcodebuild not found. Skipping iOS build step.")
129234

130235

131236
def clean_project(args: argparse.Namespace) -> None:
@@ -137,8 +242,10 @@ def clean_project(args: argparse.Namespace) -> None:
137242

138243
# Check if the build directory exists
139244
if os.path.exists(build_dir):
140-
# Delete the build directory
141245
shutil.rmtree(build_dir)
246+
print("Removed build/ directory.")
247+
else:
248+
print("No build/ directory to remove.")
142249

143250

144251
def main() -> None:
@@ -147,6 +254,8 @@ def main() -> None:
147254

148255
# Create a new command 'init' that calls init_project
149256
parser_init = subparsers.add_parser("init")
257+
parser_init.add_argument("name", nargs="?", help="Project name (defaults to current directory name)")
258+
parser_init.add_argument("--force", action="store_true", help="Overwrite existing files if present")
150259
parser_init.set_defaults(func=init_project)
151260

152261
# Create a new command 'run' that calls run_project
@@ -160,3 +269,7 @@ def main() -> None:
160269

161270
args = parser.parse_args()
162271
args.func(args)
272+
273+
274+
if __name__ == "__main__":
275+
main()

tests/test_cli.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os
2+
import shutil
3+
import subprocess
4+
import sys
5+
import tempfile
6+
7+
8+
def run_pn(args, cwd):
9+
cmd = [sys.executable, "-m", "pythonnative.cli.pn"] + args
10+
return subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, text=True)
11+
12+
13+
def test_cli_init_and_clean():
14+
tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
15+
try:
16+
# init
17+
result = run_pn(["init", "MyApp"], tmpdir)
18+
assert result.returncode == 0, result.stderr
19+
assert os.path.isdir(os.path.join(tmpdir, "app"))
20+
assert os.path.isfile(os.path.join(tmpdir, "pythonnative.json"))
21+
assert os.path.isfile(os.path.join(tmpdir, "requirements.txt"))
22+
assert os.path.isfile(os.path.join(tmpdir, ".gitignore"))
23+
24+
# clean (on empty build should be no-op)
25+
result = run_pn(["clean"], tmpdir)
26+
assert result.returncode == 0, result.stderr
27+
28+
# create build dir and ensure clean removes it
29+
os.makedirs(os.path.join(tmpdir, "build", "android"), exist_ok=True)
30+
result = run_pn(["clean"], tmpdir)
31+
assert result.returncode == 0, result.stderr
32+
assert not os.path.exists(os.path.join(tmpdir, "build"))
33+
finally:
34+
shutil.rmtree(tmpdir, ignore_errors=True)

0 commit comments

Comments
 (0)