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.
Files changed (28) hide show
  1. pythonnative/__init__.py +53 -15
  2. pythonnative/cli/pn.py +150 -30
  3. pythonnative/components.py +217 -107
  4. pythonnative/element.py +14 -8
  5. pythonnative/hooks.py +334 -0
  6. pythonnative/hot_reload.py +143 -0
  7. pythonnative/native_modules/__init__.py +19 -0
  8. pythonnative/native_modules/camera.py +105 -0
  9. pythonnative/native_modules/file_system.py +131 -0
  10. pythonnative/native_modules/location.py +61 -0
  11. pythonnative/native_modules/notifications.py +151 -0
  12. pythonnative/native_views.py +638 -34
  13. pythonnative/page.py +138 -171
  14. pythonnative/reconciler.py +153 -20
  15. pythonnative/style.py +135 -0
  16. pythonnative/templates/android_template/app/build.gradle +2 -7
  17. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +2 -9
  18. pythonnative/templates/android_template/build.gradle +1 -1
  19. pythonnative/templates/ios_template/ios_template/ViewController.swift +7 -20
  20. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/METADATA +18 -38
  21. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/RECORD +25 -20
  22. pythonnative/collection_view.py +0 -0
  23. pythonnative/material_bottom_navigation_view.py +0 -0
  24. pythonnative/material_toolbar.py +0 -0
  25. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/WHEEL +0 -0
  26. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/entry_points.txt +0 -0
  27. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/licenses/LICENSE +0 -0
  28. {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
- class MainPage(pn.Page):
8
- def __init__(self, native_instance):
9
- super().__init__(native_instance)
10
- self.state = {"count": 0}
11
-
12
- def render(self):
13
- return pn.Column(
14
- pn.Text(f"Count: {self.state['count']}", font_size=24),
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.5.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 .page import Page
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
- "Element",
60
+ "FlatList",
44
61
  "Image",
45
- "Page",
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
- """import pythonnative as pn
49
-
50
-
51
- class MainPage(pn.Page):
52
- def __init__(self, native_instance):
53
- super().__init__(native_instance)
54
- self.state = {"count": 0}
55
-
56
- def increment(self):
57
- self.set_state(count=self.state["count"] + 1)
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("pythonnative\n")
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
- req = urllib.request.Request(url, headers={"User-Agent": "pythonnative-cli"})
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(os.getcwd(), "build", platform)
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
- # Install any necessary Python packages into the project environment
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