pythonnative 0.4.0__py3-none-any.whl → 0.6.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 (52) hide show
  1. pythonnative/__init__.py +94 -66
  2. pythonnative/cli/pn.py +153 -24
  3. pythonnative/components.py +563 -0
  4. pythonnative/element.py +53 -0
  5. pythonnative/hooks.py +287 -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 +1334 -0
  13. pythonnative/page.py +320 -247
  14. pythonnative/reconciler.py +262 -0
  15. pythonnative/style.py +115 -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 -1
  18. pythonnative/templates/android_template/build.gradle +1 -1
  19. pythonnative/utils.py +21 -29
  20. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/METADATA +20 -19
  21. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/RECORD +25 -40
  22. pythonnative/activity_indicator_view.py +0 -71
  23. pythonnative/button.py +0 -113
  24. pythonnative/collection_view.py +0 -0
  25. pythonnative/date_picker.py +0 -76
  26. pythonnative/image_view.py +0 -78
  27. pythonnative/label.py +0 -133
  28. pythonnative/list_view.py +0 -76
  29. pythonnative/material_activity_indicator_view.py +0 -71
  30. pythonnative/material_bottom_navigation_view.py +0 -0
  31. pythonnative/material_button.py +0 -69
  32. pythonnative/material_date_picker.py +0 -87
  33. pythonnative/material_progress_view.py +0 -70
  34. pythonnative/material_search_bar.py +0 -69
  35. pythonnative/material_switch.py +0 -69
  36. pythonnative/material_time_picker.py +0 -76
  37. pythonnative/material_toolbar.py +0 -0
  38. pythonnative/picker_view.py +0 -69
  39. pythonnative/progress_view.py +0 -70
  40. pythonnative/scroll_view.py +0 -101
  41. pythonnative/search_bar.py +0 -69
  42. pythonnative/stack_view.py +0 -199
  43. pythonnative/switch.py +0 -68
  44. pythonnative/text_field.py +0 -132
  45. pythonnative/text_view.py +0 -135
  46. pythonnative/time_picker.py +0 -77
  47. pythonnative/view.py +0 -173
  48. pythonnative/web_view.py +0 -60
  49. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/WHEEL +0 -0
  50. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/entry_points.txt +0 -0
  51. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/licenses/LICENSE +0 -0
  52. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/top_level.txt +0 -0
