pythonhere 0.1.4__py3-none-any.whl → 0.2.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.
pythonhere/__init__.py CHANGED
@@ -1,8 +1,8 @@
1
1
  """PythonHere Jupyter magic."""
2
+
2
3
  from herethere.magic import load_ipython_extension
3
4
 
4
5
  from .magic_here import shortcuts # noqa
5
6
  from .version_here import __version__ # noqa
6
7
 
7
-
8
8
  __all__ = ("load_ipython_extension",)
@@ -0,0 +1,100 @@
1
+ """Android specific functions."""
2
+
3
+ # pylint: disable=invalid-name,import-error,import-outside-toplevel
4
+ import uuid
5
+ from pathlib import Path
6
+
7
+ from android import activity as android_activity
8
+ from jnius import autoclass, cast
9
+ from kivy.logger import Logger
10
+
11
+ Context = autoclass("android.content.Context")
12
+ Icon = autoclass("android.graphics.drawable.Icon")
13
+ Intent = autoclass("android.content.Intent")
14
+ PythonActivity = autoclass("org.kivy.android.PythonActivity")
15
+ ShortcutInfoBuilder = autoclass("android.content.pm.ShortcutInfo$Builder")
16
+ System = autoclass("java.lang.System")
17
+ Uri = autoclass("android.net.Uri")
18
+
19
+
20
+ def get_current_intent() -> Intent:
21
+ """Return the intent that started Python activity."""
22
+ return PythonActivity.mActivity.getIntent()
23
+
24
+
25
+ def get_startup_script(intent: None = None) -> str | None:
26
+ """Return script entrypoint that was passed to a given, or current, intent."""
27
+ if not intent:
28
+ intent = get_current_intent()
29
+ data = intent.getData()
30
+ return data and data.toString()
31
+
32
+
33
+ def restart_app(script: str = None):
34
+ """Restart app, with a script as a starting point if provided."""
35
+ Logger.info("PythonHere: restart requested with a script: %s", script)
36
+ activity = PythonActivity.mActivity
37
+ intent = Intent(activity.getApplicationContext(), PythonActivity)
38
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
39
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
40
+
41
+ if script:
42
+ intent.setData(Uri.parse(script))
43
+
44
+ activity.startActivity(intent)
45
+ System.exit(0)
46
+
47
+
48
+ def bind_run_script_on_new_intent():
49
+ """Add handler for new intent event:
50
+ restart app with entrypoint of a new intent.
51
+ """
52
+
53
+ def on_new_intent(intent):
54
+ Logger.info("PythonHere: on_new_intent")
55
+ restart_app(get_startup_script(intent))
56
+ Logger.error("PythonHere: app was not restarted")
57
+
58
+ android_activity.bind(on_new_intent=on_new_intent)
59
+
60
+
61
+ def create_shortcut_icon() -> Icon:
62
+ """Create icon to use for a shurtcut."""
63
+ activity = PythonActivity.mActivity
64
+ Drawable = autoclass(f"{activity.getPackageName()}.R$drawable")
65
+ context = cast("android.content.Context", activity.getApplicationContext())
66
+ return Icon.createWithResource(context, Drawable.icon)
67
+
68
+
69
+ def resolve_script_path(script: str) -> str:
70
+ """Resolve path against upload directory."""
71
+ from kivy.app import App
72
+
73
+ if script.startswith("/"):
74
+ path = Path(script)
75
+ else:
76
+ app = App.get_running_app()
77
+ path = Path(app.upload_dir) / script
78
+ return str(path.resolve(strict=True))
79
+
80
+
81
+ def pin_shortcut(script: str, label: str):
82
+ """Request a pinned shortcut creation to run a Python script."""
83
+ activity = PythonActivity.mActivity
84
+ context = cast("android.content.Context", activity.getApplicationContext())
85
+
86
+ intent = Intent(activity.getApplicationContext(), PythonActivity)
87
+ intent.setAction(Intent.ACTION_MAIN)
88
+ intent.setData(Uri.parse(resolve_script_path(script)))
89
+
90
+ shortcut = (
91
+ ShortcutInfoBuilder(context, f"pythonhere-{uuid.uuid4().hex}")
92
+ .setShortLabel(label)
93
+ .setLongLabel(label)
94
+ .setIntent(intent)
95
+ .setIcon(create_shortcut_icon())
96
+ .build()
97
+ )
98
+
99
+ manager = activity.getSystemService(Context.SHORTCUT_SERVICE)
100
+ manager.requestPinShortcut(shortcut, None)
Binary file
Binary file
Binary file
pythonhere/enum_here.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """Enums."""
2
+
2
3
  from enum import Enum
