pythonnative 0.32.0__py3-none-any.whl → 0.34.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 (78) hide show
  1. pythonnative/__init__.py +27 -15
  2. pythonnative/animated.py +7 -13
  3. pythonnative/cli/pn.py +1 -2
  4. pythonnative/component.py +255 -0
  5. pythonnative/components/__init__.py +106 -0
  6. pythonnative/components/_base.py +132 -0
  7. pythonnative/components/controls.py +515 -0
  8. pythonnative/components/layout.py +459 -0
  9. pythonnative/components/lists.py +809 -0
  10. pythonnative/components/media.py +204 -0
  11. pythonnative/components/overlays.py +107 -0
  12. pythonnative/components/pressable.py +252 -0
  13. pythonnative/components/structural.py +167 -0
  14. pythonnative/components/text.py +291 -0
  15. pythonnative/diagnostics.py +1 -1
  16. pythonnative/element.py +108 -29
  17. pythonnative/gestures.py +1 -1
  18. pythonnative/hooks.py +366 -671
  19. pythonnative/hosts/__init__.py +85 -0
  20. pythonnative/hosts/android.py +269 -0
  21. pythonnative/hosts/base.py +665 -0
  22. pythonnative/hosts/desktop.py +107 -0
  23. pythonnative/hosts/ios.py +404 -0
  24. pythonnative/hot_reload.py +16 -24
  25. pythonnative/layout.py +372 -66
  26. pythonnative/native_modules/__init__.py +23 -0
  27. pythonnative/native_modules/net_info.py +5 -1
  28. pythonnative/native_views/__init__.py +1 -1
  29. pythonnative/native_views/android.py +50 -6
  30. pythonnative/native_views/base.py +64 -1
  31. pythonnative/native_views/desktop.py +11 -3
  32. pythonnative/native_views/ios.py +72 -12
  33. pythonnative/navigation/__init__.py +102 -0
  34. pythonnative/navigation/container.py +154 -0
  35. pythonnative/navigation/handle.py +573 -0
  36. pythonnative/navigation/hooks.py +94 -0
  37. pythonnative/navigation/host.py +58 -0
  38. pythonnative/navigation/linking.py +200 -0
  39. pythonnative/navigation/navigators.py +637 -0
  40. pythonnative/navigation/screen.py +149 -0
  41. pythonnative/navigation/state.py +248 -0
  42. pythonnative/net.py +4 -0
  43. pythonnative/platform_metrics.py +1 -1
  44. pythonnative/preview.py +21 -19
  45. pythonnative/project/android.py +7 -1
  46. pythonnative/project/doctor.py +1 -1
  47. pythonnative/project/ios.py +1 -0
  48. pythonnative/project/runtime_assets.py +1 -1
  49. pythonnative/reconciler/__init__.py +29 -0
  50. pythonnative/reconciler/boundaries.py +365 -0
  51. pythonnative/reconciler/children.py +88 -0
  52. pythonnative/reconciler/core.py +1153 -0
  53. pythonnative/reconciler/layout_pass.py +359 -0
  54. pythonnative/reconciler/vnode.py +266 -0
  55. pythonnative/scheduler.py +159 -0
  56. pythonnative/sdk/_components.py +2 -4
  57. pythonnative/style.py +95 -13
  58. pythonnative/suspense.py +8 -12
  59. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +85 -1
  60. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +6 -7
  61. pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +1 -1
  62. pythonnative/templates/android_template/app/src/main/res/values/strings.xml +3 -1
  63. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +33 -0
  64. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +1 -1
  65. pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -10
  66. pythonnative/testing/__init__.py +53 -0
  67. pythonnative/testing/backend.py +276 -0
  68. pythonnative/testing/harness.py +391 -0
  69. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/METADATA +2 -21
  70. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/RECORD +74 -43
  71. pythonnative/components.py +0 -2856
  72. pythonnative/navigation.py +0 -1031
  73. pythonnative/reconciler.py +0 -2298
  74. pythonnative/screen.py +0 -2068
  75. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/WHEEL +0 -0
  76. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/entry_points.txt +0 -0
  77. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/licenses/LICENSE +0 -0
  78. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,8 @@ import UIKit
11
11
 
