pythonnative 0.5.0__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonnative/__init__.py +53 -15
- pythonnative/cli/pn.py +150 -30
- pythonnative/components.py +217 -107
- pythonnative/element.py +14 -8
- pythonnative/hooks.py +334 -0
- pythonnative/hot_reload.py +143 -0
- pythonnative/native_modules/__init__.py +19 -0
- pythonnative/native_modules/camera.py +105 -0
- pythonnative/native_modules/file_system.py +131 -0
- pythonnative/native_modules/location.py +61 -0
- pythonnative/native_modules/notifications.py +151 -0
- pythonnative/native_views.py +638 -34
- pythonnative/page.py +138 -171
- pythonnative/reconciler.py +153 -20
- pythonnative/style.py +135 -0
- pythonnative/templates/android_template/app/build.gradle +2 -7
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +2 -9
- pythonnative/templates/android_template/build.gradle +1 -1
- pythonnative/templates/ios_template/ios_template/ViewController.swift +7 -20
- {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/METADATA +18 -38
- {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/RECORD +25 -20
- pythonnative/collection_view.py +0 -0
- pythonnative/material_bottom_navigation_view.py +0 -0
- pythonnative/material_toolbar.py +0 -0
- {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/top_level.txt +0 -0
pythonnative/__init__.py
CHANGED
|
@@ -4,51 +4,89 @@ Public API::
|
|
|
4
4
|
|
|
5
5
|
import pythonnative as pn
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
pn.Button("Increment", on_click=lambda: self.set_state(count=self.state["count"] + 1)),
|
|
16
|
-
spacing=12,
|
|
17
|
-
)
|
|
7
|
+
@pn.component
|
|
8
|
+
def App():
|
|
9
|
+
count, set_count = pn.use_state(0)
|
|
10
|
+
return pn.Column(
|
|
11
|
+
pn.Text(f"Count: {count}", style={"font_size": 24}),
|
|
12
|
+
pn.Button("+", on_click=lambda: set_count(count + 1)),
|
|
13
|
+
style={"spacing": 12},
|
|
14
|
+
)
|
|
18
15
|
"""
|
|
19
16
|
|
|
20
|
-
__version__ = "0.
|
|
17
|
+
__version__ = "0.7.0"
|
|
21
18
|
|
|
22
19
|
from .components import (
|
|
23
20
|
ActivityIndicator,
|
|
24
21
|
Button,
|
|
25
22
|
Column,
|
|
23
|
+
FlatList,
|
|
26
24
|
Image,
|
|
25
|
+
Modal,
|
|
26
|
+
Pressable,
|
|
27
27
|
ProgressBar,
|
|
28
28
|
Row,
|
|
29
|
+
SafeAreaView,
|
|
29
30
|
ScrollView,
|
|
31
|
+
Slider,
|
|
30
32
|
Spacer,
|
|
31
33
|
Switch,
|
|
32
34
|
Text,
|
|
33
35
|
TextInput,
|
|
36
|
+
View,
|
|
34
37
|
WebView,
|
|
35
38
|
)
|
|
36
39
|
from .element import Element
|
|
37
|
-
from .
|
|
40
|
+
from .hooks import (
|
|
41
|
+
Provider,
|
|
42
|
+
component,
|
|
43
|
+
create_context,
|
|
44
|
+
use_callback,
|
|
45
|
+
use_context,
|
|
46
|
+
use_effect,
|
|
47
|
+
use_memo,
|
|
48
|
+
use_navigation,
|
|
49
|
+
use_ref,
|
|
50
|
+
use_state,
|
|
51
|
+
)
|
|
52
|
+
from .page import create_page
|
|
53
|
+
from .style import StyleSheet, ThemeContext
|
|
38
54
|
|
|
39
55
|
__all__ = [
|
|
56
|
+
# Components
|
|
40
57
|
"ActivityIndicator",
|
|
41
58
|
"Button",
|
|
42
59
|
"Column",
|
|
43
|
-
"
|
|
60
|
+
"FlatList",
|
|
44
61
|
"Image",
|
|
45
|
-
"
|
|
62
|
+
"Modal",
|
|
63
|
+
"Pressable",
|
|
46
64
|
"ProgressBar",
|
|
47
65
|
"Row",
|
|
66
|
+
"SafeAreaView",
|
|
48
67
|
"ScrollView",
|
|
68
|
+
"Slider",
|
|
49
69
|
"Spacer",
|
|
50
70
|
"Switch",
|
|
51
71
|
"Text",
|
|
52
72
|
"TextInput",
|
|
73
|
+
"View",
|
|
53
74
|
"WebView",
|
|
75
|
+
# Core
|
|
76
|
+
"Element",
|
|
77
|
+
"create_page",
|
|
78
|
+
# Hooks
|
|
79
|
+
"component",
|
|
80
|
+
"create_context",
|
|
81
|
+
"use_callback",
|
|
82
|
+
"use_context",
|
|
83
|
+
"use_effect",
|
|
84
|
+
"use_memo",
|
|
85
|
+
"use_navigation",
|
|
86
|
+
"use_ref",
|
|
87
|
+
"use_state",
|
|
88
|
+
"Provider",
|
|
89
|
+
# Styling
|
|
90
|
+
"StyleSheet",
|
|
91
|
+
"ThemeContext",
|
|
54
92
|
]
|
pythonnative/cli/pn.py
CHANGED
|
@@ -2,6 +2,7 @@ import argparse
|
|
|
2
2
|
import hashlib
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
import shutil
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
@@ -44,47 +45,38 @@ def init_project(args: argparse.Namespace) -> None:
|
|
|
44
45
|
main_page_py = os.path.join(app_dir, "main_page.py")
|
|
45
46
|
if not os.path.exists(main_page_py) or args.force:
|
|
46
47
|
with open(main_page_py, "w", encoding="utf-8") as f:
|
|
47
|
-
f.write(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def render(self):
|
|
60
|
-
return pn.ScrollView(
|
|
61
|
-
pn.Column(
|
|
62
|
-
pn.Text("Hello from PythonNative!", font_size=24, bold=True),
|
|
63
|
-
pn.Text(f"Tapped {self.state['count']} times"),
|
|
64
|
-
pn.Button("Tap me", on_click=self.increment),
|
|
65
|
-
spacing=12,
|
|
66
|
-
padding=16,
|
|
67
|
-
alignment="fill",
|
|
68
|
-
)
|
|
48
|
+
f.write("""import pythonnative as pn
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pn.component
|
|
52
|
+
def MainPage():
|
|
53
|
+
count, set_count = pn.use_state(0)
|
|
54
|
+
return pn.ScrollView(
|
|
55
|
+
pn.Column(
|
|
56
|
+
pn.Text("Hello from PythonNative!", style={"font_size": 24, "bold": True}),
|
|
57
|
+
pn.Text(f"Tapped {count} times"),
|
|
58
|
+
pn.Button("Tap me", on_click=lambda: set_count(count + 1)),
|
|
59
|
+
style={"spacing": 12, "padding": 16, "align_items": "stretch"},
|
|
69
60
|
)
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
)
|
|
62
|
+
""")
|
|
72
63
|
|
|
73
64
|
# Create config
|
|
74
65
|
config = {
|
|
75
66
|
"name": project_name,
|
|
76
67
|
"appId": "com.example." + project_name.replace(" ", "").lower(),
|
|
77
68
|
"entryPoint": "app/main_page.py",
|
|
69
|
+
"pythonVersion": "3.11",
|
|
78
70
|
"ios": {},
|
|
79
71
|
"android": {},
|
|
80
72
|
}
|
|
81
73
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
82
74
|
json.dump(config, f, indent=2)
|
|
83
75
|
|
|
84
|
-
# Requirements
|
|
76
|
+
# Requirements (third-party packages only; pythonnative itself is bundled by the CLI)
|
|
85
77
|
if not os.path.exists(requirements_path) or args.force:
|
|
86
78
|
with open(requirements_path, "w", encoding="utf-8") as f:
|
|
87
|
-
f.write("
|
|
79
|
+
f.write("")
|
|
88
80
|
|
|
89
81
|
# .gitignore
|
|
90
82
|
default_gitignore = "# PythonNative\n" "__pycache__/\n" "*.pyc\n" ".venv/\n" "build/\n" ".DS_Store\n"
|
|
@@ -158,7 +150,11 @@ def _copy_bundled_template_dir(template_dir: str, destination: str) -> None:
|
|
|
158
150
|
|
|
159
151
|
|
|
160
152
|
def _github_json(url: str) -> Any:
|
|
161
|
-
|
|
153
|
+
headers: dict[str, str] = {"User-Agent": "pythonnative-cli"}
|
|
154
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
155
|
+
if token:
|
|
156
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
157
|
+
req = urllib.request.Request(url, headers=headers)
|
|
162
158
|
with urllib.request.urlopen(req) as r:
|
|
163
159
|
return json.loads(r.read().decode("utf-8"))
|
|
164
160
|
|
|
@@ -213,6 +209,43 @@ def create_ios_project(project_name: str, destination: str) -> None:
|
|
|
213
209
|
_copy_bundled_template_dir("ios_template", destination)
|
|
214
210
|
|
|
215
211
|
|
|
212
|
+
def _read_project_config() -> dict:
|
|
213
|
+
"""Read pythonnative.json from the current working directory."""
|
|
214
|
+
config_path = os.path.join(os.getcwd(), "pythonnative.json")
|
|
215
|
+
if os.path.exists(config_path):
|
|
216
|
+
with open(config_path, encoding="utf-8") as f:
|
|
217
|
+
return json.load(f)
|
|
218
|
+
return {}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _read_requirements(requirements_path: str) -> list[str]:
|
|
222
|
+
"""Read a requirements file and return non-empty, non-comment lines.
|
|
223
|
+
|
|
224
|
+
Exits with an error if pythonnative is listed — the CLI bundles it
|
|
225
|
+
directly, so it must not be installed separately via pip/Chaquopy.
|
|
226
|
+
"""
|
|
227
|
+
if not os.path.exists(requirements_path):
|
|
228
|
+
return []
|
|
229
|
+
with open(requirements_path, encoding="utf-8") as f:
|
|
230
|
+
lines = f.readlines()
|
|
231
|
+
result: list[str] = []
|
|
232
|
+
for line in lines:
|
|
233
|
+
stripped = line.strip()
|
|
234
|
+
if not stripped or stripped.startswith("#") or stripped.startswith("-"):
|
|
235
|
+
continue
|
|
236
|
+
pkg_name = re.split(r"[\[><=!;]", stripped)[0].strip()
|
|
237
|
+
if pkg_name.lower().replace("-", "_") == "pythonnative":
|
|
238
|
+
print(
|
|
239
|
+
"Error: 'pythonnative' must not be in requirements.txt.\n"
|
|
240
|
+
"The pn CLI automatically bundles the installed pythonnative into your app.\n"
|
|
241
|
+
"requirements.txt is for third-party packages only (e.g. humanize, requests).\n"
|
|
242
|
+
"Remove the pythonnative line from requirements.txt and try again."
|
|
243
|
+
)
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
result.append(stripped)
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
|
|
216
249
|
def run_project(args: argparse.Namespace) -> None:
|
|
217
250
|
"""
|
|
218
251
|
Run the specified project.
|
|
@@ -220,9 +253,15 @@ def run_project(args: argparse.Namespace) -> None:
|
|
|
220
253
|
# Determine the platform
|
|
221
254
|
platform: str = args.platform
|
|
222
255
|
prepare_only: bool = getattr(args, "prepare_only", False)
|
|
256
|
+
hot_reload: bool = getattr(args, "hot_reload", False)
|
|
257
|
+
|
|
258
|
+
# Read project configuration and save project root before any chdir
|
|
259
|
+
project_dir: str = os.getcwd()
|
|
260
|
+
config = _read_project_config()
|
|
261
|
+
python_version: str = config.get("pythonVersion", "3.11")
|
|
223
262
|
|
|
224
263
|
# Define the build directory
|
|
225
|
-
build_dir: str = os.path.join(
|
|
264
|
+
build_dir: str = os.path.join(project_dir, "build", platform)
|
|
226
265
|
|
|
227
266
|
# Create the build directory if it doesn't exist
|
|
228
267
|
os.makedirs(build_dir, exist_ok=True)
|
|
@@ -266,10 +305,30 @@ def run_project(args: argparse.Namespace) -> None:
|
|
|
266
305
|
# Non-fatal; fallback to the packaged PyPI dependency if present
|
|
267
306
|
pass
|
|
268
307
|
|
|
269
|
-
#
|
|
308
|
+
# Validate and read the user's requirements.txt
|
|
309
|
+
requirements_path = os.path.join(project_dir, "requirements.txt")
|
|
310
|
+
pip_reqs = _read_requirements(requirements_path)
|
|
311
|
+
|
|
312
|
+
if platform == "android":
|
|
313
|
+
# Patch the Android build.gradle with the configured Python version
|
|
314
|
+
app_build_gradle = os.path.join(build_dir, "android_template", "app", "build.gradle")
|
|
315
|
+
if os.path.exists(app_build_gradle):
|
|
316
|
+
with open(app_build_gradle, encoding="utf-8") as f:
|
|
317
|
+
content = f.read()
|
|
318
|
+
content = content.replace('version "3.11"', f'version "{python_version}"')
|
|
319
|
+
with open(app_build_gradle, "w", encoding="utf-8") as f:
|
|
320
|
+
f.write(content)
|
|
321
|
+
# Copy requirements.txt into the Android project for Chaquopy
|
|
322
|
+
android_reqs_path = os.path.join(build_dir, "android_template", "app", "requirements.txt")
|
|
323
|
+
if os.path.exists(requirements_path):
|
|
324
|
+
shutil.copy2(requirements_path, android_reqs_path)
|
|
325
|
+
else:
|
|
326
|
+
with open(android_reqs_path, "w", encoding="utf-8") as f:
|
|
327
|
+
f.write("")
|
|
328
|
+
|
|
329
|
+
# Install any necessary Python packages into the host environment
|
|
270
330
|
# Skip installation during prepare-only to avoid network access and speed up scaffolding
|
|
271
331
|
if not prepare_only:
|
|
272
|
-
requirements_path = os.path.join(os.getcwd(), "requirements.txt")
|
|
273
332
|
if os.path.exists(requirements_path):
|
|
274
333
|
subprocess.run([sys.executable, "-m", "pip", "install", "-r", requirements_path], check=False)
|
|
275
334
|
|
|
@@ -521,6 +580,29 @@ def run_project(args: argparse.Namespace) -> None:
|
|
|
521
580
|
except Exception:
|
|
522
581
|
# Non-fatal; if metadata isn't present, rubicon import may fail and fallback UI will appear
|
|
523
582
|
pass
|
|
583
|
+
# Install user's pip requirements (pure-Python packages) into the app bundle
|
|
584
|
+
if pip_reqs:
|
|
585
|
+
try:
|
|
586
|
+
reqs_tmp = os.path.join(build_dir, "ios_requirements.txt")
|
|
587
|
+
with open(reqs_tmp, "w", encoding="utf-8") as f:
|
|
588
|
+
f.write("\n".join(pip_reqs) + "\n")
|
|
589
|
+
tmp_reqs_dir = os.path.join(build_dir, "ios_user_packages")
|
|
590
|
+
if os.path.isdir(tmp_reqs_dir):
|
|
591
|
+
shutil.rmtree(tmp_reqs_dir)
|
|
592
|
+
os.makedirs(tmp_reqs_dir, exist_ok=True)
|
|
593
|
+
subprocess.run(
|
|
594
|
+
[sys.executable, "-m", "pip", "install", "-t", tmp_reqs_dir, "-r", reqs_tmp],
|
|
595
|
+
check=False,
|
|
596
|
+
)
|
|
597
|
+
for entry in os.listdir(tmp_reqs_dir):
|
|
598
|
+
src_entry = os.path.join(tmp_reqs_dir, entry)
|
|
599
|
+
dst_entry = os.path.join(platform_site_dir, entry)
|
|
600
|
+
if os.path.isdir(src_entry):
|
|
601
|
+
shutil.copytree(src_entry, dst_entry, dirs_exist_ok=True)
|
|
602
|
+
else:
|
|
603
|
+
shutil.copy2(src_entry, dst_entry)
|
|
604
|
+
except Exception:
|
|
605
|
+
pass
|
|
524
606
|
# Note: Python.xcframework provides a static library for Simulator; it must be linked at build time.
|
|
525
607
|
# We copy the XCFramework into the project directory above so Xcode can link it.
|
|
526
608
|
except Exception:
|
|
@@ -567,6 +649,39 @@ def run_project(args: argparse.Namespace) -> None:
|
|
|
567
649
|
except Exception:
|
|
568
650
|
print("Failed to auto-run on Simulator; open the project in Xcode to run.")
|
|
569
651
|
|
|
652
|
+
# Hot-reload file watcher
|
|
653
|
+
if hot_reload and not prepare_only:
|
|
654
|
+
_run_hot_reload(platform, project_dir, build_dir)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _run_hot_reload(platform: str, project_dir: str, build_dir: str) -> None:
|
|
658
|
+
"""Watch ``app/`` for changes and push updated files to the device."""
|
|
659
|
+
from .hot_reload import FileWatcher
|
|
660
|
+
|
|
661
|
+
app_dir = os.path.join(project_dir, "app")
|
|
662
|
+
|
|
663
|
+
def on_change(changed_files: List[str]) -> None:
|
|
664
|
+
for fpath in changed_files:
|
|
665
|
+
rel = os.path.relpath(fpath, project_dir)
|
|
666
|
+
print(f"[hot-reload] Changed: {rel}")
|
|
667
|
+
if platform == "android":
|
|
668
|
+
dest = f"/data/data/com.pythonnative.android_template/files/{rel}"
|
|
669
|
+
subprocess.run(["adb", "push", fpath, dest], check=False, capture_output=True)
|
|
670
|
+
elif platform == "ios":
|
|
671
|
+
pass # simctl file push would go here
|
|
672
|
+
|
|
673
|
+
print("[hot-reload] Watching app/ for changes. Press Ctrl+C to stop.")
|
|
674
|
+
watcher = FileWatcher(app_dir, on_change, interval=1.0)
|
|
675
|
+
watcher.start()
|
|
676
|
+
try:
|
|
677
|
+
import time
|
|
678
|
+
|
|
679
|
+
while True:
|
|
680
|
+
time.sleep(1)
|
|
681
|
+
except KeyboardInterrupt:
|
|
682
|
+
watcher.stop()
|
|
683
|
+
print("\n[hot-reload] Stopped.")
|
|
684
|
+
|
|
570
685
|
|
|
571
686
|
def clean_project(args: argparse.Namespace) -> None:
|
|
572
687
|
"""
|
|
@@ -601,6 +716,11 @@ def main() -> None:
|
|
|
601
716
|
action="store_true",
|
|
602
717
|
help="Extract templates and stage app without building",
|
|
603
718
|
)
|
|
719
|
+
parser_run.add_argument(
|
|
720
|
+
"--hot-reload",
|
|
721
|
+
action="store_true",
|
|
722
|
+
help="Watch app/ for changes and push updates to the running app",
|
|
723
|
+
)
|
|
604
724
|
parser_run.set_defaults(func=run_project)
|
|
605
725
|
|
|
606
726
|
# Create a new command 'clean' that calls clean_project
|