3
4
 
4
5
 
@@ -0,0 +1,22 @@
1
+ <-UnhandledExceptionPopupHere>:
2
+ title: "Unhandled Exception catched"
3
+ BoxLayout:
4
+ orientation: 'vertical'
5
+ padding: 10
6
+ spacing: 20
7
+ Label:
8
+ size_hint_y: None
9
+ font_size: '18sp'
10
+ height: '24sp'
11
+ text: 'Exception details: '
12
+ ScrollView:
13
+ CodeInput:
14
+ id: catched_exception_code_input_here
15
+ text: root.message
16
+ size_hint: 1, None
17
+ height: self.minimum_height
18
+ Button:
19
+ size_hint_y: None
20
+ height: '40sp'
21
+ text: 'OK, continue'
22
+ on_press: root.dismiss()
@@ -1,46 +1,26 @@
1
1
  """App exceptions manager."""
2
+
2
3
  import asyncio
3
4
  import traceback
4
- from typing import Optional
5
+ from pathlib import Path
5
6
 
6
7
  from kivy.base import (
7
8
  ExceptionHandler,
8
9
  ExceptionManager,
9
10
  )
10
11
  from kivy.clock import Clock
11
- from kivy.properties import StringProperty # pylint: disable=no-name-in-module
12
12
  from kivy.lang import Builder
13
13
  from kivy.logger import Logger
14
+ from kivy.properties import StringProperty # pylint: disable=no-name-in-module
14
15
  from kivy.uix.popup import Popup
15
16
 
16
17
 
17
18
  def load_exception_popup_style():
18
19
  """Load KV rules for `UnhandledExceptionPopupHere`."""
19
- Builder.load_string(
20
- """<-UnhandledExceptionPopupHere>:
21
- title: "Unhandled Exception catched"
22
- BoxLayout:
23
- orientation: 'vertical'
24
- padding: 10
25
- spacing: 20
26
- Label:
27
- size_hint_y: None
28
- font_size: '18sp'
29
- height: '24sp'
30
- text: 'Exception details: '
31
- ScrollView:
32
- CodeInput:
33
- id: catched_exception_code_input_here
34
- text: root.message
35
- size_hint: 1, None
36
- height: self.minimum_height
37
- Button:
38
- size_hint_y: None
39
- height: '40sp'
40
- text: 'OK, continue'
41
- on_press: root.dismiss()
42
- """
43
- )
20
+ kv_path = str(Path(__file__).with_suffix(".kv"))
21
+
22
+ if kv_path not in Builder.files:
23
+ Builder.load_file(kv_path)
44
24
 
45
25
 
46
26
  class ErrorMessageOnException(ExceptionHandler):
@@ -48,9 +28,9 @@ class ErrorMessageOnException(ExceptionHandler):
48
28
 
49
29
  def handle_exception(self, exception) -> int:
50
30
  """Handle a exception."""
51
- Logger.exception("Unhandled Exception catched")
52
31
  if isinstance(exception, (asyncio.CancelledError, KeyboardInterrupt)):
53
32
  return ExceptionManager.RAISE
33
+ Logger.exception("Unhandled Exception catched")
54
34
  show_exception_popup()
55
35
  return ExceptionManager.PASS
56
36
 
@@ -63,10 +43,14 @@ class UnhandledExceptionPopupHere(Popup):
63
43
 
64
44
  def install_exception_handler():
65
45
  """Install `ErrorMessageOnException` exception handler."""
66
- ExceptionManager.add_handler(ErrorMessageOnException())
46
+ if not any(
47
+ isinstance(handler, ErrorMessageOnException)
48
+ for handler in ExceptionManager.handlers
49
+ ):
50
+ ExceptionManager.add_handler(ErrorMessageOnException())
67
51
 