pythonnative/hooks.py ADDED
@@ -0,0 +1,287 @@
1
+ """Hook primitives for function components.
2
+
3
+ Provides React-like hooks for managing state, effects, memoisation,
4
+ and context within function components decorated with :func:`component`.
5
+
6
+ Usage::
7
+
8
+ import pythonnative as pn
9
+
10
+ @pn.component
11
+ def counter(initial=0):
12
+ count, set_count = pn.use_state(initial)
13
+ return pn.Column(
14
+ pn.Text(f"Count: {count}"),
15
+ pn.Button("+", on_click=lambda: set_count(count + 1)),
16
+ )
17
+ """
18
+
19
+ import inspect
20
+ import threading
21
+ from typing import Any, Callable, List, Optional, Tuple, TypeVar
22
+
23
+ from .element import Element
24
+
25
+ T = TypeVar("T")
26
+
27
+ _SENTINEL = object()
28
+
29
+ _hook_context: threading.local = threading.local()
30
+
31
+
32
+ # ======================================================================
33
+ # Hook state container
34
+ # ======================================================================
35
+
36
+
37
+ class HookState:
38
+ """Stores all hook data for a single function component instance."""
39
+
40
+ __slots__ = ("states", "effects", "memos", "refs", "hook_index", "_trigger_render")
41
+
42
+ def __init__(self) -> None:
43
+ self.states: List[Any] = []
44
+ self.effects: List[Tuple[Any, Any]] = []
45
+ self.memos: List[Tuple[Any, Any]] = []
46
+ self.refs: List[dict] = []
47
+ self.hook_index: int = 0
48
+ self._trigger_render: Optional[Callable[[], None]] = None
49
+
50
+ def reset_index(self) -> None:
51
+ self.hook_index = 0
52
+
53
+ def run_pending_effects(self) -> None:
54
+ """Execute effects whose deps changed during the last render pass."""
55
+ for i, (deps, cleanup) in enumerate(self.effects):
56
+ if deps is _SENTINEL:
57
+ continue
58
+
59
+ def cleanup_all_effects(self) -> None:
60
+ """Run all outstanding cleanup functions (called on unmount)."""
61
+ for i, (deps, cleanup) in enumerate(self.effects):
62
+ if callable(cleanup):
63
+ try:
64
+ cleanup()
65
+ except Exception:
66
+ pass
67
+ self.effects[i] = (_SENTINEL, None)
68
+
69
+
70
+ # ======================================================================
71
+ # Thread-local context helpers
72
+ # ======================================================================
73
+
74
+
75
+ def _get_hook_state() -> Optional[HookState]:
76
+ return getattr(_hook_context, "current", None)
77
+
78
+
79
+ def _set_hook_state(state: Optional[HookState]) -> None:
80
+ _hook_context.current = state
81
+
82
+
83
+ def _deps_changed(prev: Any, current: Any) -> bool:
84
+ if prev is _SENTINEL:
85
+ return True
86
+ if prev is None or current is None:
87
+ return True
88
+ if len(prev) != len(current):
89
+ return True
90
+ return any(p is not c and p != c for p, c in zip(prev, current))
91
+
92
+
93
+ # ======================================================================
94
+ # Public hooks
95
+ # ======================================================================
96
+
97
+
98
+ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
99
+ """Return ``(value, setter)`` for component-local state.
100
+
101
+ If *initial* is callable it is invoked once (lazy initialisation).
102
+ The setter accepts a value **or** a ``current -> new`` callable.
103
+ """
104
+ ctx = _get_hook_state()
105
+ if ctx is None:
106
+ raise RuntimeError("use_state must be called inside a @component function")
107
+
108
+ idx = ctx.hook_index
109
+ ctx.hook_index += 1
110
+
111
+ if idx >= len(ctx.states):
112
+ val = initial() if callable(initial) else initial
113
+ ctx.states.append(val)
114
+
115
+ current = ctx.states[idx]
116
+
117
+ def setter(new_value: Any) -> None:
118
+ if callable(new_value):
119
+ new_value = new_value(ctx.states[idx])
120
+ if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
121
+ ctx.states[idx] = new_value
122
+ if ctx._trigger_render:
123
+ ctx._trigger_render()
124
+
125
+ return current, setter
126
+
127
+
128
+ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
129
+ """Schedule *effect* to run after render.
130
+
131
+ *deps* controls when the effect re-runs:
132
+
133
+ - ``None`` -> every render
134
+ - ``[]`` -> mount only
135
+ - ``[a, b]``-> when *a* or *b* change
136
+
137
+ *effect* may return a cleanup callable.
138
+ """
139
+ ctx = _get_hook_state()
140
+ if ctx is None:
141
+ raise RuntimeError("use_effect must be called inside a @component function")
142
+
143
+ idx = ctx.hook_index
144
+ ctx.hook_index += 1
145
+
146
+ if idx >= len(ctx.effects):
147
+ ctx.effects.append((_SENTINEL, None))
148
+
149
+ prev_deps, prev_cleanup = ctx.effects[idx]
150
+ if _deps_changed(prev_deps, deps):
151
+ if callable(prev_cleanup):
152
+ try:
153
+ prev_cleanup()
154
+ except Exception:
155
+ pass
156
+ cleanup = effect()
157
+ ctx.effects[idx] = (list(deps) if deps is not None else None, cleanup)
158
+ else:
159
+ ctx.effects[idx] = (prev_deps, prev_cleanup)
160
+
161
+
162
+ def use_memo(factory: Callable[[], T], deps: list) -> T:
163
+ """Return a memoised value, recomputed only when *deps* change."""
164
+ ctx = _get_hook_state()
165
+ if ctx is None:
166
+ raise RuntimeError("use_memo must be called inside a @component function")
167
+
168
+ idx = ctx.hook_index
169
+ ctx.hook_index += 1
170
+
171
+ if idx >= len(ctx.memos):
172
+ value = factory()
173
+ ctx.memos.append((list(deps), value))
174
+ return value
175
+
176
+ prev_deps, prev_value = ctx.memos[idx]
177
+ if not _deps_changed(prev_deps, deps):
178
+ return prev_value
179
+
180
+ value = factory()
181
+ ctx.memos[idx] = (list(deps), value)
182
+ return value
183
+
184
+
185
+ def use_callback(callback: Callable, deps: list) -> Callable:
186
+ """Return a stable reference to *callback*, updated only when *deps* change."""
187
+ return use_memo(lambda: callback, deps)
188
+
189
+
190
+ def use_ref(initial: Any = None) -> dict:
191
+ """Return a mutable ref dict ``{"current": initial}`` that persists across renders."""
192
+ ctx = _get_hook_state()
193
+ if ctx is None:
194
+ raise RuntimeError("use_ref must be called inside a @component function")
195
+
196
+ idx = ctx.hook_index
197
+ ctx.hook_index += 1
198
+
199
+ if idx >= len(ctx.refs):
200
+ ref: dict = {"current": initial}
201
+ ctx.refs.append(ref)
202
+ return ref
203
+
204
+ return ctx.refs[idx]
205
+
206
+
207
+ # ======================================================================
208
+ # Context
209
+ # ======================================================================
210
+
211
+
212
+ class Context:
213
+ """A context object created by :func:`create_context`."""
214
+
215
+ def __init__(self, default: Any = None) -> None:
216
+ self.default = default
217
+ self._stack: List[Any] = []
218
+
219
+ def _current(self) -> Any:
220
+ return self._stack[-1] if self._stack else self.default
221
+
222
+
223
+ def create_context(default: Any = None) -> Context:
224
+ """Create a new context with an optional default value."""
225
+ return Context(default)
226
+
227
+
228
+ def use_context(context: Context) -> Any:
229
+ """Read the current value of *context* from the nearest ``Provider`` ancestor."""
230
+ ctx = _get_hook_state()
231
+ if ctx is None:
232
+ raise RuntimeError("use_context must be called inside a @component function")
233
+ return context._current()
234
+
235
+
236
+ # ======================================================================
237
+ # Provider element helper
238
+ # ======================================================================
239
+
240
+
241
+ def Provider(context: Context, value: Any, child: Element) -> Element:
242
+ """Create a context provider element.
243
+
244
+ All descendants of *child* will read *value* via ``use_context(context)``.
245
+ """
246
+ return Element("__Provider__", {"__context__": context, "__value__": value}, [child])
247
+
248
+
249
+ # ======================================================================
250
+ # @component decorator
251
+ # ======================================================================
252
+
253
+
254
+ def component(func: Callable) -> Callable[..., Element]:
255
+ """Decorator that turns a Python function into a PythonNative component.
256
+
257
+ The decorated function can use hooks (``use_state``, ``use_effect``, etc.)
258
+ and returns an ``Element`` tree. Each call site creates an independent
259
+ component instance with its own hook state.
260
+ """
261
+ sig = inspect.signature(func)
262
+ positional_params = [
263
+ name
264
+ for name, p in sig.parameters.items()
265
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
266
+ ]
267
+ has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values())
268
+
269
+ def wrapper(*args: Any, **kwargs: Any) -> Element:
270
+ props: dict = dict(kwargs)
271
+
272
+ if args:
273
+ if has_var_positional:
274
+ props["children"] = list(args)
275
+ else:
276
+ for i, arg in enumerate(args):
277
+ if i < len(positional_params):
278
+ props[positional_params[i]] = arg
279
+
280
+ key = props.pop("key", None)
281
+ return Element(func, props, [], key=key)
282
+
283
+ wrapper.__wrapped__ = func # noqa: B010
284
+ wrapper.__name__ = func.__name__
285
+ wrapper.__qualname__ = func.__qualname__
286
+ wrapper._pn_component = True # noqa: B010
287
+ return wrapper
@@ -0,0 +1,143 @@
1
+ """Hot-reload support for PythonNative development.
2
+
3
+ Host-side
4
+ ~~~~~~~~~
5
+ :class:`FileWatcher` monitors the ``app/`` directory for changes and
6
+ triggers a push-and-reload cycle via ``adb push`` (Android) or
7
+ ``simctl`` file copy (iOS).
8
+
9
+ Device-side
10
+ ~~~~~~~~~~~
11
+ :class:`ModuleReloader` reloads changed Python modules using
12
+ ``importlib.reload`` and triggers a page re-render.
13
+
14
+ Usage (host-side, integrated into ``pn run --hot-reload``)::
15
+
16
+ watcher = FileWatcher("app/", on_change=push_files)
17
+ watcher.start()
18
+ """
19
+
20
+ import importlib
21
+ import os
22
+ import sys
23
+ import threading
24
+ import time
25
+ from typing import Any, Callable, Dict, List, Optional
26
+
27
+ # ======================================================================
28
+ # Host-side file watcher
29
+ # ======================================================================
30
+
31
+
32
+ class FileWatcher:
33
+ """Watch a directory tree for ``.py`` file changes.
34
+
35
+ Parameters
36
+ ----------
37
+ watch_dir:
38
+ Directory to watch.
39
+ on_change:
40
+ Called with a list of changed file paths when modifications are detected.
41
+ interval:
42
+ Polling interval in seconds.
43
+ """
44
+
45
+ def __init__(self, watch_dir: str, on_change: Callable[[List[str]], None], interval: float = 1.0) -> None:
46
+ self.watch_dir = watch_dir
47
+ self.on_change = on_change
48
+ self.interval = interval
49
+ self._running = False
50
+ self._thread: Optional[threading.Thread] = None
51
+ self._mtimes: Dict[str, float] = {}
52
+
53
+ def start(self) -> None:
54
+ """Begin watching in a background daemon thread."""
55
+ self._running = True
56
+ self._scan()
57
+ self._thread = threading.Thread(target=self._loop, daemon=True)
58
+ self._thread.start()
59
+
60
+ def stop(self) -> None:
61
+ """Stop the watcher."""
62
+ self._running = False
63
+ if self._thread is not None:
64
+ self._thread.join(timeout=self.interval * 2)
65
+ self._thread = None
66
+
67
+ def _scan(self) -> List[str]:
68
+ changed: List[str] = []
69
+ current_files: set = set()
70
+
71
+ for root, _dirs, files in os.walk(self.watch_dir):
72
+ for fname in files:
73
+ if not fname.endswith(".py"):
74
+ continue
75
+ fpath = os.path.join(root, fname)
76
+ current_files.add(fpath)
77
+ try:
78
+ mtime = os.path.getmtime(fpath)
79
+ except OSError:
80
+ continue
81
+ if fpath in self._mtimes:
82
+ if mtime > self._mtimes[fpath]:
83
+ changed.append(fpath)
84
+ self._mtimes[fpath] = mtime
85
+
86
+ for old in list(self._mtimes):
87
+ if old not in current_files:
88
+ del self._mtimes[old]
89
+
90
+ return changed
91
+
92
+ def _loop(self) -> None:
93
+ while self._running:
94
+ time.sleep(self.interval)
95
+ changed = self._scan()
96
+ if changed:
97
+ try:
98
+ self.on_change(changed)
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ # ======================================================================
104
+ # Device-side module reloader
105
+ # ======================================================================
106
+
107
+
108
+ class ModuleReloader:
109
+ """Reload changed Python modules on device and trigger re-render."""
110
+
111
+ @staticmethod
112
+ def reload_module(module_name: str) -> bool:
113
+ """Reload a single module by its dotted name.
114
+
115
+ Returns ``True`` if the module was found and reloaded successfully.
116
+ """
117
+ mod = sys.modules.get(module_name)
118
+ if mod is None:
119
+ return False
120
+ try:
121
+ importlib.reload(mod)
122
+ return True
123
+ except Exception:
124
+ return False
125
+
126
+ @staticmethod
127
+ def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]:
128
+ """Convert a file path to a dotted module name relative to *base_dir*."""
129
+ rel = os.path.relpath(file_path, base_dir) if base_dir else file_path
130
+ if rel.endswith(".py"):
131
+ rel = rel[:-3]
132
+ parts = rel.replace(os.sep, ".").split(".")
133
+ if parts[-1] == "__init__":
134
+ parts = parts[:-1]
135
+ return ".".join(parts) if parts else None
136
+
137
+ @staticmethod
138
+ def reload_page(page_instance: Any) -> None:
139
+ """Force a page re-render after module reload."""
140
+ from .page import _re_render
141
+
142
+ if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None:
143
+ _re_render(page_instance)
@@ -0,0 +1,19 @@
1
+ """Native API modules for device capabilities.
2
+
3
+ Provides cross-platform Python interfaces to common device APIs:
4
+
5
+ - :mod:`~.camera` — photo capture and gallery picking
6
+ - :mod:`~.location` — GPS / location services
7
+ - :mod:`~.file_system` — app-scoped file I/O
8
+ - :mod:`~.notifications` — local push notifications
9
+
10
+ Each module auto-detects the platform and calls the appropriate native
11
+ APIs via Chaquopy (Android) or rubicon-objc (iOS).
12
+ """
13
+
14
+ from .camera import Camera
15
+ from .file_system import FileSystem
16
+ from .location import Location
17
+ from .notifications import Notifications
18
+
19
+ __all__ = ["Camera", "FileSystem", "Location", "Notifications"]
@@ -0,0 +1,105 @@
1
+ """Cross-platform camera access.
2
+
3
+ Provides methods for capturing photos and picking images from the gallery.
4
+ Uses Android's ``Intent``/``MediaStore`` or iOS's ``UIImagePickerController``.
5
+ """
6
+
7
+ from typing import Any, Callable, Optional
8
+
9
+ from ..utils import IS_ANDROID
10
+
11
+
12
+ class Camera:
13
+ """Camera and image picker interface.
14
+
15
+ All methods accept an ``on_result`` callback that receives the image
16
+ file path (or ``None`` on cancellation).
17
+ """
18
+
19
+ @staticmethod
20
+ def take_photo(on_result: Optional[Callable[[Optional[str]], None]] = None, **options: Any) -> None:
21
+ """Launch the device camera to capture a photo.
22
+
23
+ Parameters
24
+ ----------
25
+ on_result:
26
+ ``(path_or_none) -> None`` called with the saved image path,
27
+ or ``None`` if the user cancelled.
28
+ """
29
+ if IS_ANDROID:
30
+ Camera._android_take_photo(on_result, **options)
31
+ else:
32
+ Camera._ios_take_photo(on_result, **options)
33
+
34
+ @staticmethod
35
+ def pick_from_gallery(on_result: Optional[Callable[[Optional[str]], None]] = None, **options: Any) -> None:
36
+ """Open the system gallery picker.
37
+
38
+ Parameters
39
+ ----------
40
+ on_result:
41
+ ``(path_or_none) -> None`` called with the selected image path,
42
+ or ``None`` if the user cancelled.
43
+ """
44
+ if IS_ANDROID:
45
+ Camera._android_pick_gallery(on_result, **options)
46
+ else:
47
+ Camera._ios_pick_gallery(on_result, **options)
48
+
49
+ # -- Android implementations -----------------------------------------
50
+
51
+ @staticmethod
52
+ def _android_take_photo(on_result: Optional[Callable] = None, **options: Any) -> None:
53
+ try:
54
+ from java import jclass
55
+
56
+ Intent = jclass("android.content.Intent")
57
+ MediaStore = jclass("android.provider.MediaStore")
58
+ intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
59
+ from ..utils import get_android_context
60
+
61
+ ctx = get_android_context()
62
+ ctx.startActivity(intent)
63
+ except Exception:
64
+ if on_result:
65
+ on_result(None)
66
+
67
+ @staticmethod
68
+ def _android_pick_gallery(on_result: Optional[Callable] = None, **options: Any) -> None:
69
+ try:
70
+ from java import jclass
71
+
72
+ Intent = jclass("android.content.Intent")
73
+ intent = Intent(Intent.ACTION_PICK)
74
+ intent.setType("image/*")
75
+ from ..utils import get_android_context
76
+
77
+ ctx = get_android_context()
78
+ ctx.startActivity(intent)
79
+ except Exception:
80
+ if on_result:
81
+ on_result(None)
82
+
83
+ # -- iOS implementations ---------------------------------------------
84
+
85
+ @staticmethod
86
+ def _ios_take_photo(on_result: Optional[Callable] = None, **options: Any) -> None:
87
+ try:
88
+ from rubicon.objc import ObjCClass
89
+
90
+ picker = ObjCClass("UIImagePickerController").alloc().init()
91
+ picker.setSourceType_(1) # UIImagePickerControllerSourceTypeCamera
92
+ except Exception:
93
+ if on_result:
94
+ on_result(None)
95
+
96
+ @staticmethod
97
+ def _ios_pick_gallery(on_result: Optional[Callable] = None, **options: Any) -> None:
98
+ try:
99
+ from rubicon.objc import ObjCClass
100
+
101
+ picker = ObjCClass("UIImagePickerController").alloc().init()
102
+ picker.setSourceType_(0) # PhotoLibrary
103
+ except Exception:
104
+ if on_result:
105
+ on_result(None)
@@ -0,0 +1,131 @@
1
+ """Cross-platform file system access.
2
+
3
+ Provides helpers for reading and writing files within the app's
4
+ sandboxed storage area.
5
+ """
6
+
7
+ import os
8
+ from typing import Any, Optional
9
+
10
+ from ..utils import IS_ANDROID
11
+
12
+
13
+ class FileSystem:
14
+ """App-scoped file I/O."""
15
+
16
+ @staticmethod
17
+ def app_dir() -> str:
18
+ """Return the app's writable data directory."""
19
+ if IS_ANDROID:
20
+ try:
21
+ from ..utils import get_android_context
22
+
23
+ return str(get_android_context().getFilesDir().getAbsolutePath())
24
+ except Exception:
25
+ pass
26
+ else:
27
+ try:
28
+ from rubicon.objc import ObjCClass
29
+
30
+ NSSearchPathForDirectoriesInDomains = ObjCClass(
31
+ "NSFileManager"
32
+ ).defaultManager.URLsForDirectory_inDomains_
33
+ docs = NSSearchPathForDirectoriesInDomains(9, 1) # NSDocumentDirectory, NSUserDomainMask
34
+ if docs and docs.count > 0:
35
+ return str(docs.objectAtIndex_(0).path)
36
+ except Exception:
37
+ pass
38
+ return os.path.join(os.path.expanduser("~"), ".pythonnative_data")
39
+
40
+ @staticmethod
41
+ def read_text(path: str, encoding: str = "utf-8") -> Optional[str]:
42
+ """Read a text file relative to :meth:`app_dir` (or an absolute path)."""
43
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
44
+ try:
45
+ with open(full, encoding=encoding) as f:
46
+ return f.read()
47
+ except OSError:
48
+ return None
49
+
50
+ @staticmethod
51
+ def write_text(path: str, content: str, encoding: str = "utf-8") -> bool:
52
+ """Write a text file. Returns ``True`` on success."""
53
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
54
+ try:
55
+ os.makedirs(os.path.dirname(full), exist_ok=True)
56
+ with open(full, "w", encoding=encoding) as f:
57
+ f.write(content)
58
+ return True
59
+ except OSError:
60
+ return False
61
+
62
+ @staticmethod
63
+ def exists(path: str) -> bool:
64
+ """Check if a file or directory exists."""
65
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
66
+ return os.path.exists(full)
67
+
68
+ @staticmethod
69
+ def delete(path: str) -> bool:
70
+ """Delete a file. Returns ``True`` on success."""
71
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
72
+ try:
73
+ os.remove(full)
74
+ return True
75
+ except OSError:
76
+ return False
77
+
78
+ @staticmethod
79
+ def list_dir(path: str = "") -> list:
80
+ """List entries in a directory."""
81
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
82
+ try:
83
+ return os.listdir(full)
84
+ except OSError:
85
+ return []
86
+
87
+ @staticmethod
88
+ def read_bytes(path: str) -> Optional[bytes]:
89
+ """Read a binary file."""
90
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
91
+ try:
92
+ with open(full, "rb") as f:
93
+ return f.read()
94
+ except OSError:
95
+ return None
96
+
97
+ @staticmethod
98
+ def write_bytes(path: str, data: bytes) -> bool:
99
+ """Write a binary file. Returns ``True`` on success."""
100
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
101
+ try:
102
+ os.makedirs(os.path.dirname(full), exist_ok=True)
103
+ with open(full, "wb") as f:
104
+ f.write(data)
105
+ return True
106
+ except OSError:
107
+ return False
108
+
109
+ @staticmethod
110
+ def get_size(path: str) -> Optional[int]:
111
+ """Return file size in bytes, or ``None`` if not found."""
112
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
113
+ try:
114
+ return os.path.getsize(full)
115
+ except OSError:
116
+ return None
117
+
118
+ @staticmethod
119
+ def ensure_dir(path: str) -> bool:
120
+ """Create a directory (and parents) if it doesn't exist."""
121
+ full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
122
+ try:
123
+ os.makedirs(full, exist_ok=True)
124
+ return True
125
+ except OSError:
126
+ return False
127
+
128
+ @staticmethod
129
+ def join(*parts: Any) -> str:
130
+ """Join path components."""
131
+ return os.path.join(*[str(p) for p in parts])