pythonnative 0.9.0__tar.gz → 0.10.0__tar.gz

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 (106) hide show
  1. {pythonnative-0.9.0/src/pythonnative.egg-info → pythonnative-0.10.0}/PKG-INFO +3 -1
  2. {pythonnative-0.9.0 → pythonnative-0.10.0}/pyproject.toml +29 -3
  3. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/__init__.py +28 -3
  4. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/_ios_log.py +20 -22
  5. pythonnative-0.10.0/src/pythonnative/cli/__init__.py +7 -0
  6. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/cli/pn.py +275 -45
  7. pythonnative-0.10.0/src/pythonnative/components.py +649 -0
  8. pythonnative-0.10.0/src/pythonnative/element.py +77 -0
  9. pythonnative-0.10.0/src/pythonnative/hooks.py +706 -0
  10. pythonnative-0.10.0/src/pythonnative/hot_reload.py +329 -0
  11. pythonnative-0.10.0/src/pythonnative/native_modules/__init__.py +25 -0
  12. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/native_modules/camera.py +32 -16
  13. pythonnative-0.10.0/src/pythonnative/native_modules/file_system.py +241 -0
  14. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/native_modules/location.py +32 -9
  15. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/native_modules/notifications.py +51 -18
  16. pythonnative-0.10.0/src/pythonnative/native_views/__init__.py +172 -0
  17. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/native_views/android.py +14 -7
  18. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/native_views/base.py +68 -11
  19. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/native_views/ios.py +13 -6
  20. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/navigation.py +288 -63
  21. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/page.py +232 -47
  22. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/reconciler.py +59 -25
  23. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/style.py +79 -18
  24. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +4 -0
  25. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +23 -0
  26. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/ViewController.swift +34 -1
  27. pythonnative-0.10.0/src/pythonnative/utils.py +184 -0
  28. {pythonnative-0.9.0 → pythonnative-0.10.0/src/pythonnative.egg-info}/PKG-INFO +3 -1
  29. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative.egg-info/SOURCES.txt +2 -0
  30. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative.egg-info/requires.txt +2 -0
  31. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_cli.py +73 -0
  32. pythonnative-0.10.0/tests/test_hot_reload.py +97 -0
  33. pythonnative-0.10.0/tests/test_page.py +68 -0
  34. pythonnative-0.9.0/src/pythonnative/cli/__init__.py +0 -0
  35. pythonnative-0.9.0/src/pythonnative/components.py +0 -408
  36. pythonnative-0.9.0/src/pythonnative/element.py +0 -53
  37. pythonnative-0.9.0/src/pythonnative/hooks.py +0 -440
  38. pythonnative-0.9.0/src/pythonnative/hot_reload.py +0 -143
  39. pythonnative-0.9.0/src/pythonnative/native_modules/__init__.py +0 -19
  40. pythonnative-0.9.0/src/pythonnative/native_modules/file_system.py +0 -131
  41. pythonnative-0.9.0/src/pythonnative/native_views/__init__.py +0 -87
  42. pythonnative-0.9.0/src/pythonnative/utils.py +0 -122
  43. {pythonnative-0.9.0 → pythonnative-0.10.0}/LICENSE +0 -0
  44. {pythonnative-0.9.0 → pythonnative-0.10.0}/README.md +0 -0
  45. {pythonnative-0.9.0 → pythonnative-0.10.0}/setup.cfg +0 -0
  46. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/build.gradle +0 -0
  47. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/proguard-rules.pro +0 -0
  48. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/androidTest/java/com/pythonnative/android_template/ExampleInstrumentedTest.kt +0 -0
  49. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +0 -0
  50. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +0 -0
  51. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/drawable/ic_launcher_background.xml +0 -0
  52. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +0 -0
  53. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/layout/activity_main.xml +0 -0
  54. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +0 -0
  55. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +0 -0
  56. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher.webp +0 -0
  57. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp +0 -0
  58. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher.webp +0 -0
  59. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp +0 -0
  60. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher.webp +0 -0
  61. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp +0 -0
  62. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp +0 -0
  63. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp +0 -0
  64. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp +0 -0
  65. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp +0 -0
  66. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +0 -0
  67. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/values/colors.xml +0 -0
  68. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/values/strings.xml +0 -0
  69. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/values/themes.xml +0 -0
  70. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/values-night/themes.xml +0 -0
  71. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/xml/backup_rules.xml +0 -0
  72. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/main/res/xml/data_extraction_rules.xml +0 -0
  73. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/app/src/test/java/com/pythonnative/android_template/ExampleUnitTest.kt +0 -0
  74. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/build.gradle +0 -0
  75. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.jar +0 -0
  76. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +0 -0
  77. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/gradle.properties +0 -0
  78. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/gradlew +0 -0
  79. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/gradlew.bat +0 -0
  80. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/android_template/settings.gradle +0 -0
  81. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/AppDelegate.swift +0 -0
  82. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AccentColor.colorset/Contents.json +0 -0
  83. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -0
  84. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/Assets.xcassets/Contents.json +0 -0
  85. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/LaunchScreen.storyboard +0 -0
  86. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -0
  87. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/Info.plist +0 -0
  88. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +0 -0
  89. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +0 -0
  90. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_template.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -0
  91. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_templateTests/ios_templateTests.swift +0 -0
  92. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +0 -0
  93. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITestsLaunchTests.swift +0 -0
  94. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative.egg-info/dependency_links.txt +0 -0
  95. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative.egg-info/entry_points.txt +0 -0
  96. {pythonnative-0.9.0 → pythonnative-0.10.0}/src/pythonnative.egg-info/top_level.txt +0 -0
  97. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_components.py +0 -0
  98. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_element.py +0 -0
  99. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_hooks.py +0 -0
  100. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_ios_log.py +0 -0
  101. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_native_views.py +0 -0
  102. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_navigation.py +0 -0
  103. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_reconciler.py +0 -0
  104. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_smoke.py +0 -0
  105. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_style.py +0 -0
  106. {pythonnative-0.9.0 → pythonnative-0.10.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Cross-platform native UI toolkit for Android and iOS
5
5
  Author: Owen Carey
6
6
  License: MIT License
@@ -48,6 +48,8 @@ Provides-Extra: docs
48
48
  Requires-Dist: mkdocs>=1.5; extra == "docs"
49
49
  Requires-Dist: mkdocs-material[imaging]>=9.5; extra == "docs"
50
50
  Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
51
+ Requires-Dist: mkdocs-autorefs>=1.0; extra == "docs"
52
+ Requires-Dist: pymdown-extensions>=10.7; extra == "docs"
51
53
  Provides-Extra: dev
52
54
  Requires-Dist: black>=24.0; extra == "dev"
53
55
  Requires-Dist: ruff>=0.5; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythonnative"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "Cross-platform native UI toolkit for Android and iOS"
9
9
  authors = [
10
10
  { name = "Owen Carey" }
@@ -35,6 +35,8 @@ docs = [
35
35
  "mkdocs>=1.5",
36
36
  "mkdocs-material[imaging]>=9.5",
37
37
  "mkdocstrings[python]>=0.24",
38
+ "mkdocs-autorefs>=1.0",
39
+ "pymdown-extensions>=10.7",
38
40
  ]
39
41
  dev = [
40
42
  "black>=24.0",
@@ -81,8 +83,32 @@ extend-exclude = [
81
83
  ]
82
84
 
83
85
  [tool.ruff.lint]
84
- select = ["E", "F", "I"]
85
- ignore = []
86
+ # Docstring (D) rules use the Google convention; see [tool.ruff.lint.pydocstyle].
87
+ # Selectively enabled so tests/examples/templates aren't penalized.
88
+ select = ["E", "F", "I", "D"]
89
+ # D107 (missing __init__ docstring): mkdocstrings is configured to merge
90
+ # __init__ into the class docstring, so the class docstring is the source of truth.
91
+ # D105 (magic method docstring): most are self-explanatory (__repr__, __eq__, etc.).
92
+ # D203/D213: conflict with Google convention defaults; ruff auto-suppresses
93
+ # these via `convention = "google"`, but we list them defensively.
94
+ ignore = ["D107", "D105", "D203", "D213"]
95
+
96
+ [tool.ruff.lint.per-file-ignores]
97
+ "tests/**/*.py" = ["D"]
98
+ "examples/**/*.py" = ["D"]
99
+ "src/pythonnative/templates/**/*.py" = ["D"]
100
+ "setup.py" = ["D"]
101
+ "conftest.py" = ["D"]
102
+ # Platform handler subclasses implement the ViewHandler ABC defined in
103
+ # native_views/base.py, which carries the canonical docstrings for the
104
+ # protocol. The concrete classes are internal (registered by name in
105
+ # NativeViewRegistry, never imported by users), so requiring per-method
106
+ # docstrings would only add boilerplate that repeats the ABC.
107
+ "src/pythonnative/native_views/android.py" = ["D101", "D102"]
108
+ "src/pythonnative/native_views/ios.py" = ["D101", "D102"]
109
+
110
+ [tool.ruff.lint.pydocstyle]
111
+ convention = "google"
86
112
 
87
113
  [tool.black]
88
114
  line-length = 120
@@ -1,7 +1,31 @@
1
- """PythonNative declarative native UI for Android and iOS.
1
+ """PythonNative: declarative native UI for Android and iOS.
2
2
 
3
- Public API::
3
+ PythonNative is a cross-platform toolkit that turns Python ``@component``
4
+ functions into real, native Android and iOS views. The component model
5
+ is React-like (function components plus hooks), but rendering happens
6
+ through direct platform bindings: Chaquopy on Android (Java) and
7
+ rubicon-objc on iOS (Objective-C). There is no JavaScript bridge.
4
8
 
9
+ Key building blocks:
10
+
11
+ - **Element factories** ([`Text`][pythonnative.Text],
12
+ [`Button`][pythonnative.Button], [`Column`][pythonnative.Column], etc.)
13
+ return immutable [`Element`][pythonnative.Element] descriptors.
14
+ - **Hooks** ([`use_state`][pythonnative.use_state],
15
+ [`use_effect`][pythonnative.use_effect],
16
+ [`use_reducer`][pythonnative.use_reducer], etc.) manage state, side
17
+ effects, and context inside `@component` functions.
18
+ - **Navigation** is built from
19
+ [`NavigationContainer`][pythonnative.NavigationContainer] plus one of
20
+ the [`create_stack_navigator`][pythonnative.create_stack_navigator],
21
+ [`create_tab_navigator`][pythonnative.create_tab_navigator], or
22
+ [`create_drawer_navigator`][pythonnative.create_drawer_navigator]
23
+ factories.
24
+ - **Styling** uses a single ``style`` dict per element (or a list of
25
+ dicts), composable via [`StyleSheet`][pythonnative.StyleSheet].
26
+
27
+ Example:
28
+ ```python
5
29
  import pythonnative as pn
6
30
 
7
31
  @pn.component
@@ -12,9 +36,10 @@ Public API::
12
36
  pn.Button("+", on_click=lambda: set_count(count + 1)),
13
37
  style={"spacing": 12},
14
38
  )
39
+ ```
15
40
  """
16
41
 
17
- __version__ = "0.9.0"
42
+ __version__ = "0.10.0"
18
43
 
19
44
  from .components import (
20
45
  ActivityIndicator,
@@ -1,24 +1,22 @@
1
- """Route Python ``sys.stdout``/``sys.stderr`` through fd 2 on iOS.
2
-
3
- Why
4
- ---
5
- When an app is launched via ``xcrun simctl launch --console-pty`` (what
6
- ``pn run ios`` does), the simulator attaches the caller's terminal to
7
- the app's stderr, which is the same channel ``NSLog`` / ``os_log``
8
- writes to. Python ``print()`` calls, however, go to ``sys.stdout``
9
- (fd 1), and for reasons specific to how CPython's embedded framework
10
- is started on the iOS Simulator that descriptor does not reach the
11
- attached console. As a result users see Swift-side ``NSLog`` output
12
- but never their own ``print()`` output.
13
-
14
- Redirecting ``sys.stdout`` / ``sys.stderr`` at a Python level to write
1
+ """Route Python `sys.stdout`/`sys.stderr` through fd 2 on iOS.
2
+
3
+ When an app is launched via `xcrun simctl launch --console-pty`
4
+ (what `pn run ios` does), the simulator attaches the caller's
5
+ terminal to the app's stderr, which is the same channel `NSLog` and
6
+ `os_log` write to. Python `print()` calls, however, go to
7
+ `sys.stdout` (fd 1), and for reasons specific to how CPython's
8
+ embedded framework is started on the iOS Simulator that descriptor
9
+ does not reach the attached console. As a result, users see
10
+ Swift-side `NSLog` output but never their own `print()` output.
11
+
12
+ Redirecting `sys.stdout` and `sys.stderr` at a Python level to write
15
13
  straight to fd 2 is a small, reliable fix: fd 2 *is* visible to
16
- ``simctl`` (that's exactly how ``NSLog`` reaches the terminal), so
14
+ `simctl` (that is exactly how `NSLog` reaches the terminal), so
17
15
  Python output lands next to the Swift logs with correct ordering.
18
16
 
19
- This module is intentionally self-contained: no rubicon-objc or
20
- platform-specific C bindings required, so it's safe to import early
21
- during ``pythonnative`` package initialization.
17
+ This module is intentionally self-contained (no rubicon-objc or
18
+ platform-specific C bindings required), so it is safe to import
19
+ early during `pythonnative` package initialization.
22
20
  """
23
21
 
24
22
  from __future__ import annotations
@@ -33,8 +31,8 @@ _STDERR_FD = 2
33
31
  class _StderrStream:
34
32
  """Minimal text-mode file-like that writes UTF-8 bytes to fd 2.
35
33
 
36
- It's write-through (no buffering) so a ``print()`` call appears in
37
- the terminal immediately, which matches user expectations for an
34
+ Write-through (no buffering), so a `print()` call appears in the
35
+ terminal immediately. That matches user expectations for an
38
36
  interactive "run on simulator" log stream.
39
37
  """
40
38
 
@@ -82,9 +80,9 @@ _installed = False
82
80
 
83
81
 
84
82
  def install() -> None:
85
- """Swap ``sys.stdout`` / ``sys.stderr`` for fd-2 writers.
83
+ """Swap `sys.stdout` and `sys.stderr` for fd-2 writers.
86
84
 
87
- Safe to call multiple times; only the first call has effect.
85
+ Idempotent: only the first call has effect.
88
86
  """
89
87
  global _installed
90
88
  if _installed:
@@ -0,0 +1,7 @@
1
+ """Command-line interface package for the `pn` script.
2
+
3
+ The CLI entry point lives in
4
+ [`pythonnative.cli.pn`][pythonnative.cli.pn] and is exposed as the
5
+ `pn` console script (configured in `pyproject.toml`'s
6
+ `[project.scripts]`).
7
+ """
@@ -1,3 +1,19 @@
1
+ """`pn` CLI: scaffold, run, and clean PythonNative projects.
2
+
3
+ The console script `pn` (declared in `pyproject.toml` under
4
+ `[project.scripts]`) dispatches to one of three subcommands:
5
+
6
+ - `pn init [name]`: scaffold a new project in the current directory.
7
+ - `pn run android|ios`: stage code into a native template, build it,
8
+ install it, and stream logs back to the terminal.
9
+ - `pn clean`: remove the local `build/` directory.
10
+
11
+ The implementation here is intentionally side-effect heavy: it shells
12
+ out to `gradle`, `xcodebuild`, `adb`, and `xcrun simctl`. Errors from
13
+ those tools are usually surfaced inline so the developer sees the
14
+ underlying message.
15
+ """
16
+
1
17
  import argparse
2
18
  import hashlib
3
19
  import json
@@ -7,15 +23,25 @@ import shutil
7
23
  import subprocess
8
24
  import sys
9
25
  import sysconfig
26
+ import time
10
27
  import urllib.request
11
28
  from importlib import resources
12
29
  from typing import Any, Dict, List, Optional
13
30
 
14
31
 
15
32
  def init_project(args: argparse.Namespace) -> None:
16
- """
17
- Initialize a new PythonNative project.
18
- Creates `app/`, `pythonnative.json`, `requirements.txt`, `.gitignore`.
33
+ """Scaffold a new PythonNative project in the current directory.
34
+
35
+ Creates `app/main_page.py`, `pythonnative.json`,
36
+ `requirements.txt`, and `.gitignore`. Refuses to overwrite
37
+ existing files unless `--force` is passed.
38
+
39
+ Args:
40
+ args: The parsed argparse namespace. Recognized attributes:
41
+
42
+ - `name` (`str`, optional): Project name (defaults to the
43
+ current directory name).
44
+ - `force` (`bool`): Overwrite existing files.
19
45
  """
20
46
  project_name: str = getattr(args, "name", None) or os.path.basename(os.getcwd())
21
47
  cwd: str = os.getcwd()
@@ -88,16 +114,29 @@ def MainPage():
88
114
 
89
115
 
90
116
  def _copy_dir(src: str, dst: str) -> None:
117
+ """Recursively copy `src` into `dst`, creating parents as needed."""
91
118
  os.makedirs(os.path.dirname(dst), exist_ok=True)
92
119
  shutil.copytree(src, dst, dirs_exist_ok=True)
93
120
 
94
121
 
95
122
  def _copy_bundled_template_dir(template_dir: str, destination: str) -> None:
96
- """
97
- Copy a bundled template directory into the destination directory.
98
- Tries the repository `templates/` first during development, then
99
- package resources when installed from a wheel.
100
- The result should be `${destination}/{template_dir}`.
123
+ """Copy a bundled template directory into `destination`.
124
+
125
+ Search order:
126
+
127
+ 1. Local source checkout (`src/pythonnative/templates/<name>`).
128
+ 2. Repository `templates/<name>` (used when running from a clone).
129
+ 3. Installed package data via `importlib.resources`.
130
+ 4. `sysconfig` data/site directories (last resort).
131
+
132
+ Args:
133
+ template_dir: The bundled template subdirectory to copy
134
+ (e.g., `"android_template"`).
135
+ destination: Parent directory; the template lands at
136
+ `<destination>/<template_dir>`.
137
+
138
+ Raises:
139
+ FileNotFoundError: If no bundled copy can be located.
101
140
  """
102
141
  dest_path = os.path.join(destination, template_dir)
103
142
 
@@ -150,6 +189,11 @@ def _copy_bundled_template_dir(template_dir: str, destination: str) -> None:
150
189
 
151
190
 
152
191
  def _github_json(url: str) -> Any:
192
+ """Fetch a GitHub JSON endpoint, optionally authenticated.
193
+
194
+ Reads `GITHUB_TOKEN` or `GH_TOKEN` from the environment to raise
195
+ the unauthenticated rate limit.
196
+ """
153
197
  headers: dict[str, str] = {"User-Agent": "pythonnative-cli"}
154
198
  token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
155
199
  if token:
@@ -162,10 +206,21 @@ def _github_json(url: str) -> Any:
162
206
  def _resolve_python_apple_support_asset(
163
207
  py_major_minor: str = "3.11", preferred_name: str = "Python-3.11-iOS-support.b7.tar.gz"
164
208
  ) -> Optional[str]:
165
- """
166
- Find a browser_download_url for a Python-Apple-support asset on GitHub Releases.
167
- Prefers an exact name match (preferred_name). Falls back to the newest
168
- asset whose name contains "Python-{py_major_minor}-iOS-support" and endswith .tar.gz.
209
+ """Resolve a download URL for a `Python-Apple-support` release asset.
210
+
211
+ Prefers an exact name match for `preferred_name`; otherwise falls
212
+ back to the newest asset whose name contains
213
+ `Python-{py_major_minor}-iOS-support` and ends with `.tar.gz`.
214
+
215
+ Args:
216
+ py_major_minor: Python version string used in the asset name
217
+ (e.g., `"3.11"`).
218
+ preferred_name: Exact filename to prefer when multiple matching
219
+ assets exist.
220
+
221
+ Returns:
222
+ A `browser_download_url` string, or `None` if the GitHub API
223
+ call fails or no matching asset is found.
169
224
  """
170
225
  try:
171
226
  releases = _github_json("https://api.github.com/repos/beeware/Python-Apple-support/releases?per_page=100")
@@ -188,29 +243,33 @@ def _resolve_python_apple_support_asset(
188
243
 
189
244
 
190
245
  def create_android_project(project_name: str, destination: str) -> None:
191
- """
192
- Create a new Android project using a template.
246
+ """Stage the bundled Android template into `destination`.
193
247
 
194
- :param project_name: The name of the project.
195
- :param destination: The directory where the project will be created.
248
+ Args:
249
+ project_name: Project name (currently informational; the
250
+ template uses fixed package IDs).
251
+ destination: Directory to receive the staged project.
196
252
  """
197
- # Copy the Android template project directory
198
253
  _copy_bundled_template_dir("android_template", destination)
199
254
 
200
255
 
201
256
  def create_ios_project(project_name: str, destination: str) -> None:
202
- """
203
- Create a new iOS project using a template.
257
+ """Stage the bundled iOS template into `destination`.
204
258
 
205
- :param project_name: The name of the project.
206
- :param destination: The directory where the project will be created.
259
+ Args:
260
+ project_name: Project name (currently informational; the
261
+ template uses fixed bundle IDs).
262
+ destination: Directory to receive the staged project.
207
263
  """
208
- # Copy the iOS template project directory
209
264
  _copy_bundled_template_dir("ios_template", destination)
210
265
 
211
266
 
212
267
  def _read_project_config() -> dict:
213
- """Read pythonnative.json from the current working directory."""
268
+ """Read `pythonnative.json` from the current working directory.
269
+
270
+ Returns:
271
+ The parsed config dict, or `{}` if the file is missing.
272
+ """
214
273
  config_path = os.path.join(os.getcwd(), "pythonnative.json")
215
274
  if os.path.exists(config_path):
216
275
  with open(config_path, encoding="utf-8") as f:
@@ -221,8 +280,15 @@ def _read_project_config() -> dict:
221
280
  def _read_requirements(requirements_path: str) -> list[str]:
222
281
  """Read a requirements file and return non-empty, non-comment lines.
223
282
 
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.
283
+ Exits with an error if `pythonnative` is listed: the CLI bundles
284
+ it directly, so it must not be installed separately via pip or
285
+ Chaquopy.
286
+
287
+ Args:
288
+ requirements_path: Path to a `requirements.txt` file.
289
+
290
+ Returns:
291
+ A list of requirement specifier strings, in file order.
226
292
  """
227
293
  if not os.path.exists(requirements_path):
228
294
  return []
@@ -246,6 +312,9 @@ def _read_requirements(requirements_path: str) -> list[str]:
246
312
  return result
247
313
 
248
314
 
315
+ ANDROID_PACKAGE_ID: str = "com.pythonnative.android_template"
316
+ HOT_RELOAD_DEV_ROOT: str = "pythonnative_dev"
317
+
249
318
  ANDROID_LOGCAT_FILTERS: list[str] = [
250
319
  "python.stdout:V",
251
320
  "python.stderr:V",
@@ -264,10 +333,13 @@ IOS_BUNDLE_ID: str = "com.pythonnative.ios-template"
264
333
  def _start_android_log_stream() -> Optional[subprocess.Popen]:
265
334
  """Clear logcat and stream Python-relevant log tags to the terminal.
266
335
 
267
- Returns the ``adb logcat`` subprocess, or ``None`` when ``adb`` is
268
- unavailable. Python's ``print()`` output reaches logcat via Chaquopy,
269
- which redirects ``sys.stdout``/``sys.stderr`` to the ``python.stdout``
270
- and ``python.stderr`` tags.
336
+ Python's `print()` output reaches logcat via Chaquopy, which
337
+ redirects `sys.stdout`/`sys.stderr` to the `python.stdout` and
338
+ `python.stderr` tags.
339
+
340
+ Returns:
341
+ The `adb logcat` subprocess, or `None` when `adb` is
342
+ unavailable on `PATH`.
271
343
  """
272
344
  try:
273
345
  subprocess.run(["adb", "logcat", "-c"], check=False, capture_output=True)
@@ -283,7 +355,10 @@ def _start_android_log_stream() -> Optional[subprocess.Popen]:
283
355
 
284
356
 
285
357
  def _terminate_subprocess(proc: Optional[subprocess.Popen]) -> None:
286
- """Politely stop a subprocess, escalating to SIGKILL if needed."""
358
+ """Politely stop a subprocess, escalating to `SIGKILL` if needed.
359
+
360
+ A no-op when `proc` is `None` or has already exited.
361
+ """
287
362
  if proc is None:
288
363
  return
289
364
  if proc.poll() is not None:
@@ -295,9 +370,141 @@ def _terminate_subprocess(proc: Optional[subprocess.Popen]) -> None:
295
370
  proc.kill()
296
371
 
297
372
 
373
+ def _hot_reload_manifest_payload(
374
+ changed_files: List[str],
375
+ project_dir: str,
376
+ *,
377
+ version: Optional[str] = None,
378
+ ) -> Dict[str, Any]:
379
+ """Build the reload manifest consumed by the running app."""
380
+ from pythonnative.hot_reload import ModuleReloader
381
+
382
+ rel_files = sorted(os.path.relpath(path, project_dir) for path in changed_files)
383
+ return {
384
+ "version": version or str(time.time_ns()),
385
+ "files": rel_files,
386
+ "modules": ModuleReloader.modules_from_files(rel_files),
387
+ }
388
+
389
+
390
+ def _write_hot_reload_manifest(changed_files: List[str], project_dir: str, build_dir: str) -> str:
391
+ """Write a local hot-reload manifest and return its path."""
392
+ manifest_dir = os.path.join(build_dir, "hot_reload")
393
+ os.makedirs(manifest_dir, exist_ok=True)
394
+ manifest_path = os.path.join(manifest_dir, "reload.json")
395
+ with open(manifest_path, "w", encoding="utf-8") as f:
396
+ json.dump(_hot_reload_manifest_payload(changed_files, project_dir), f)
397
+ return manifest_path
398
+
399
+
400
+ def _android_hot_reload_dest(rel_path: str) -> str:
401
+ """Return a `run-as` relative destination for an app source file."""
402
+ return os.path.join("files", HOT_RELOAD_DEV_ROOT, rel_path)
403
+
404
+
405
+ def _push_android_hot_reload_file(local_path: str, rel_path: str) -> bool:
406
+ """Push one file into the Android app's writable hot-reload overlay."""
407
+ tmp_path = f"/data/local/tmp/pythonnative-hot-reload-{os.getpid()}-{os.path.basename(local_path)}"
408
+ dest_path = _android_hot_reload_dest(rel_path)
409
+ dest_dir = os.path.dirname(dest_path)
410
+ push = subprocess.run(["adb", "push", local_path, tmp_path], check=False, capture_output=True)
411
+ if push.returncode != 0:
412
+ return False
413
+ subprocess.run(
414
+ ["adb", "shell", "run-as", ANDROID_PACKAGE_ID, "mkdir", "-p", dest_dir],
415
+ check=False,
416
+ capture_output=True,
417
+ )
418
+ copy = subprocess.run(
419
+ ["adb", "shell", "run-as", ANDROID_PACKAGE_ID, "cp", tmp_path, dest_path],
420
+ check=False,
421
+ capture_output=True,
422
+ )
423
+ subprocess.run(["adb", "shell", "rm", "-f", tmp_path], check=False, capture_output=True)
424
+ return copy.returncode == 0
425
+
426
+
427
+ def _ios_data_container() -> Optional[str]:
428
+ """Return the booted simulator's app data container, if available."""
429
+ try:
430
+ result = subprocess.run(
431
+ ["xcrun", "simctl", "get_app_container", "booted", IOS_BUNDLE_ID, "data"],
432
+ check=False,
433
+ capture_output=True,
434
+ text=True,
435
+ )
436
+ except FileNotFoundError:
437
+ return None
438
+ if result.returncode != 0:
439
+ return None
440
+ container = result.stdout.strip()
441
+ return container or None
442
+
443
+
444
+ def _push_ios_hot_reload_file(local_path: str, rel_path: str) -> bool:
445
+ """Copy one file into the booted iOS Simulator's hot-reload overlay."""
446
+ container = _ios_data_container()
447
+ if container is None:
448
+ return False
449
+ dest_path = os.path.join(container, "Documents", HOT_RELOAD_DEV_ROOT, rel_path)
450
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
451
+ shutil.copy2(local_path, dest_path)
452
+ return True
453
+
454
+
455
+ def _clear_android_hot_reload_overlay() -> bool:
456
+ """Remove stale Android hot-reload files before launching."""
457
+ result = subprocess.run(
458
+ ["adb", "shell", "run-as", ANDROID_PACKAGE_ID, "rm", "-rf", f"files/{HOT_RELOAD_DEV_ROOT}"],
459
+ check=False,
460
+ capture_output=True,
461
+ )
462
+ return result.returncode == 0
463
+
464
+
465
+ def _clear_ios_hot_reload_overlay() -> bool:
466
+ """Remove stale iOS Simulator hot-reload files before launching."""
467
+ container = _ios_data_container()
468
+ if container is None:
469
+ return False
470
+ shutil.rmtree(os.path.join(container, "Documents", HOT_RELOAD_DEV_ROOT), ignore_errors=True)
471
+ return True
472
+
473
+
474
+ def _clear_hot_reload_overlay(platform: str) -> bool:
475
+ """Remove stale hot-reload overlay files for `platform`."""
476
+ if platform == "android":
477
+ return _clear_android_hot_reload_overlay()
478
+ if platform == "ios":
479
+ return _clear_ios_hot_reload_overlay()
480
+ return False
481
+
482
+
483
+ def _push_hot_reload_file(platform: str, local_path: str, rel_path: str) -> bool:
484
+ """Push a changed source file to the running app."""
485
+ if platform == "android":
486
+ return _push_android_hot_reload_file(local_path, rel_path)
487
+ if platform == "ios":
488
+ return _push_ios_hot_reload_file(local_path, rel_path)
489
+ return False
490
+
491
+
298
492
  def run_project(args: argparse.Namespace) -> None:
299
- """
300
- Run the specified project.
493
+ """Build and run the project on the requested platform.
494
+
495
+ Stages templates, copies the user's `app/` into the platform
496
+ project, optionally installs Python requirements, and (unless
497
+ `--prepare-only` is set) builds and launches the app on a
498
+ connected device or simulator. With `--hot-reload`, also watches
499
+ `app/` for changes and pushes updates to the device.
500
+
501
+ Args:
502
+ args: Parsed argparse namespace. Recognized attributes:
503
+
504
+ - `platform` (`"android"` | `"ios"`): Build target.
505
+ - `prepare_only` (`bool`): Stage files but skip the build.
506
+ - `hot_reload` (`bool`): Watch `app/` and push changes.
507
+ - `no_logs` (`bool`): Don't stream device logs after launch.
301
508
  """
302
509
  # Determine the platform
303
510
  platform: str = args.platform
@@ -407,6 +614,8 @@ def run_project(args: argparse.Namespace) -> None:
407
614
  pass
408
615
  subprocess.run(["./gradlew", "installDebug"], check=True, env=env)
409
616
 
617
+ _clear_hot_reload_overlay(platform)
618
+
410
619
  # Run the Android app
411
620
  # Assumes that the package name of your app is "com.example.myapp" and the main activity is "MainActivity"
412
621
  # Replace "com.example.myapp" and ".MainActivity" with your actual package name and main activity
@@ -417,7 +626,7 @@ def run_project(args: argparse.Namespace) -> None:
417
626
  "am",
418
627
  "start",
419
628
  "-n",
420
- "com.pythonnative.android_template/.MainActivity",
629
+ f"{ANDROID_PACKAGE_ID}/.MainActivity",
421
630
  ],
422
631
  check=True,
423
632
  )
@@ -708,6 +917,7 @@ def run_project(args: argparse.Namespace) -> None:
708
917
  subprocess.run(["xcrun", "simctl", "boot", udid], check=False, capture_output=True)
709
918
  # Install
710
919
  subprocess.run(["xcrun", "simctl", "install", udid, app_path], check=False)
920
+ _clear_hot_reload_overlay(platform)
711
921
  if show_logs and not hot_reload:
712
922
  # Attach the app's stdout/stderr to this terminal so Python
713
923
  # print() calls and exceptions are visible. SIMCTL_CHILD_*
@@ -754,25 +964,37 @@ def run_project(args: argparse.Namespace) -> None:
754
964
 
755
965
 
756
966
  def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs: bool = True) -> None:
757
- """Watch ``app/`` for changes and push updated files to the device.
967
+ """Watch `app/` for changes and push updated files to the device.
758
968
 
759
- When ``show_logs`` is true and targeting Android, ``adb logcat`` is
760
- streamed in parallel so Python print/exception output stays visible
761
- alongside hot-reload notifications.
969
+ When `show_logs` is true and targeting Android, `adb logcat` is
970
+ streamed in parallel so Python print and exception output stays
971
+ visible alongside hot-reload notifications.
972
+
973
+ Args:
974
+ platform: Either `"android"` or `"ios"`.
975
+ project_dir: Absolute path to the user's project root.
976
+ build_dir: Absolute path to the staged build directory.
977
+ show_logs: Whether to stream device logs in parallel.
762
978
  """
763
- from .hot_reload import FileWatcher
979
+ from ..hot_reload import FileWatcher
764
980
 
765
981
  app_dir = os.path.join(project_dir, "app")
766
982
 
767
983
  def on_change(changed_files: List[str]) -> None:
984
+ pushed: List[str] = []
768
985
  for fpath in changed_files:
769
986
  rel = os.path.relpath(fpath, project_dir)
770
987
  print(f"[hot-reload] Changed: {rel}")
771
- if platform == "android":
772
- dest = f"/data/data/com.pythonnative.android_template/files/{rel}"
773
- subprocess.run(["adb", "push", fpath, dest], check=False, capture_output=True)
774
- elif platform == "ios":
775
- pass # simctl file push would go here
988
+ if _push_hot_reload_file(platform, fpath, rel):
989
+ pushed.append(fpath)
990
+ else:
991
+ print(f"[hot-reload] Failed to push {rel}")
992
+ if pushed:
993
+ manifest = _write_hot_reload_manifest(pushed, project_dir, build_dir)
994
+ if _push_hot_reload_file(platform, manifest, "reload.json"):
995
+ print(f"[hot-reload] Signaled reload for {len(pushed)} file(s).")
996
+ else:
997
+ print("[hot-reload] Failed to signal reload; app will not refresh automatically.")
776
998
 
777
999
  print("[hot-reload] Watching app/ for changes. Press Ctrl+C to stop.")
778
1000
  watcher = FileWatcher(app_dir, on_change, interval=1.0)
@@ -799,8 +1021,11 @@ def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs:
799
1021
 
800
1022
 
801
1023
  def clean_project(args: argparse.Namespace) -> None:
802
- """
803
- Clean the specified project.
1024
+ """Remove the local `build/` directory.
1025
+
1026
+ Args:
1027
+ args: Parsed argparse namespace (unused; accepted for the
1028
+ `set_defaults(func=...)` dispatch shape).
804
1029
  """
805
1030
  # Define the build directory
806
1031
  build_dir: str = os.path.join(os.getcwd(), "build")
@@ -814,6 +1039,11 @@ def clean_project(args: argparse.Namespace) -> None:
814
1039
 
815
1040
 
816
1041
  def main() -> None:
1042
+ """Entry point for the `pn` console script.
1043
+
1044
+ Wires up the `init`, `run`, and `clean` subcommands and dispatches
1045
+ to the corresponding handler.
1046
+ """
817
1047
  parser = argparse.ArgumentParser(prog="pn", description="PythonNative CLI")
818
1048
  subparsers = parser.add_subparsers()
819
1049