68
52
 
69
- def show_exception_popup(exc: Optional[Exception] = None):
53
+ def show_exception_popup(exc: Exception | None = None):
70
54
  """Show exception popup."""
71
55
  load_exception_popup_style()
72
56
  if exc:
@@ -0,0 +1,46 @@
1
+ """Utilities for launching scripts."""
2
+
3
+ import os
4
+ import runpy
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from kivy import platform
9
+ from kivy.logger import Logger
10
+
11
+
12
+ def run_script(script: str):
13
+ """Execute given script."""
14
+ Logger.info("PythonHere: Run script %s", script)
15
+ try:
16
+ path = Path(script).resolve(strict=True)
17
+ except FileNotFoundError:
18
+ Logger.error("Script not found: %s", script)
19
+ raise Exception(f"Script not found: {script}") from None
20
+
21
+ original_cwd = str(Path.cwd())
22
+ original_sys_path = sys.path[:]
23
+ try:
24
+ script_dir = path.parent
25
+ os.chdir(str(script_dir))
26
+ sys.path.insert(0, str(script_dir))
27
+ runpy.run_path(str(path), run_name="__main__")
28
+ finally:
29
+ os.chdir(original_cwd)
30
+ sys.path = original_sys_path
31
+
32
+
33
+ def try_startup_script():
34
+ """Execute startup script, if it was passed to app."""
35
+ if platform != "android":
36
+ return
37
+ import android_here # pylint: disable=import-outside-toplevel
38
+
39
+ try:
40
+ android_here.bind_run_script_on_new_intent()
41
+ script = android_here.get_startup_script()
42
+ if script:
43
+ run_script(script)
44
+ except Exception:
45
+ Logger.exception("PythonHere: Error while starting script")
46
+ raise
@@ -4,11 +4,10 @@
4
4
  from base64 import b64decode
5
5
  from io import BytesIO, StringIO
6
6
 
7
- from PIL import Image as PILImage
8
- from IPython.display import display
9
7
  import click
10
- from herethere.there.commands import there_group, there_code_shortcut
11
-
8
+ from herethere.there.commands import there_code_shortcut, there_group
9
+ from IPython.display import display
10
+ from PIL import Image as PILImage
12
11
 
13
12
  KV_COMMAND_TEMPLATE = r"""
14
13
  from window_here import load_kv_string
@@ -21,6 +20,11 @@ from window_here import encoded_screenshot
21
20
  sys.stderr.write(encoded_screenshot())
22
21
  """
23
22
 
23
+ PIN_COMMAND_TEMPLATE = """
24
+ from android_here import pin_shortcut
25
+ pin_shortcut(script="{script}", label="{label}")
26
+ """
27
+
24
28
 
25
29
  @there_code_shortcut
