Skip to content

Commit 0603672

Browse files
committed
Update for herethere 0.2.1 changes
1 parent d0ed581 commit 0603672

9 files changed

Lines changed: 191 additions & 44 deletions

File tree

CHANGELOG.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
Changelog
22
=========
33

4+
0.2.1
5+
-----
6+
7+
* Updated ``herethere`` package to 0.2.1, adding ``%there get`` and
8+
``%there download`` commands
9+
* Fixed Android shortcut icon lookup for ``%there pin``
10+
* Fixed ``%there`` reliability issues in notebooks, including event loop
11+
handling, port reuse after server shutdown, and cleanup behavior
12+
* Improved Docker JupyterLab defaults
13+
* Separated Android package names:
14+
``me.herethere.pythonhere`` for releases and
15+
``me.herethere.pythonhere_dev`` for source/self-built builds
16+
417
0.2.0
518
-----
619

README.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ PythonHere
1616
:alt: Supported Python versions
1717
.. image:: https://github.com/b3b/pythonhere/actions/workflows/tests.yml/badge.svg?branch=master
1818
:target: https://github.com/b3b/pythonhere/actions/workflows/tests.yml?query=branch%3Amaster
19-
:alt: CI Status
19+
:alt: CI Status
2020
.. image:: https://codecov.io/github/b3b/pythonhere/coverage.svg?branch=master
2121
:target: https://codecov.io/github/b3b/pythonhere?branch=master
2222
:alt: Code coverage Status
@@ -44,6 +44,9 @@ Project documentation: https://herethere.me/pythonhere
4444
Install the Android app
4545
-----------------------
4646

47+
Install *PythonHere* with `Obtainium <https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/b3b/pythonhere>`_
48+
to receive updates from GitHub Releases.
49+
4750
Ready-to-use *PythonHere* APKs are available from the `GitHub Releases <https://github.com/b3b/pythonhere/releases>`_ page.
4851

4952
For APK provenance and signing checks, see `Android APK verification <https://github.com/b3b/pythonhere/blob/master/docs/android-apk-verification.rst>`_.