12
12
  class ViewController: UIViewController {
13
13
  // Optional keys for dynamic screen navigation. Push navigation sets
14
- // these before presenting the controller; nil means "app.main".
14
+ // these before presenting the controller; nil means the app's entry
15
+ // module (Info.plist `PNEntryModule`, default "app.main").
15
16
  @objc dynamic var requestedScreenPath: String? = nil
16
17
  @objc dynamic var requestedScreenArgsJSON: String? = nil
17
18
 
@@ -32,21 +33,22 @@ class ViewController: UIViewController {
32
33
  }
33
34
 
34
35
  // PythonNative's convention is "import the module and grab its
35
- // top-level `App` attribute", so the default is the module path
36
- // "app.main". Push navigation overrides this via
37
- // `requestedScreenPath`, which may also be a dotted-attribute
38
- // path like "app.main.RootScreen".
39
- let screenPath: String = requestedScreenPath ?? "app.main"
36
+ // top-level `App` attribute". The entry module comes from
37
+ // `app.entry_point` in pythonnative.toml (written to Info.plist as
38
+ // `PNEntryModule` by `pn build`); push navigation overrides it via
39
+ // `requestedScreenPath`.
40
+ let entryModule = Bundle.main.object(forInfoDictionaryKey: "PNEntryModule") as? String
41
+ let screenPath: String = requestedScreenPath ?? entryModule ?? "app.main"
40
42
  let addr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque())
41
43
  do {
42
44
  let screen: PyRef
43
45
  if let argsJSON = requestedScreenArgsJSON {
44
46
  screen = try PythonRuntime.shared.call(
45
- module: "pythonnative.screen", function: "create_screen", screenPath, addr, argsJSON
47
+ module: "pythonnative.hosts", function: "create_screen", screenPath, addr, argsJSON
46
48
  )
47
49
  } else {
48
50
  screen = try PythonRuntime.shared.call(
49
- module: "pythonnative.screen", function: "create_screen", screenPath, addr
51
+ module: "pythonnative.hosts", function: "create_screen", screenPath, addr
50
52
  )
51
53
  }
52
54
  self.screen = screen
@@ -69,7 +71,7 @@ class ViewController: UIViewController {
69
71
  guard screen != nil else { return }
70
72
  let addr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque())
71
73
  PythonRuntime.shared.notify(
72
- module: "pythonnative.screen", function: "forward_lifecycle", addr, event
74
+ module: "pythonnative.hosts", function: "forward_lifecycle", addr, event
73
75
  )
74
76
  }
75
77
 
@@ -119,7 +121,7 @@ class ViewController: UIViewController {
119
121
  if screen != nil {
120
122
  let addr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque())
121
123
  PythonRuntime.shared.notify(
122
- module: "pythonnative.screen", function: "forward_lifecycle", addr, "on_destroy"
124
+ module: "pythonnative.hosts", function: "forward_lifecycle", addr, "on_destroy"
123
125
  )
124
126
  }
125
127
  }
