diff --git a/buildozer.spec b/buildozer.spec index 2092cf3..81bc95d 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -37,7 +37,7 @@ requirements = # herethere dependencies asyncssh==2.23.0, python-dotenv==1.2.2, - herethere, + herethere==0.2.1, # asyncssh dependencies cryptography, typing_extensions, diff --git a/pyproject.toml b/pyproject.toml index c93d705..a73f936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "herethere[magic]>=0.1.0", + "herethere[magic]>=0.2.1", "ipython", "ipywidgets", "Pillow", @@ -36,7 +36,6 @@ dev = [ "docutils", "ifaddr", "kivy==2.3.1", - "nest-asyncio2==1.7.2", "pylint", "pytest", "pytest-asyncio", @@ -60,6 +59,7 @@ environments = [ [tool.pytest.ini_options] asyncio_mode = "auto" +pythonpath = ["pythonhere"] [tool.setuptools] packages = [ diff --git a/pythonhere/server_here.py b/pythonhere/server_here.py index 2458461..8de4210 100644 --- a/pythonhere/server_here.py +++ b/pythonhere/server_here.py @@ -30,7 +30,7 @@ async def run_ssh_server(app): try: config = ServerConfig( host="", - chroot=app.upload_dir, + sftp_root=app.upload_dir, key_path=Path("./key.rsa").resolve(), **app.get_pythonhere_config(), ) diff --git a/tests/conftest.py b/tests/conftest.py index 8dc75ae..df16312 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,14 @@ import asyncio import os import sys +import types from contextlib import suppress from pathlib import Path -import nest_asyncio2 import pytest from asyncssh import PermissionDenied from herethere.everywhere import ConnectionConfig +from herethere.everywhere.loop import run_sync from herethere.there.client import Client from herethere.there.commands import ContextObject, there_group from kivy.config import Config @@ -15,24 +16,38 @@ from main import PythonHereApp, run_ssh_server +async def run_herethere_sync(awaitable): + """Run sync magic work while the in-process SSH server keeps its test loop. + + Use this only for code paths that exercise herethere's synchronous magic + bridge. Plain async client tests should await the client API directly. + """ + return await asyncio.to_thread(run_sync, awaitable) + + @pytest.fixture -def connection_config(): +def connection_config(app_config): return ConnectionConfig( host="localhost", - port=8022, + port=app_config, username="here", password="there", ) @pytest.fixture -def app_config(): - Config.read("../tests/config.ini") +def app_config(unused_tcp_port): + Config.read(str(Path(__file__).with_name("config.ini"))) + Config.set("pythonhere", "port", str(unused_tcp_port)) + return unused_tcp_port @pytest.fixture async def app_instance(mocker, capfd, app_config, tmpdir): + original_cwd = Path.cwd() + os.chdir(Path(__file__).parents[1] / "pythonhere") mocker.patch("main.App.user_data_dir", tmpdir) + Window.size = (800, 600) app = PythonHereApp() app.init_asyncio_state() @@ -54,6 +69,7 @@ async def app_instance(mocker, capfd, app_config, tmpdir): raise result app.root.clear_widgets() Window.children.clear() + os.chdir(original_cwd) @pytest.fixture @@ -66,11 +82,37 @@ async def there(app_instance, connection_config): finally: connection = client.connection.connection await client.disconnect() + if connection is not None: with suppress(Exception): await connection.wait_closed() +@pytest.fixture +async def sync_there_client(app_instance, connection_config): + """Client connected on herethere's sync magic loop. + + Use with command/magic helpers that call herethere.there.commands, because + those commands call run_sync() internally and expect the client connection + to belong to herethere's background magic loop. + """ + client = Client() + await asyncio.wait_for(app_instance.ssh_server_started.wait(), 5) + await run_herethere_sync(client.connect(connection_config)) + try: + yield client + finally: + connection = client.connection.connection + await run_herethere_sync(client.disconnect()) + + async def wait_closed(): + if connection is not None: + await connection.wait_closed() + + with suppress(Exception): + await run_herethere_sync(wait_closed()) + + @pytest.fixture async def there_with_wrong_password(app_instance, connection_config): client = Client() @@ -82,18 +124,21 @@ async def there_with_wrong_password(app_instance, connection_config): @pytest.fixture -def nested_event_loop(): - nest_asyncio2.apply() +async def call_there_group(app_instance, sync_there_client): + """Call the synchronous %there command group from async tests. + The command itself is synchronous, so it is run in a worker thread. This + leaves pytest's event loop free to service the in-process PythonHere SSH + server which receives the command. + """ -@pytest.fixture -async def call_there_group(nested_event_loop, app_instance, there): - def _callable(args, code): - there_group( + async def _callable(args, code): + return await asyncio.to_thread( + there_group, args, "test", standalone_mode=False, - obj=ContextObject(client=there, code=code), + obj=ContextObject(client=sync_there_client, code=code), ) return _callable @@ -112,8 +157,106 @@ def preserve_cwd(): @pytest.fixture def mocked_android_modules(mocker): - sys.modules["jnius"] = mocker.Mock() - sys.modules["android"] = mocker.Mock() + """Install a small fake Android/Jnius surface for Android-only code paths. + + Keep this fake narrow: add methods/constants here only when tests exercise + the corresponding behavior in android_here or launcher_here. + """ + activity = mocker.Mock() + context = mocker.Mock() + app_info = mocker.Mock(icon=1) + manager = mocker.Mock() + manager.isRequestPinShortcutSupported.return_value = True + context.getApplicationInfo.return_value = app_info + activity.getApplicationContext.return_value = context + activity.getSystemService.return_value = manager + + class Context: + SHORTCUT_SERVICE = "shortcut" + + class Icon: + createWithResource = mocker.Mock(return_value=mocker.Mock()) + + class Intent: + FLAG_ACTIVITY_NEW_TASK = 1 + FLAG_ACTIVITY_CLEAR_TASK = 2 + ACTION_MAIN = "android.intent.action.MAIN" + + def __init__(self, *args): + self.args = args + self.data = None + self.flags = None + self.action = None + + def setAction(self, action): + self.action = action + return self + + def setData(self, data): + self.data = data + return self + + def setFlags(self, flags): + self.flags = flags + return self + + def getData(self): + return self.data + + class PythonActivity: + mActivity = activity + + class ShortcutInfoBuilder: + def __init__(self, *args): + self.args = args + + def setShortLabel(self, label): + self.short_label = label + return self + + def setLongLabel(self, label): + self.long_label = label + return self + + def setIntent(self, intent): + self.intent = intent + return self + + def setIcon(self, icon): + self.icon = icon + return self + + def build(self): + return self + + class System: + exit = mocker.Mock() + + class Uri: + @staticmethod + def parse(value): + uri = mocker.Mock() + uri.toString.return_value = value + return uri + + classes = { + "android.content.Context": Context, + "android.graphics.drawable.Icon": Icon, + "android.content.Intent": Intent, + "org.kivy.android.PythonActivity": PythonActivity, + "android.content.pm.ShortcutInfo$Builder": ShortcutInfoBuilder, + "java.lang.System": System, + "android.net.Uri": Uri, + } + + def autoclass(name): + return classes[name] + + sys.modules["jnius"] = types.SimpleNamespace( + autoclass=autoclass, + cast=mocker.Mock(side_effect=lambda _class_name, obj: obj), + ) + sys.modules["android"] = types.SimpleNamespace(activity=mocker.Mock()) @pytest.fixture diff --git a/tests/test_magic.py b/tests/test_magic.py index 05ca3ad..06d6182 100644 --- a/tests/test_magic.py +++ b/tests/test_magic.py @@ -10,7 +10,7 @@ async def test_kv_command_runcode_called(call_there_group, mocker): runcode = mocker.patch.object(ContextObject, "runcode", autospec=True) - call_there_group(["kv"], "Label:") + await call_there_group(["kv"], "Label:") runcode.assert_called_once() ctx_obj = runcode.call_args[0][0] @@ -20,7 +20,7 @@ async def test_kv_command_runcode_called(call_there_group, mocker): @pytest.mark.asyncio async def test_kv_command_executed(capfd, app_instance, call_there_group): assert not getattr(app_instance.root, "text", "") - call_there_group(["kv"], "Label:\n text: '''Hello there'''") + await call_there_group(["kv"], "Label:\n text: '''Hello there'''") captured = capfd.readouterr() assert not captured.out and not captured.err assert app_instance.root.text == "Hello there" @@ -28,14 +28,14 @@ async def test_kv_command_executed(capfd, app_instance, call_there_group): @pytest.mark.asyncio async def test_screenshot_command_executed(app_instance, call_there_group): - call_there_group(["screenshot"], "") + await call_there_group(["screenshot"], "") @pytest.mark.asyncio async def test_screenshot_saved_to_file(tmpdir, app_instance, call_there_group): output = Path(tmpdir) / "test.png" assert not os.path.exists(output) - call_there_group(["screenshot", "-o", output], "") + await call_there_group(["screenshot", "-o", output], "") assert os.path.exists(output) @@ -43,10 +43,10 @@ async def test_screenshot_saved_to_file(tmpdir, app_instance, call_there_group): async def test_screenshot_resized_to_width(tmpdir, app_instance, call_there_group): output = Path(tmpdir) / "test.png" - call_there_group(["screenshot", "--width", "100", "-o", output], "") + await call_there_group(["screenshot", "--width", "100", "-o", output], "") image = shortcuts.PILImage.open(output) - assert image.size == (100, 75) + assert image.size[0] == 100 @pytest.mark.asyncio @@ -54,7 +54,7 @@ async def test_pin_command_pin_shortcut_called( mocker, capfd, mocked_android_modules, call_there_group, test_py_script ): pin_shortcut = mocker.patch("android_here.pin_shortcut") - call_there_group(["pin", test_py_script, "--label", "Test label"], "") + await call_there_group(["pin", test_py_script, "--label", "Test label"], "") captured = capfd.readouterr() assert not captured.out and not captured.err @@ -66,7 +66,7 @@ async def test_pin_command_default_label( mocker, capfd, mocked_android_modules, call_there_group, test_py_script ): pin_shortcut = mocker.patch("android_here.pin_shortcut") - call_there_group(["pin", test_py_script], "") + await call_there_group(["pin", test_py_script], "") captured = capfd.readouterr() assert not captured.out and not captured.err diff --git a/tests/test_server_here.py b/tests/test_server_here.py index 04793d3..3a23deb 100644 --- a/tests/test_server_here.py +++ b/tests/test_server_here.py @@ -60,7 +60,7 @@ async def test_run_ssh_server_clears_config_ready_after_start_error(mocker): start_server.assert_called_once() config = start_server.call_args.args[0] assert config.host == "" - assert config.chroot == app.upload_dir + assert config.sftp_root == app.upload_dir assert config.key_path == Path("./key.rsa").resolve() assert not app.ssh_server_config_ready.is_set() show_exception_popup.assert_called_once_with(start_error) diff --git a/uv.lock b/uv.lock index 9346157..acd671b 100644 --- a/uv.lock +++ b/uv.lock @@ -470,16 +470,16 @@ wheels = [ [[package]] name = "herethere" -version = "0.2.0" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asyncssh", marker = "sys_platform == 'linux'" }, { name = "click", marker = "sys_platform == 'linux'" }, { name = "python-dotenv", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/f9/108a004dc5de67f00cf2e86fa18bb0617852a082bc4c19bee5d8094faaf7/herethere-0.2.0.tar.gz", hash = "sha256:c60019a149d2f84da329616ff34cde2d6473d1f1bce699d3b0e70c80ca27d21c", size = 18115, upload-time = "2026-05-17T15:41:51.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/c9/a4fea92b31e272866422f0ca46232cc8959768d7412758b726d564d74284/herethere-0.2.1.tar.gz", hash = "sha256:be4fd93485ca633320e15b66e8d3df9c680a5a981cea27ea079b8fef3f9749d4", size = 25665, upload-time = "2026-05-25T19:45:23.689Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/d5/f35fb06b17575fa61daf9013fe526735c522088cdaa5681c5d0923ea1b3f/herethere-0.2.0-py3-none-any.whl", hash = "sha256:62b27942c47785ecb5db8ee7894342ce85aa458ebeb0796eb0f83048f809b29b", size = 18503, upload-time = "2026-05-17T15:41:50.104Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/2c9dfabe56c14e8e19000596b045aec69dde465a84d7c923346dfc8d7093/herethere-0.2.1-py3-none-any.whl", hash = "sha256:c5a509ad723b82e7984a3315d8dbe772501bf3b8ef30717c71cd049e7cf0ea94", size = 25472, upload-time = "2026-05-25T19:45:22.457Z" }, ] [package.optional-dependencies] @@ -487,7 +487,6 @@ magic = [ { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, { name = "ipywidgets", marker = "sys_platform == 'linux'" }, - { name = "nest-asyncio2", marker = "sys_platform == 'linux'" }, ] [[package]] @@ -867,15 +866,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, ] -[[package]] -name = "nest-asyncio2" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, -] - [[package]] name = "nh3" version = "0.3.5" @@ -1204,7 +1194,6 @@ dev = [ { name = "ifaddr", marker = "sys_platform == 'linux'" }, { name = "jupytext", marker = "sys_platform == 'linux'" }, { name = "kivy", marker = "sys_platform == 'linux'" }, - { name = "nest-asyncio2", marker = "sys_platform == 'linux'" }, { name = "pylint", marker = "sys_platform == 'linux'" }, { name = "pytest", marker = "sys_platform == 'linux'" }, { name = "pytest-asyncio", marker = "sys_platform == 'linux'" }, @@ -1219,7 +1208,7 @@ docker = [ [package.metadata] requires-dist = [ - { name = "herethere", extras = ["magic"], specifier = ">=0.1.0" }, + { name = "herethere", extras = ["magic"], specifier = ">=0.2.1" }, { name = "ipython" }, { name = "ipywidgets" }, { name = "pillow" }, @@ -1233,7 +1222,6 @@ dev = [ { name = "ifaddr" }, { name = "jupytext", specifier = "==1.19.3" }, { name = "kivy", specifier = "==2.3.1" }, - { name = "nest-asyncio2", specifier = "==1.7.2" }, { name = "pylint" }, { name = "pytest" }, { name = "pytest-asyncio" },