Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`

| 상태 | 항목 |
|---|---|
| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록), 실제 Compose 1.11.1-ext.1(`org.thisisthepy.compose.*:1.11.1-ext.1`, `material-icons-core:1.7.3` 은 upstream 유지; #127)에서 생성한 `.pyi` 스텁과 `py.typed`(wheel 포함, mypy 로 검사), `Alignment`/`Arrangement` 상수(평면 `Alignment.End` 와 묶음 `Alignment.Horizontal.End` 모두, #9), proxy 메서드의 snake_case 이름과 키워드 인자(`m.padding(padding_values=...)`; python-multiplatform #131 이 바인더 쪽에서 제공하므로 #131 이상 필요; Kotlin 이름도 실행 시 그대로 통하고 스텁은 snake_case 만), 함수이기도 한 Kotlin object 의 호출(`Color(0xFFFFFFFF)` 과 `Color.Red` 둘 다; #88, 설치한 wheel 에서도 테스트됨. Int/Long 오버로드 선택은 python-multiplatform #146), TypedPython 게이트 통과(`pythonx/` 에 오류 0, 경고 5: `_reexport.py` 의 재노출 자체인 `dynamic-attr`; #118, CI 의 with-binder 에서 검사) |
| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, `DefaultCoroutineScope`·`MainCoroutineScope`(`pythonx.concurrent.CoroutineScope` 기반; #94), 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록), 실제 Compose 1.11.1-ext.1(`org.thisisthepy.compose.*:1.11.1-ext.1`, `material-icons-core:1.7.3` 은 upstream 유지; #127)에서 생성한 `.pyi` 스텁과 `py.typed`(wheel 포함, mypy 로 검사), `Alignment`/`Arrangement` 상수(평면 `Alignment.End` 와 묶음 `Alignment.Horizontal.End` 모두, #9), proxy 메서드의 snake_case 이름과 키워드 인자(`m.padding(padding_values=...)`; python-multiplatform #131 이 바인더 쪽에서 제공하므로 #131 이상 필요; Kotlin 이름도 실행 시 그대로 통하고 스텁은 snake_case 만), 함수이기도 한 Kotlin object 의 호출(`Color(0xFFFFFFFF)` 과 `Color.Red` 둘 다; #88, 설치한 wheel 에서도 테스트됨. Int/Long 오버로드 선택은 python-multiplatform #146), TypedPython 게이트 통과(`pythonx/` 에 오류 0, 경고 5: `_reexport.py` 의 재노출 자체인 `dynamic-attr`; #118, CI 의 with-binder 에서 검사) |
| 부분 | 노트북 표기 그대로의 `remember_saveable`(`getValue()`/`setValue()`, `.value` 별칭; #102, 가짜 호스트로 테스트됨, 저장·복원 증거는 python-multiplatform #174), 선언형 앱 루트 `@app`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 증거는 python-multiplatform 노트북 E2E #26 에서 통과; 숫자·문자열 상태는 python-multiplatform #69 부터 왕복됨), 배포 설정, Material 3 위젯(렌더 증거는 python-multiplatform 노트북 E2E 에만, 설치한 wheel 에서 Kotlin 과 픽셀 일치), `Icon`(import 만 됨)·`DefaultIcons`(`Icons.Default` alias, 가짜 호스트로만 테스트됨, 스텁에서는 `DefaultIcons.Add` 가 `ImageVector`; `DefaultIcons.Add` 로 쓴다; 노트북의 `DefaultIcons.Add()` 가 아님), `TextField(state=...)`·`TextFieldState`(#10; `pythonx.compose.foundation.text.input`, 가짜 호스트로 테스트됨, 노트북의 `text_state=`/`padding=` 는 `state=`/`modifier=Modifier.padding(8)`; IME 조합 증거는 python-multiplatform 노트북 E2E #26 에서 통과) |
| 계획 | 색 스킴, 코루틴 스코프(pythonx-concurrent 이후) |
| 계획 | 색 스킴 |

### 테스트 기준선

```
GitHub Actions, develop (2026-10-24, #127; with-binder 는 pyrefly==1.3.2 포함)
PythonMultiplatform 체크아웃 없음 156 passed, 129 skipped
python-multiplatform develop 31c092f0+ 283 passed, 2 skipped (UI.ipynb, .tmp/kotlin-stubs.zip 없음)
GitHub Actions, develop (2026-10-24, #94; with-binder 는 pyrefly==1.3.2 포함)
PythonMultiplatform 체크아웃 없음 160 passed, 129 skipped
python-multiplatform develop 31c092f0+ 287 passed, 2 skipped (UI.ipynb, .tmp/kotlin-stubs.zip 없음)
describe_member 없는 체크아웃 같은 수, 그중 7 개 건너뜀 (python-multiplatform #54 이전)
describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전)
그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (#131 이전, snake_case 이름 없음)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ standalone desktop toolkit.
| `DefaultIcons` (`Icons.Default`), written `DefaultIcons.Add` | 🟡 partial: tested against a fake host shaped after python-multiplatform #37/#38; `Icon(DefaultIcons.Add, …)` is drawn in python-multiplatform's render test |
| Colour schemes | ⏳ planned |
| `remember_saveable` with the notebook's `getValue()` / `setValue()` | 🟡 partial: tested against a fake host; save and restore is python-multiplatform's test (#174) |
| Coroutine scopes (`DefaultCoroutineScope`, `MainCoroutineScope`) | ⏳ planned, after pythonx-concurrent |
| Coroutine scopes (`DefaultCoroutineScope`, `MainCoroutineScope`) | ✅ implemented and tested on top of `pythonx.concurrent` (#94) |

The full list is on the guide's [Status page](https://thisisthepy.github.io/pythonx-compose/status.html).

Expand Down
25 changes: 17 additions & 8 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,14 +502,23 @@ Stubs: `tests/test_stubs.py::TheConversion::test_a_class_the_package_defines_kee
Typing: `tests/test_typing.py::test_remember_saveable_types_the_notebook_spelling` and
`test_what_remember_saveable_returns_has_no_other_spelling`.

### S5.7 `DefaultCoroutineScope`, `MainCoroutineScope`: `planned` (after pythonx-concurrent)

INTENT §5.10. In 2024 `DefaultCoroutineScope()` was Kotlin's `CoroutineScope(Dispatchers.Default)`
and `MainCoroutineScope()` was `CoroutineScope(Dispatchers.Main)`, each with `.launch(block)`
running a Python callable (PyREPL `abc6952`, `Runtime.kt`). Here they are names in
`pythonx.compose.runtime` on top of `pythonx.concurrent`: a `pythonx.concurrent` scope on
`Dispatchers.Default` and one on `Dispatchers.Main`, with `launch`. Nothing is implemented before
pythonx-concurrent provides scopes and dispatchers.
### S5.7 `DefaultCoroutineScope`, `MainCoroutineScope`: `implemented`

INTENT §5.10 (issue #94). In 2024 `DefaultCoroutineScope()` was Kotlin's
`CoroutineScope(Dispatchers.Default)` and `MainCoroutineScope()` was
`CoroutineScope(Dispatchers.Main)`, each with `.launch(block)` running a Python callable (PyREPL
`abc6952`, `Runtime.kt`). Here they are functions in `pythonx.compose.runtime` built on
`pythonx.concurrent` (Milestone 1, `thisisthepy/pythonx-concurrent#21`):

- `DefaultCoroutineScope()` imports `pythonx.concurrent` and returns
`CoroutineScope(Dispatchers.Default)`.
- `MainCoroutineScope()` imports `pythonx.concurrent` and returns
`CoroutineScope(Dispatchers.Main)`.
- When `pythonx.concurrent` is not installed, calling either function raises `RuntimeError` naming
`pythonx-concurrent`.

Tests: `tests/test_coroutine_scopes.py` (`CoroutineScopesSurface`, `CoroutineScopesWithConcurrent`)
and `tests/test_typing.py::test_default_and_main_coroutine_scopes_type_check`.

## 6. Modifiers: extension functions as methods

Expand Down
4 changes: 2 additions & 2 deletions docs/guide/status.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/locale/README_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ pip 패키지 **`pythonx-compose`**(파이썬 3.11 이상)로 배포되며, impo
| `DefaultIcons`(`Icons.Default`), `DefaultIcons.Add` 로 씀 | 🟡 부분: python-multiplatform #37/#38 모양의 가짜 호스트로 테스트됨; `Icon(DefaultIcons.Add, …)` 의 실제 그리기는 python-multiplatform 의 렌더 테스트에 있음 |
| 색 스킴 | ⏳ 계획 |
| 노트북의 `getValue()` / `setValue()` 를 쓰는 `remember_saveable` | 🟡 부분: 가짜 호스트로 테스트됨, 저장과 복원은 python-multiplatform 의 테스트(#174) |
| 코루틴 스코프(`DefaultCoroutineScope`, `MainCoroutineScope`) | ⏳ 계획, pythonx-concurrent 이후 |
| 코루틴 스코프(`DefaultCoroutineScope`, `MainCoroutineScope`) | ✅ 구현, `pythonx.concurrent` 위에서 테스트됨 (#94) |

전체 목록은 가이드의 [현황 페이지](https://thisisthepy.github.io/pythonx-compose/status.html)에 있습니다.

Expand Down
65 changes: 65 additions & 0 deletions pythonx/compose/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@
A host app binds the wrapper by listing `python.multiplatform.compose.RememberSaveableKt` in its
`artifactIncludePackages`.

## `DefaultCoroutineScope` and `MainCoroutineScope`

`UI.ipynb` cell 5 imports `DefaultCoroutineScope` and `MainCoroutineScope` from
`pythonx.compose.runtime` (`docs/INTENT.md` section 5.10, `docs/SPEC.md` S5.7, issue #94). They are
built on `pythonx.concurrent`: `DefaultCoroutineScope()` returns
`pythonx.concurrent.CoroutineScope(Dispatchers.Default)` and `MainCoroutineScope()` returns
`pythonx.concurrent.CoroutineScope(Dispatchers.Main)`. `pythonx.concurrent` is imported when either
factory is called, and if it is not installed a `RuntimeError` names `pythonx-concurrent`.

## How it is reached

By its own dotted name: `import pythonx.compose.runtime` loads this file. The binder used to put a
Expand Down Expand Up @@ -198,6 +207,62 @@ def remember_saveable(initial: bool | int | float | str) -> SaveableState:
return SaveableState(wrapper(initial))


_CONCURRENT_PACKAGE = "pythonx.concurrent"


class _ConcurrentScope(Protocol):
"""What `DefaultCoroutineScope()` and `MainCoroutineScope()` return (`pythonx.concurrent.CoroutineScope`)."""

dispatcher: object

def launch(self, fn: Callable[..., object], /, *args: object, name: str | None = None) -> object: ...
def defer(self, fn: Callable[..., object], /, *args: object, name: str | None = None) -> object: ...
def cancel(self) -> None: ...


class _ConcurrentDispatchers(Protocol):
Default: object
Main: object


class _ConcurrentModule(Protocol):
Dispatchers: _ConcurrentDispatchers

def CoroutineScope(self, dispatcher: object, /) -> _ConcurrentScope: ...


def _concurrent_scope(dispatcher: object) -> _ConcurrentScope:
try:
mod = cast(_ConcurrentModule, importlib.import_module(_CONCURRENT_PACKAGE))
except ImportError as missing:
raise RuntimeError(
f"{_CONCURRENT_PACKAGE} is required for DefaultCoroutineScope and MainCoroutineScope: "
"install pythonx-concurrent (Python 3.15+)"
) from missing
return mod.CoroutineScope(dispatcher)


def _concurrent_dispatchers() -> _ConcurrentDispatchers:
try:
mod = cast(_ConcurrentModule, importlib.import_module(_CONCURRENT_PACKAGE))
return mod.Dispatchers
except (ImportError, AttributeError) as missing:
raise RuntimeError(
f"{_CONCURRENT_PACKAGE} is required for DefaultCoroutineScope and MainCoroutineScope: "
"install pythonx-concurrent (Python 3.15+)"
) from missing


def DefaultCoroutineScope() -> object:
"""Create an app-level `CoroutineScope(Dispatchers.Default)` backed by `pythonx.concurrent` (SPEC S5.7)."""
return _concurrent_scope(_concurrent_dispatchers().Default)


def MainCoroutineScope() -> object:
"""Create an app-level `CoroutineScope(Dispatchers.Main)` backed by `pythonx.concurrent` (SPEC S5.7)."""
return _concurrent_scope(_concurrent_dispatchers().Main)


from pythonx.compose._reexport import reexport

_reexported_getattr, _reexported_dir = reexport(__name__)
Expand Down
6 changes: 6 additions & 0 deletions pythonx/compose/runtime/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,9 @@ class SaveableState:

def remember_saveable(initial: bool | int | float | str) -> SaveableState:
...

def DefaultCoroutineScope() -> object:
...

def MainCoroutineScope() -> object:
...
104 changes: 104 additions & 0 deletions tests/test_coroutine_scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""`DefaultCoroutineScope` and `MainCoroutineScope` in `pythonx.compose.runtime` (issue #94, SPEC S5.7).

INTENT section 5.10: `DefaultCoroutineScope()` is `CoroutineScope(Dispatchers.Default)` and
`MainCoroutineScope()` is `CoroutineScope(Dispatchers.Main)`, built on `pythonx.concurrent`.
`UI.ipynb` cell 5 imports both from `pythonx.compose.runtime`.
"""

from __future__ import annotations

import asyncio
import importlib.util
import sys
import threading
import unittest
from pathlib import Path
from unittest import mock

REPO = Path(__file__).resolve().parents[1]
CONCURRENT_REPO = REPO.parents[2] / "pythonx-concurrent"
if not CONCURRENT_REPO.is_dir():
CONCURRENT_REPO = REPO.parent / "pythonx-concurrent"

if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))


def _ensure_concurrent_on_path() -> bool:
if importlib.util.find_spec("pythonx.concurrent") is not None:
return True
if CONCURRENT_REPO.is_dir():
import pythonx

concurrent_pkg = CONCURRENT_REPO / "pythonx"
if str(concurrent_pkg) not in pythonx.__path__:
pythonx.__path__.append(str(concurrent_pkg))
if str(CONCURRENT_REPO) not in sys.path:
sys.path.insert(0, str(CONCURRENT_REPO))
return importlib.util.find_spec("pythonx.concurrent") is not None
return False


class CoroutineScopesSurface(unittest.TestCase):
"""Both factories are exported from `pythonx.compose.runtime` and fail clearly if `pythonx-concurrent` is absent."""

def test_both_names_are_importable_from_runtime_and_listed_in_dir(self) -> None:
import pythonx.compose.runtime as runtime
from pythonx.compose.runtime import DefaultCoroutineScope, MainCoroutineScope

self.assertTrue(callable(DefaultCoroutineScope))
self.assertTrue(callable(MainCoroutineScope))
self.assertIn("DefaultCoroutineScope", dir(runtime))
self.assertIn("MainCoroutineScope", dir(runtime))

def test_missing_pythonx_concurrent_raises_runtime_error_naming_it(self) -> None:
import pythonx.compose.runtime as runtime

with mock.patch.dict(sys.modules, {"pythonx.concurrent": None}):
with self.assertRaises(RuntimeError) as ctx1:
runtime.DefaultCoroutineScope()
self.assertIn("pythonx-concurrent", str(ctx1.exception))
with self.assertRaises(RuntimeError) as ctx2:
runtime.MainCoroutineScope()
self.assertIn("pythonx-concurrent", str(ctx2.exception))


class CoroutineScopesWithConcurrent(unittest.IsolatedAsyncioTestCase):
"""Against `pythonx.concurrent`, `DefaultCoroutineScope()` and `MainCoroutineScope()` return `CoroutineScope`s."""

def setUp(self) -> None:
if sys.version_info < (3, 15):
self.skipTest("pythonx-concurrent requires Python 3.15 or later")
if not _ensure_concurrent_on_path():
self.skipTest("pythonx.concurrent is not installed or checked out beside pythonx-compose")

async def test_default_and_main_coroutine_scopes_launch_and_cancel(self) -> None:
import pythonx.concurrent as pc
from pythonx.compose.runtime import DefaultCoroutineScope, MainCoroutineScope

default_scope = DefaultCoroutineScope()
main_scope = MainCoroutineScope()
try:
self.assertIsInstance(default_scope, pc.CoroutineScope)
self.assertIsInstance(main_scope, pc.CoroutineScope)
self.assertIs(default_scope.dispatcher, pc.Dispatchers.Default)
self.assertIs(main_scope.dispatcher, pc.Dispatchers.Main)

ran = threading.Event()

async def worker(v: int) -> int:
await asyncio.sleep(0.005)
ran.set()
return v + 1

job = default_scope.launch(worker, 41)
self.assertTrue(ran.wait(timeout=2.0))
await default_scope.join()
self.assertTrue(job.done())
finally:
default_scope.cancel()
main_scope.cancel()


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion tests/test_runtime_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def setUp(self):

def test_no_chaquopy_lookup_survives(self):
for banned in ("jclass", "ComposeApp", "KotlinComposable", "KotlinWidget",
"CoroutineScope", "ComposableLambdaImpl", "composableWrapper"):
"ComposableLambdaImpl", "composableWrapper"):
self.assertNotIn(banned, self.source, f"{banned} is 2024 chaquopy-era code")

def test_no_content_slot_is_detected_by_inspecting_code_objects(self):
Expand Down
14 changes: 14 additions & 0 deletions tests/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,20 @@ def check(scope: BoxWithConstraintsScope, motion: MotionScheme) -> int:
return scope.constraints.max_width + c.max_width
""")

def test_default_and_main_coroutine_scopes_type_check(self):
"""Issue #94: `DefaultCoroutineScope` and `MainCoroutineScope` import from `pythonx.compose.runtime`."""
self.assertPasses("""
from pythonx.compose.runtime import DefaultCoroutineScope, MainCoroutineScope

s1: object = DefaultCoroutineScope()
s2: object = MainCoroutineScope()
""")
self.assertFails("""
from pythonx.compose.runtime import DefaultCoroutineScope

DefaultCoroutineScope(1)
""", "Too many arguments")


if __name__ == "__main__":
unittest.main()