@@ -0,0 +1,53 @@
1
+ """Test utilities: render components without a device.
2
+
3
+ ```python
4
+ import pythonnative as pn
5
+ from pythonnative.testing import render, render_hook
6
+
7
+ @pn.component
8
+ def Counter():
9
+ count, set_count = pn.use_state(0)
10
+ return pn.Column(
11
+ pn.Text(f"Count: {count}"),
12
+ pn.Button("+", on_press=lambda: set_count(count + 1)),
13
+ )
14
+
15
+ def test_counter_increments():
16
+ result = render(Counter())
17
+ result.press(result.get_by_text("+"))
18
+ assert result.get_by_text("Count: 1")
19
+
20
+ def test_use_state():
21
+ hook = render_hook(lambda: pn.use_state("a"))
22
+ hook.act(lambda: hook.current[1]("b"))
23
+ assert hook.current[0] == "b"
24
+ ```
25
+
26
+ - [`render`][pythonnative.testing.render] mounts an element into a
27
+ [`FakeBackend`][pythonnative.testing.FakeBackend] and returns a
28
+ [`RenderResult`][pythonnative.testing.RenderResult] with Testing
29
+ Library-style queries (``get_by_text``, ``get_by_test_id``,
30
+ ``get_by_label``, ``get_by_type``) and event helpers (``press``,
31
+ ``fire``, ``change_text``, ``back``).
32
+ - [`render_hook`][pythonnative.testing.render_hook] runs a hook in a
33
+ throwaway component.
34
+ - [`settle`][pythonnative.testing.settle] pumps the framework loop so
35
+ async work (resources, queries, transitions) completes.
36
+ - [`FakeHost`][pythonnative.testing.FakeHost] stands in for a native
37
+ screen host so root stack navigators can be tested.
38
+ """
39
+
40
+ from .backend import DEFAULT_INTRINSIC, FakeBackend, FakeView
41
+ from .harness import FakeHost, HookResult, RenderResult, render, render_hook, settle
42
+
43
+ __all__ = [
44
+ "DEFAULT_INTRINSIC",
45
+ "FakeBackend",
46
+ "FakeHost",
47
+ "FakeView",
48
+ "HookResult",
49
+ "RenderResult",
50
+ "render",
51
+ "render_hook",
52
+ "settle",
53
+ ]
@@ -0,0 +1,276 @@
1
+ """In-memory backend implementing the batched mutation protocol.
2
+
3
+ [`FakeBackend`][pythonnative.testing.FakeBackend] speaks the same
4
+ protocol as the platform registries (``apply_mutations``,
5
+ ``resolve_view``, ``measure_intrinsic``, ``command``, plus the animation
6
+ hooks) while keeping a real tree of
7
+ [`FakeView`][pythonnative.testing.FakeView] objects, so tests can assert
8
+ on structure, props, and frames without a device.
9
+
10
+ Unlike the production registries (which isolate per-op failures so a
11
+ bad prop can't desync a device), the fake **raises** on malformed
12
+ transactions (unknown tags, double destroys, inserting into a destroyed
13
+ parent) so reconciler bugs fail tests loudly.
14
+
15
+ Recorded op shapes (in ``FakeBackend.ops``):
16
+
17
+ - ``("create", type_name, view.id)``
18
+ - ``("update", type_name, view.id, tuple(sorted(changed_keys)))``
19
+ - ``("insert_child", parent.id, child.id, index)``
20
+ - ``("destroy", view.id)``
21
+ - ``("set_frame", view.id, x, y, w, h)``
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple
27
+
28
+ from ..mutations import CreateOp, DestroyOp, InsertOp, Mutation, SetFrameOp, UpdateOp
29
+
30
+ __all__ = ["DEFAULT_INTRINSIC", "FakeBackend", "FakeView"]
31
+
32
+ DEFAULT_INTRINSIC: Dict[str, Tuple[float, float]] = {
33
+ "Text": (60.0, 16.0),
34
+ "Button": (80.0, 32.0),
35
+ "Image": (40.0, 40.0),
36
+ "TextInput": (120.0, 32.0),
37
+ "TabBar": (320.0, 49.0),
38
+ }
39
+ """Intrinsic sizes reported for content-sized leaves (what platform measure hooks would return)."""
40
+
41
+
42
+ class FakeView:
43
+ """Simulated native view: type, props, children, and last frame.
44
+
45
+ Attributes:
46
+ tag: The reconciler-assigned tag (use with ``fire`` / events).
47
+ type_name: Native type, e.g. ``"Text"``.
48
+ props: Native-safe props (event callbacks are stripped; they
49
+ live in the event registry keyed by ``tag``).
50
+ children: Child views in order.
51
+ frame: ``(x, y, width, height)`` from the last layout pass.
52
+ """
53
+
54
+ _next_id = 0
55
+
56
+ def __init__(self, tag: int, type_name: str, props: Dict[str, Any]) -> None:
57
+ FakeView._next_id += 1
58
+ self.id = FakeView._next_id
59
+ self.tag = tag
60
+ self.type_name = type_name
61
+ self.props: Dict[str, Any] = dict(props)
62
+ self.children: List[FakeView] = []
63
+ self.parent: Optional[FakeView] = None
64
+ self.frame: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
65
+ self.destroyed = False
66
+
67
+ def __repr__(self) -> str:
68
+ label = self.text
69
+ suffix = f" {label!r}" if label else ""
70
+ return f"<{self.type_name} tag={self.tag}{suffix}>"
71
+
72
+ # -- content --------------------------------------------------------
73
+
74
+ @property
75
+ def text(self) -> Optional[str]:
76
+ """Visible text for text-bearing views (``Text.text``, ``Button.title``, ``TextInput.value``)."""
77
+ for key in ("text", "title", "value", "placeholder"):
78
+ value = self.props.get(key)
79
+ if isinstance(value, str):
80
+ return value
81
+ return None
82
+
83
+ @property
84
+ def hidden(self) -> bool:
85
+ """Whether this view is removed from layout (``display: "none"``)."""
86
+ return self.props.get("display") == "none"
87
+
88
+ @property
89
+ def test_id(self) -> Optional[str]:
90
+ """The ``test_id`` prop, if set."""
91
+ return self.props.get("test_id")
92
+
93
+ @property
94
+ def label(self) -> Optional[str]:
95
+ """The ``accessibility_label`` prop, if set."""
96
+ return self.props.get("accessibility_label")
97
+
98
+ # -- traversal ------------------------------------------------------
99
+
100
+ def walk(self, *, include_hidden: bool = True) -> Iterator["FakeView"]:
101
+ """Yield this view and every descendant, depth-first.
102
+
103
+ With ``include_hidden=False`` subtrees under a ``display: "none"``
104
+ view are skipped (what a user can see).
105
+ """
106
+ if not include_hidden and self.hidden:
107
+ return
108
+ yield self
109
+ for child in self.children:
110
+ yield from child.walk(include_hidden=include_hidden)
111
+
112
+ def find_all(self, predicate_or_type: Any) -> List["FakeView"]:
113
+ """Every view in this subtree matching a type name or predicate."""
114
+ if isinstance(predicate_or_type, str):
115
+ wanted = predicate_or_type
116
+
117
+ def predicate(v: "FakeView") -> bool:
118
+ return v.type_name == wanted
119
+
120
+ else:
121
+ predicate = predicate_or_type
122
+ return [v for v in self.walk() if predicate(v)]
123
+
124
+ def find_first(self, predicate_or_type: Any) -> Optional["FakeView"]:
125
+ """Return the first view in this subtree matching a type name or predicate, or ``None``."""
126
+ found = self.find_all(predicate_or_type)
127
+ return found[0] if found else None
128
+
129
+ def dump(self, indent: int = 0) -> str:
130
+ """Indented, human-readable subtree (for failing-test output)."""
131
+ line = " " * indent + repr(self)
132
+ x, y, w, h = self.frame
133
+ if w or h:
134
+ line += f" @({x:g},{y:g} {w:g}x{h:g})"
135
+ return "\n".join([line, *(c.dump(indent + 2) for c in self.children)])
136
+
137
+
138
+ class FakeBackend:
139
+ """Tag-table backend recording one tuple per applied mutation.
140
+
141
+ Args:
142
+ intrinsic: Override the intrinsic sizes used by
143
+ ``measure_intrinsic`` (defaults to
144
+ [`DEFAULT_INTRINSIC`][pythonnative.testing.backend.DEFAULT_INTRINSIC]).
145
+ """
146
+
147
+ def __init__(self, intrinsic: Optional[Dict[str, Tuple[float, float]]] = None) -> None:
148
+ self.intrinsic = dict(DEFAULT_INTRINSIC if intrinsic is None else intrinsic)
149
+ self.views: Dict[int, FakeView] = {}
150
+ self.ops: List[Any] = []
151
+ self.batches: List[List[Any]] = []
152
+ self.measure_calls: List[int] = []
153
+ self.commands: List[Tuple[int, str, Dict[str, Any]]] = []
154
+ self.animated: List[Tuple[int, str, Any]] = []
155
+ self.last_create_props: Dict[str, Any] = {}
156
+ self.last_update_changes: Dict[str, Any] = {}
157
+
158
+ # ------------------------------------------------------------------
159
+ # Commit channel
160
+ # ------------------------------------------------------------------
161
+
162
+ def apply_mutations(self, ops: Sequence[Mutation]) -> None:
163
+ """Apply one committed batch to the view tree, recording each op in ``ops`` and ``batches``.
164
+
165
+ Raises ``AssertionError`` on malformed transactions (unknown tags, double creates or destroys).
166
+ """
167
+ batch: List[Any] = []
168
+ for op in ops:
169
+ recorded = self._apply_one(op)
170
+ self.ops.append(recorded)
171
+ batch.append(recorded)
172
+ self.batches.append(batch)
173
+
174
+ def _apply_one(self, op: Mutation) -> Tuple[Any, ...]:
175
+ if isinstance(op, CreateOp):
176
+ if op.tag in self.views:
177
+ raise AssertionError(f"create: tag {op.tag} already registered")
178
+ view = FakeView(op.tag, op.type_name, op.props)
179
+ self.views[op.tag] = view
180
+ self.last_create_props = dict(op.props)
181
+ return ("create", op.type_name, view.id)
182
+
183
+ if isinstance(op, UpdateOp):
184
+ view = self._require(op.tag, "update")
185
+ view.props.update(op.changed_props)
186
+ self.last_update_changes = dict(op.changed_props)
187
+ return ("update", view.type_name, view.id, tuple(sorted(op.changed_props)))
188
+
189
+ if isinstance(op, InsertOp):
190
+ parent = self._require(op.parent_tag, "insert_child")
191
+ child = self._require(op.child_tag, "insert_child")
192
+ if child.parent is not None:
193
+ child.parent.children.remove(child)
194
+ child.parent = None
195
+ index = max(0, min(op.index, len(parent.children)))
196
+ parent.children.insert(index, child)
197
+ child.parent = parent
198
+ return ("insert_child", parent.id, child.id, index)
199
+
200
+ if isinstance(op, DestroyOp):
201
+ view = self.views.pop(op.tag, None)
202
+ if view is None:
203
+ raise AssertionError(f"destroy: unknown tag {op.tag}")
204
+ if view.parent is not None:
205
+ view.parent.children.remove(view)
206
+ view.parent = None
207
+ view.destroyed = True
208
+ return ("destroy", view.id)
209
+
210
+ if isinstance(op, SetFrameOp):
211
+ view = self._require(op.tag, "set_frame")
212
+ view.frame = (op.x, op.y, op.width, op.height)
213
+ return ("set_frame", view.id, op.x, op.y, op.width, op.height)
214
+
215
+ raise AssertionError(f"unknown mutation op: {op!r}")
216
+
217
+ def _require(self, tag: int, op_name: str) -> FakeView:
218
+ view = self.views.get(tag)
219
+ if view is None:
220
+ raise AssertionError(f"{op_name}: unknown tag {tag}")
221
+ return view
222
+
223
+ # ------------------------------------------------------------------
224
+ # Imperative escape hatches
225
+ # ------------------------------------------------------------------
226
+
227
+ def resolve_view(self, tag: int) -> Optional[FakeView]:
228
+ """Return the live view registered under ``tag``, or ``None``."""
229
+ return self.views.get(tag)
230
+
231
+ def measure_intrinsic(self, tag: int, max_width: float, max_height: float) -> Tuple[float, float]:
232
+ """Return the configured intrinsic size for the view's type and record the call in ``measure_calls``.
233
+
234
+ Unknown tags and types without an entry measure as ``(0.0, 0.0)``; the constraints are ignored.
235
+ """
236
+ view = self.views.get(tag)
237
+ if view is None:
238
+ return (0.0, 0.0)
239
+ self.measure_calls.append(view.id)
240
+ return self.intrinsic.get(view.type_name, (0.0, 0.0))
241
+
242
+ def command(self, tag: int, name: str, args: Optional[Dict[str, Any]] = None) -> Any:
243
+ """Record an imperative view command in ``commands`` and return ``None``."""
244
+ self.commands.append((tag, name, dict(args or {})))
245
+ return None
246
+
247
+ def set_animated_property(self, tag: int, prop_name: str, value: Any) -> None:
248
+ """Record an animated property write in ``animated`` without touching ``props``."""
249
+ self.animated.append((tag, prop_name, value))
250
+
251
+ def start_animation(self, tag: int, anim_id: int, prop_name: str, spec: Dict[str, Any]) -> bool:
252
+ """Decline native animation (return ``False``) so animations run through the Python driver."""
253
+ return False
254
+
255
+ def cancel_animation(self, tag: int, anim_id: int) -> Any:
256
+ """Do nothing; the fake never starts native animations."""
257
+ return None
258
+
259
+ # ------------------------------------------------------------------
260
+ # Diagnostics
261
+ # ------------------------------------------------------------------
262
+
263
+ def live_view_count(self) -> int:
264
+ """Return how many views are currently registered (created and not yet destroyed)."""
265
+ return len(self.views)
266
+
267
+ def ops_of(self, kind: str) -> List[Any]:
268
+ """Every recorded op tuple whose first element is ``kind``."""
269
+ return [op for op in self.ops if op[0] == kind]
270
+
271
+ def detached_views(self, type_name: Optional[str] = None) -> List[FakeView]:
272
+ """Live views never inserted into a parent (``Portal`` overlays, the root)."""
273
+ out = [v for v in self.views.values() if v.parent is None]
274
+ if type_name is not None:
275
+ out = [v for v in out if v.type_name == type_name]
276
+ return out