buildozer.spec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ requirements =
3737
# herethere dependencies
3838
asyncssh==2.23.0,
3939
python-dotenv==1.2.2,
40-
herethere,
40+
herethere==0.2.1,
4141
# asyncssh dependencies
4242
cryptography,
4343
typing_extensions,

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ classifiers = [
2222
"Programming Language :: Python :: 3.14",
2323
]
2424
dependencies = [
25-
"herethere[magic]>=0.1.0",
25+
"herethere[magic]>=0.2.1",
2626
"ipython",
2727
"ipywidgets",
2828
"Pillow",
@@ -36,7 +36,6 @@ dev = [
3636
"docutils",
3737
"ifaddr",
3838
"kivy==2.3.1",
39-
"nest-asyncio2==1.7.2",
4039
"pylint",
4140
"pytest",
4241
"pytest-asyncio",
@@ -60,6 +59,7 @@ environments = [
6059

6160
[tool.pytest.ini_options]
6261
asyncio_mode = "auto"
62+
pythonpath = ["pythonhere"]
6363

6464
[tool.setuptools]
6565
packages = [

pythonhere/server_here.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def run_ssh_server(app):
3030
try:
3131
config = ServerConfig(
3232
host="",
33-
chroot=app.upload_dir,
33+
sftp_root=app.upload_dir,
3434
key_path=Path("./key.rsa").resolve(),
3535
**app.get_pythonhere_config(),
3636
)

tests/conftest.py

Lines changed: 157 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,53 @@
11
import asyncio
22
import os
33
import sys
4+
import types
45
from contextlib import suppress
56
from pathlib import Path
67

7-
import nest_asyncio2
88
import pytest
99
from asyncssh import PermissionDenied
1010
from herethere.everywhere import ConnectionConfig
11+
from herethere.everywhere.loop import run_sync
1112
from herethere.there.client import Client
1213
from herethere.there.commands import ContextObject, there_group
1314
from kivy.config import Config
1415
from kivy.core.window import Window
1516
from main import PythonHereApp, run_ssh_server
1617

1718

19+
async def run_herethere_sync(awaitable):
20+
"""Run sync magic work while the in-process SSH server keeps its test loop.
21+
22+
Use this only for code paths that exercise herethere's synchronous magic
23+
bridge. Plain async client tests should await the client API directly.
24+
"""
25+
return await asyncio.to_thread(run_sync, awaitable)
26+
27+
1828
@pytest.fixture
19-
def connection_config():
29+
def connection_config(app_config):
2030
return ConnectionConfig(
2131
host="localhost",
22-
port=8022,
32+
port=app_config,
2333
username="here",
2434
password="there",
2535
)
2636

2737

2838
@pytest.fixture
29-
def app_config():
30-
Config.read("../tests/config.ini")
39+
def app_config(unused_tcp_port):
40+
Config.read(str(Path(__file__).with_name("config.ini")))
41+
Config.set("pythonhere", "port", str(unused_tcp_port))
42+
return unused_tcp_port
3143

3244

3345
@pytest.fixture
3446
async def app_instance(mocker, capfd, app_config, tmpdir):
47+
original_cwd = Path.cwd()
48+
os.chdir(Path(__file__).parents[1] / "pythonhere")
3549
mocker.patch("main.App.user_data_dir", tmpdir)
50+
Window.size = (800, 600)
3651

3752
app = PythonHereApp()
3853
app.init_asyncio_state()
@@ -54,6 +69,7 @@ async def app_instance(mocker, capfd, app_config, tmpdir):
5469
raise result
5570
app.root.clear_widgets()
5671
Window.children.clear()
72+
os.chdir(original_cwd)
5773

5874

5975
@pytest.fixture
@@ -66,11 +82,37 @@ async def there(app_instance, connection_config):
6682
finally:
6783
connection = client.connection.connection
6884
await client.disconnect()
85+
6986
if connection is not None:
7087
with suppress(Exception):
7188
await connection.wait_closed()
7289

7390

91+
@pytest.fixture
92+
async def sync_there_client(app_instance, connection_config):
93+
"""Client connected on herethere's sync magic loop.
94+
95+
Use with command/magic helpers that call herethere.there.commands, because
96+
those commands call run_sync() internally and expect the client connection
97+
to belong to herethere's background magic loop.
98+
"""
99+
client = Client()
100+
await asyncio.wait_for(app_instance.ssh_server_started.wait(), 5)
101+
await run_herethere_sync(client.connect(connection_config))
102+
try:
103+
yield client
104+
finally:
105+
connection = client.connection.connection
106+
await run_herethere_sync(client.disconnect())
107+
108+
async def wait_closed():
109+
if connection is not None:
110+
await connection.wait_closed()
111+
112+
with suppress(Exception):
113+
await run_herethere_sync(wait_closed())
114+
115+
74116
@pytest.fixture
75117
async def there_with_wrong_password(app_instance, connection_config):
76118
client = Client()
@@ -82,18 +124,21 @@ async def there_with_wrong_password(app_instance, connection_config):
82124

83125

84126
@pytest.fixture
85-
def nested_event_loop():
86-
nest_asyncio2.apply()
127+
async def call_there_group(app_instance, sync_there_client):
128+
"""Call the synchronous %there command group from async tests.
87129
130+
The command itself is synchronous, so it is run in a worker thread. This
131+
leaves pytest's event loop free to service the in-process PythonHere SSH
132+
server which receives the command.
133+
"""
88134

89-
@pytest.fixture
90-
async def call_there_group(nested_event_loop, app_instance, there):
91-
def _callable(args, code):
92-
there_group(
135+
async def _callable(args, code):
136+
return await asyncio.to_thread(
137+
there_group,
93138
args,
94139
"test",
95140
standalone_mode=False,
96-
obj=ContextObject(client=there, code=code),
141+
obj=ContextObject(client=sync_there_client, code=code),
97142
)
98143

99144
return _callable
@@ -112,8 +157,106 @@ def preserve_cwd():
112157

113158
@pytest.fixture
114159
def mocked_android_modules(mocker):
115-
sys.modules["jnius"] = mocker.Mock()
116-
sys.modules["android"] = mocker.Mock()
160+
"""Install a small fake Android/Jnius surface for Android-only code paths.
161+
162+
Keep this fake narrow: add methods/constants here only when tests exercise
163+
the corresponding behavior in android_here or launcher_here.
164+
"""
165+
activity = mocker.Mock()
166+
context = mocker.Mock()
167+
app_info = mocker.Mock(icon=1)
168+
manager = mocker.Mock()
169+
manager.isRequestPinShortcutSupported.return_value = True
170+
context.getApplicationInfo.return_value = app_info
171+
activity.getApplicationContext.return_value = context
172+
activity.getSystemService.return_value = manager
173+
174+
class Context:
175+
SHORTCUT_SERVICE = "shortcut"
176+
177+
class Icon:
178+
createWithResource = mocker.Mock(return_value=mocker.Mock())
179+
180+
class Intent:
181+
FLAG_ACTIVITY_NEW_TASK = 1
182+
FLAG_ACTIVITY_CLEAR_TASK = 2
183+
ACTION_MAIN = "android.intent.action.MAIN"
184+
185+
def __init__(self, *args):
186+
self.args = args
187+
self.data = None
188+
self.flags = None
189+
self.action = None
190+
191+
def setAction(self, action):
192+
self.action = action
193+
return self
194+
195+
def setData(self, data):
196+
self.data = data
197+
return self
198+
199+
def setFlags(self, flags):
200+
self.flags = flags
201+
return self
202+
203+
def getData(self):
204+
return self.data
205+
206+
class PythonActivity:
207+
mActivity = activity
208+
209+
class ShortcutInfoBuilder:
210+
def __init__(self, *args):
211+
self.args = args
212+
213+
def setShortLabel(self, label):
214+
self.short_label = label
215+
return self
216+
217+
def setLongLabel(self, label):
218+
self.long_label = label
219+
return self
220+
221+
def setIntent(self, intent):
222+
self.intent = intent
223+
return self
224+
225+
def setIcon(self, icon):
226+
self.icon = icon
227+
return self
228+
229+
def build(self):
230+
return self
231+
232+
class System:
233+
exit = mocker.Mock()
234+
235+
class Uri:
236+
@staticmethod
237+
def parse(value):
238+
uri = mocker.Mock()
239+
uri.toString.return_value = value
240+
return uri
241+
242+
classes = {
243+
"android.content.Context": Context,
244+
"android.graphics.drawable.Icon": Icon,
245+
"android.content.Intent": Intent,
246+
"org.kivy.android.PythonActivity": PythonActivity,
247+
"android.content.pm.ShortcutInfo$Builder": ShortcutInfoBuilder,
248+
"java.lang.System": System,
249+
"android.net.Uri": Uri,
250+
}
251+
252+
def autoclass(name):
253+
return classes[name]
254+
255+
sys.modules["jnius"] = types.SimpleNamespace(
256+
autoclass=autoclass,
257+
cast=mocker.Mock(side_effect=lambda _class_name, obj: obj),
258+
)
259+
sys.modules["android"] = types.SimpleNamespace(activity=mocker.Mock())
117260

118261

119262
@pytest.fixture

tests/test_magic.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
async def test_kv_command_runcode_called(call_there_group, mocker):
1111
runcode = mocker.patch.object(ContextObject, "runcode", autospec=True)
1212

13-
call_there_group(["kv"], "Label:")
13+
await call_there_group(["kv"], "Label:")
1414

1515
runcode.assert_called_once()
1616
ctx_obj = runcode.call_args[0][0]
@@ -20,41 +20,41 @@ async def test_kv_command_runcode_called(call_there_group, mocker):
2020
@pytest.mark.asyncio
2121
async def test_kv_command_executed(capfd, app_instance, call_there_group):
2222
assert not getattr(app_instance.root, "text", "")
23-
call_there_group(["kv"], "Label:\n text: '''Hello there'''")
23+
await call_there_group(["kv"], "Label:\n text: '''Hello there'''")
2424
captured = capfd.readouterr()
2525
assert not captured.out and not captured.err
2626
assert app_instance.root.text == "Hello there"
2727

2828

2929
@pytest.mark.asyncio
3030
async def test_screenshot_command_executed(app_instance, call_there_group):
31-
call_there_group(["screenshot"], "")
31+
await call_there_group(["screenshot"], "")
3232

3333

3434
@pytest.mark.asyncio
3535
async def test_screenshot_saved_to_file(tmpdir, app_instance, call_there_group):
3636
output = Path(tmpdir) / "test.png"
3737
assert not os.path.exists(output)
38-
call_there_group(["screenshot", "-o", output], "")
38+
await call_there_group(["screenshot", "-o", output], "")
3939
assert os.path.exists(output)
4040

4141

4242
@pytest.mark.asyncio
4343
async def test_screenshot_resized_to_width(tmpdir, app_instance, call_there_group):
4444
output = Path(tmpdir) / "test.png"
4545

46-
call_there_group(["screenshot", "--width", "100", "-o", output], "")
46+
await call_there_group(["screenshot", "--width", "100", "-o", output], "")
4747

4848
image = shortcuts.PILImage.open(output)
49-
assert image.size == (100, 75)
49+
assert image.size[0] == 100
5050

5151

5252
@pytest.mark.asyncio
5353
async def test_pin_command_pin_shortcut_called(
5454
mocker, capfd, mocked_android_modules, call_there_group, test_py_script
5555
):
5656
pin_shortcut = mocker.patch("android_here.pin_shortcut")
57-
call_there_group(["pin", test_py_script, "--label", "Test label"], "")
57+
await call_there_group(["pin", test_py_script, "--label", "Test label"], "")
5858
captured = capfd.readouterr()
5959
assert not captured.out and not captured.err
6060

@@ -66,7 +66,7 @@ async def test_pin_command_default_label(
6666
mocker, capfd, mocked_android_modules, call_there_group, test_py_script
6767
):
6868
pin_shortcut = mocker.patch("android_here.pin_shortcut")
69-
call_there_group(["pin", test_py_script], "")
69+
await call_there_group(["pin", test_py_script], "")
7070
captured = capfd.readouterr()
7171
assert not captured.out and not captured.err
7272

tests/test_server_here.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ async def test_run_ssh_server_clears_config_ready_after_start_error(mocker):
6060
start_server.assert_called_once()
6161
config = start_server.call_args.args[0]
6262
assert config.host == ""
63-
assert config.chroot == app.upload_dir
63+
assert config.sftp_root == app.upload_dir
6464
assert config.key_path == Path("./key.rsa").resolve()
6565
assert not app.ssh_server_config_ready.is_set()
6666
show_exception_popup.assert_called_once_with(start_error)

0 commit comments

Comments
 (0)