26
30
  @click.option(
@@ -62,10 +66,22 @@ def screenshot(ctx, width, output):
62
66
  img = PILImage.open(BytesIO(data)).convert("RGB")
63
67
  if width:
64
68
  height = int(width * img.size[1] // img.size[0])
65
- img = img.resize((width, height), PILImage.ANTIALIAS)
69
+ resampling = getattr(PILImage, "Resampling", PILImage)
70
+ resample_filter = getattr(resampling, "LANCZOS", PILImage.LANCZOS)
71
+ img = img.resize((width, height), resample_filter)
66
72
 
67
73
  if output:
68
74
  img.save(output)
69
75
  output.close()
70
76
 
71
77
  display(img)
78
+
79
+
80
+ @there_code_shortcut
81
+ @click.option("-l", "--label", type=str, default="", help="Label for shortcut")
82
+ @click.argument("script", nargs=1)
83
+ def pin(code: str, script: str, label: str) -> str: # pylint: disable=unused-argument
84
+ """Create pinned shortcut to run a Python script from Android home screen."""
85
+ if not label:
86
+ label = script.split("/")[-1]
87
+ return PIN_COMMAND_TEMPLATE.format(script=script, label=label)
pythonhere/main.py CHANGED
@@ -1,15 +1,29 @@
1
1
  """PythonHere app."""
2
+
3
+ # pylint: disable=wrong-import-order,wrong-import-position
4
+
5
+ from launcher_here import try_startup_script
6
+
7
+ try:
8
+ try_startup_script() # run script entrypoint, if it was passed
9
+ except Exception as exc:
10
+ startup_script_exception = exc # pylint: disable=invalid-name
11
+ else:
12
+ startup_script_exception = None # pylint: disable=invalid-name
13
+
2
14
  import asyncio
3
15
  import os
4
- from pathlib import Path
5
16
  import sys
6
- from typing import Any, Dict
17
+ import threading
18
+ from pathlib import Path
19
+ from typing import Any
7
20
 
21
+ from enum_here import ScreenName, ServerState
22
+ from exception_manager_here import install_exception_handler, show_exception_popup
8
23
  from kivy.app import App
24
+ from kivy.clock import Clock
25
+ from kivy.config import Config, ConfigParser
9
26
  from kivy.logger import Logger
10
-
11
- from enum_here import ScreenName
12
- from exception_manager_here import install_exception_handler
13
27
  from patches_here import monkeypatch_kivy
14
28
  from server_here import run_ssh_server
15
29
  from window_here import reset_window_environment
@@ -22,13 +36,25 @@ class PythonHereApp(App):
22
36
 
23
37
  def __init__(self):
24
38
  super().__init__()
25
- self.server_task = None
39
+ self.server_task: asyncio.Task | None = None
40
+ self.app_task: asyncio.Task | None = None
41
+ self.asyncio_loop: asyncio.AbstractEventLoop | None = None
26
42
  self.settings = None
43
+
44
+ # Created once a running asyncio loop exists.
45
+ self.ssh_server_config_ready: asyncio.Event | None = None
46
+ self.ssh_server_started: asyncio.Event | None = None
47
+ self.ssh_server_connected: asyncio.Event | None = None
48
+
49
+ self.ssh_server_namespace = {}
50
+ self.icon = "data/logo/logo-32.png"
51
+
52
+ def init_asyncio_state(self):
53
+ """Initialize asyncio-owned state after the event loop is running."""
54
+ self.asyncio_loop = asyncio.get_running_loop()
27
55
  self.ssh_server_config_ready = asyncio.Event()
28
56
  self.ssh_server_started = asyncio.Event()
29
57
  self.ssh_server_connected = asyncio.Event()
30
- self.ssh_server_namespace = {}
31
- self.icon = "data/logo/logo-32.png"
32
58
 
33
59
  @property
34
60
  def upload_dir(self) -> str:
@@ -38,59 +64,120 @@ class PythonHereApp(App):
38
64
  upload_dir.mkdir(exist_ok=True)
39
65
  return str(upload_dir)
40
66
 
67
+ @property
68
+ def config_path(self) -> str:
69
+ """Path to the application config file."""
70
+ root_dir = Path(self.user_data_dir or ".").resolve()
71
+ return str(root_dir / "config.ini")
72
+
73
+ def load_config(self) -> ConfigParser:
74
+ """Returning the application configuration."""
75
+ Config.read(self.config_path) # Override the configuration file location
76
+ return super().load_config()
77
+
41
78
  def build(self):
42
79
  """Initialize application UI."""
43
80
  super().build()
44
81
  install_exception_handler()
45
-
46
82
  self.settings = self.root.ids.settings
47
-
48
83
  self.ssh_server_namespace.update(
49
84
  {
50
85
  "app": self,
51
86
  "root": self.root,
52
87
  }
53
88
  )
54
-
55
89
  self.update_server_config_status()
56
- self.root.switch_screen(
57
- ScreenName.here
58
- if self.ssh_server_config_ready.is_set()
59
- else ScreenName.settings
60
- )
90
+ if startup_script_exception:
91
+ Clock.schedule_once(
92
+ lambda _: show_exception_popup(startup_script_exception), 0
93
+ )
61
94
 
62
- def run_app(self):
63
- """Run application and SSH server tasks."""
64
- self.ssh_server_started = asyncio.Event()
65
- self.server_task = asyncio.ensure_future(run_ssh_server(self))
66
- return asyncio.gather(self.async_run_app(), self.server_task)
95
+ async def run_app(self):
96
+ """Run application and SSH server until either main task exits."""
97
+ self.init_asyncio_state()
98
+
99
+ self.server_task = asyncio.create_task(run_ssh_server(self))
100
+ self.app_task = asyncio.create_task(self.async_run_app())
101
+
102
+ tasks = (self.server_task, self.app_task)
103
+
104
+ try:
105
+ done, pending = await asyncio.wait(
106
+ tasks,
107
+ return_when=asyncio.FIRST_COMPLETED,
108
+ )
109
+
110
+ for task in pending:
111
+ task.cancel()
112
+
113
+ if pending:
114
+ await asyncio.gather(*pending, return_exceptions=True)
115
+
116
+ for task in done:
117
+ if task.cancelled():
118
+ continue
119
+ exc = task.exception()
120
+ if exc is not None:
121
+ raise exc
122
+ finally:
123
+ await self.cancel_app_tasks()
67
124
 
68
125
  async def async_run_app(self):
69
126
  """Run app asynchronously."""
70
127
  try:
71
- await self.async_run(async_lib="asyncio")
128
+ await self.async_run()
72
129
  Logger.info("PythonHere: async run completed")
73
130
  except asyncio.CancelledError:
74
131
  Logger.info("PythonHere: app main task canceled")
132
+ raise
75
133
  except Exception as exc:
76
134
  Logger.exception(exc)
135
+ raise
136
+ finally:
137
+ if self.get_running_app():
138
+ self.stop()
139
+
140
+ async def cancel_app_tasks(self):
141
+ """Cancel tasks owned by this app."""
142
+ tasks = [
143
+ task
144
+ for task in (self.server_task, self.app_task)
145
+ if task is not None
146
+ and task is not asyncio.current_task()
147
+ and not task.done()
148
+ ]
149
+
150
+ for task in tasks:
151
+ task.cancel()
152
+
153
+ if tasks:
154
+ await asyncio.gather(*tasks, return_exceptions=True)
77
155
 
78
- if self.server_task:
79
- self.server_task.cancel()
156
+ def update_server_config_status(self):
157
+ """Check and update value of the `ssh_server_config_ready`, update screen."""
80
158
 
81
- if self.get_running_app():
82
- self.stop()
159
+ def update():
160
+ if all(self.get_pythonhere_config().values()):
161
+ if (
162
+ self.asyncio_loop is not None
163
+ and self.ssh_server_config_ready is not None
164
+ ):
165
+ self.asyncio_loop.call_soon_threadsafe(
166
+ self.ssh_server_config_ready.set
167
+ )
83
168
 
84
- def update_server_config_status(self):
85
- """Check and update value of the `ssh_server_config_ready`."""
86
- if all(self.get_pythonhere_config().values()):
87
- self.ssh_server_config_ready.set()
169
+ Clock.schedule_once(lambda _: screen.update(), 0)
170
+
171
+ screen = self.root.ids.here_screen_manager
172
+ screen.current = ServerState.starting_server
173
+ self.root.switch_screen(ScreenName.here)
174
+ threading.Thread(name="update_server_config_status", target=update).start()
88
175
 
89
176
  def get_pythonhere_config(self):
90
177
  """Return user settings for SSH server."""
91
178
  return self.settings.get_pythonhere_config()
92
179
 
93
- def update_ssh_server_namespace(self, namespace: Dict[str, Any]):
180
+ def update_ssh_server_namespace(self, namespace: dict[str, Any]):
94
181
  """Update SSH server namespace."""
95
182
  self.ssh_server_namespace.update(namespace)
96
183
 
@@ -109,8 +196,14 @@ class PythonHereApp(App):
109
196
  def on_ssh_connection_made(self):
110
197
  """New authenticated SSH client connected handler."""
111
198
  Logger.info("PythonHere: new SSH client connected")
199
+
200
+ if self.ssh_server_connected is None:
201
+ Logger.warning("PythonHere: SSH connected before asyncio state was ready")
202
+ return
203
+
112
204
  if not self.ssh_server_connected.is_set():
113
205
  self.ssh_server_connected.set()
206
+
114
207
  Logger.info("PythonHere: reset window environment")
115
208
  self.ssh_server_namespace["root"] = reset_window_environment()
116
209
  self.chdir(self.upload_dir)
@@ -122,7 +215,11 @@ class PythonHereApp(App):
122
215
  sys.path.insert(0, path)
123
216
 
124
217
 
218
+ async def main():
219
+ """Run PythonHere."""
220
+ app = PythonHereApp()
221
+ await app.run_app()
222
+
223
+
125
224
  if __name__ == "__main__":
126
- loop = asyncio.get_event_loop()
127
- loop.run_until_complete(PythonHereApp().run_app())
128
- loop.close()
225
+ asyncio.run(main())
@@ -1,5 +1,6 @@
1
1
  """Network addresses discovering."""
2
- from typing import Iterator, Tuple
2
+
3
+ from collections.abc import Iterator
3
4
 
4
5
  from kivy import platform
5
6
 
@@ -14,7 +15,7 @@ else:
14
15
 
15
16
  def get_android_interface_addresses(
16
17
  interface: "NetworkInterface",
17
- ) -> Iterator[Tuple[str, str]]:
18
+ ) -> Iterator[tuple[str, str]]:
18
19
  """Yields active IPv4 addresses for given network interface."""
19
20
  if interface.isUp():
20
21
  addresses = interface.getInetAddresses()
@@ -24,7 +25,7 @@ def get_android_interface_addresses(
24
25
  yield interface.getDisplayName(), address.getHostAddress()
25
26
 
26
27
 
27
- def get_all_available_ipv4_adrresses() -> Iterator[Tuple[str, str]]:
28
+ def get_all_available_ipv4_adrresses() -> Iterator[tuple[str, str]]:
28
29
  """Yields available interfaces with IPv4 addresses
29
30
  available for connections from there.
30
31
  """
@@ -1,9 +1,8 @@
1
1
  """Monkey patching Kivy @('_')@."""
2
2
 
3
+ import kivy.uix.widget
3
4
  from kivy.factory import Factory
4
5
  from kivy.lang.builder import BuilderBase
5
- import kivy.uix.widget
6
-
7
6
 
8
7
  _original_factory_register = Factory.register
9
8
  _original_builderbase_match = BuilderBase.match
@@ -0,0 +1,49 @@
1
+ #:kivy 1.0
2
+ #:import RootLayout ui_here.layout_here.RootLayout
3
+ #:import ScreenName enum_here.ScreenName
4
+ #:import ServerState enum_here.ServerState
5
+ #:import ServerScreenManager ui_here.server_screen_here.ServerScreenManager
6
+ #:include ui_here/actionbar_here.kv
7
+ #:include ui_here/common_here.kv
8
+ #:include ui_here/connection_address_here.kv
9
+ #:include ui_here/server_screen_here.kv
10
+ #:include ui_here/settings_here.kv
11
+
12
+ RootLayout:
13
+ orientation: "vertical"
14
+
15
+ ActionBar:
16
+ pos_hint: {"top": 1}
17
+
18
+ ActionView:
19
+ ActionPrevious:
20
+ title: ""
21
+ with_previous: False
22
+ app_icon: "data/logo/logo-32.png"
23
+
24
+ ScreenActionButton:
25
+ id: open_here_action
26
+ text: f"%{tc('H', 3)}ere"
27
+ screen: ScreenName.here
28
+ ScreenActionButton:
29
+ id: open_settings_action
30
+ text: f"{tc('S', 3)}ettings"
31
+ screen: ScreenName.settings
32
+
33
+ ActionButton:
34
+ text: f"{tc('Q', 3)}uit"
35
+ on_release: app.get_running_app().stop()
36
+
37
+ ScreenManager:
38
+ id: screen_manager
39
+ Screen:
40
+ Screen:
41
+ id: here_screen
42
+ name: ScreenName.here
43
+ ServerScreenManager:
44
+ id: here_screen_manager
45
+ Screen:
46
+ id: settings_screen
47
+ name: ScreenName.settings
48
+ SettingsHere:
49
+ id: settings