Skip to content

Commit 6cd6f7e

Browse files
Korijnclaude
andauthored
Bump minimum Python to 3.13 and modernize (#198)
Raise the minimum supported Python version to 3.13 and apply the modernizations that unlocks: - pyproject.toml: requires-python >=3.13; ty environment python-version 3.13 - CI: drop the 3.9-3.12 test matrix rows, keep 3.13 and 3.14 - README/docs: update the advertised minimum version Source: - Adopt PEP 695 type-parameter syntax for the generic Proxy/Watcher classes, the computed/watch helpers and the Watchable/WatchCallback aliases, dropping the module-level TypeVar and typing.Generic bases - init.py: drop the getattr guard around asyncio.eager_task_factory, which now always exists - watcher.py: import TypeIs from typing (added in 3.13) instead of typing_extensions Tests: - Remove sys.version_info / hasattr(asyncio, "eager_task_factory") guards that are now always true - Exclude __type_params__ (added by the native PEP 695 generic) from the wrapping-completeness check Claude-Session: https://claude.ai/code/session_01XGH6yjPx1mpY134qtk6rXa Co-authored-by: Claude <noreply@anthropic.com>
1 parent c2954e9 commit 6cd6f7e

12 files changed

Lines changed: 42 additions & 69 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,6 @@ jobs:
5656
fail-fast: false
5757
matrix:
5858
include:
59-
- name: Linux py39
60-
pyversion: "3.9"
61-
- name: Linux py310
62-
pyversion: "3.10"
63-
- name: Linux py311
64-
pyversion: "3.11"
65-
- name: Linux py312
66-
pyversion: "3.12"
6759
- name: Linux py313
6860
pyversion: "3.13"
6961
- name: Linux py314

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
* **Automatic dependency tracking** — computed state knows exactly what it depends on and lazily re-evaluates only when needed.
1414
* **React to any change** — watch plain state or computed state and build unidirectional data flow: state changes drive view changes, input events drive state changes.
15-
* **Zero dependencies, framework agnostic** — a pure Python library (≥ 3.9) that plugs into any event loop: asyncio, Qt, or your own.
15+
* **Zero dependencies, framework agnostic** — a pure Python library (≥ 3.13) that plugs into any event loop: asyncio, Qt, or your own.
1616
* **Fully typed** — a [PEP 561](https://peps.python.org/pep-0561/) typed package, checked with [ty](https://github.com/astral-sh/ty) in CI.
1717

1818
## Quick start

docs/getting-started/installation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Installation
22

3-
Observ is published on [PyPI](https://pypi.org/project/observ/) and has **no dependencies**. It supports Python 3.9 and up.
3+
Observ is published on [PyPI](https://pypi.org/project/observ/) and has **no dependencies**. It supports Python 3.13 and up.
44

55
=== "uv"
66

docs/guide/scheduling.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ You can also pass a specific loop: `scheduler.register_asyncio(loop)`.
2121

2222
!!! tip "Eager task factory"
2323

24-
On Python 3.12+, observ's `loop_factory()` helper creates a new event loop with the [eager task factory](https://docs.python.org/3/library/asyncio-task.html#asyncio.eager_task_factory) enabled, which reduces the latency of the async callbacks and watched functions that observ schedules as tasks:
24+
observ's `loop_factory()` helper creates a new event loop with the [eager task factory](https://docs.python.org/3/library/asyncio-task.html#asyncio.eager_task_factory) enabled, which reduces the latency of the async callbacks and watched functions that observ schedules as tasks:
2525

2626
```python
2727
import asyncio

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Observ 👁
22

3-
Observ is a Python port of [Vue.js](https://vuejs.org/)' [computed properties and watchers](https://vuejs.org/guide/essentials/reactivity-fundamentals.html). It is event loop/framework agnostic and has no dependencies, so it can be used in any project targeting Python >= 3.9.
3+
Observ is a Python port of [Vue.js](https://vuejs.org/)' [computed properties and watchers](https://vuejs.org/guide/essentials/reactivity-fundamentals.html). It is event loop/framework agnostic and has no dependencies, so it can be used in any project targeting Python >= 3.13.
44

55
Observ provides two benefits for stateful applications:
66

observ/init.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,9 @@ def init(
3737
def loop_factory() -> asyncio.AbstractEventLoop:
3838
"""
3939
Creates a new asyncio event loop with the eager task factory
40-
enabled (Python 3.12+), which reduces the latency of the tasks
41-
that observ schedules for async functions and callbacks.
40+
enabled, which reduces the latency of the tasks that observ
41+
schedules for async functions and callbacks.
4242
"""
4343
loop = asyncio.new_event_loop()
44-
eager_task_factory = getattr(asyncio, "eager_task_factory", None)
45-
if eager_task_factory is not None:
46-
loop.set_task_factory(eager_task_factory)
44+
loop.set_task_factory(asyncio.eager_task_factory)
4745
return loop

observ/proxy.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from copy import copy, deepcopy
4-
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
4+
from typing import TYPE_CHECKING, Any, cast
55

66
from .proxy_db import proxy_db
77

@@ -10,10 +10,8 @@
1010

1111
from .proxy_db import TargetDep
1212

13-
T = TypeVar("T")
1413

15-
16-
class Proxy(Generic[T]):
14+
class Proxy[T]:
1715
"""
1816
Proxy for an object/target.
1917
@@ -64,7 +62,7 @@ def __deepcopy__(self, memo: dict[int, Any]) -> T:
6462
PLAIN_TYPES: frozenset[type] = frozenset({type(None), bool, int, float, str, bytes})
6563

6664

67-
def proxy(target: T, readonly: bool = False, shallow: bool = False) -> T:
65+
def proxy[T](target: T, readonly: bool = False, shallow: bool = False) -> T:
6866
"""
6967
Returns a Proxy for the given object. If a proxy for the given
7068
configuration already exists, it will return that instead of
@@ -123,14 +121,13 @@ def proxy(target: T, readonly: bool = False, shallow: bool = False) -> T:
123121

124122

125123
if TYPE_CHECKING:
126-
# Only used for typing: at runtime a Ref is a plain (proxied) dict.
127-
# Defined here (instead of unconditionally) because a generic
128-
# TypedDict requires Python 3.11
129-
class Ref(TypedDict, Generic[T]):
124+
# Only used for typing: at runtime a Ref is a plain (proxied) dict,
125+
# so it is kept behind TYPE_CHECKING and never constructed.
126+
class Ref[T](TypedDict):
130127
value: T
131128

132129

133-
def ref(target: T) -> Ref[T]:
130+
def ref[T](target: T) -> Ref[T]:
134131
"""
135132
Returns a reactive dict with a single 'value' key, set to the
136133
given target. Useful for making a single (plain) value reactive.
@@ -141,30 +138,30 @@ def ref(target: T) -> Ref[T]:
141138
reactive = proxy
142139

143140

144-
def readonly(target: T) -> T:
141+
def readonly[T](target: T) -> T:
145142
"""
146143
Returns a readonly proxy for the given target: reads are tracked,
147144
but any write raises a ReadonlyError.
148145
"""
149146
return proxy(target, readonly=True)
150147

151148

152-
def shallow_reactive(target: T) -> T:
149+
def shallow_reactive[T](target: T) -> T:
153150
"""
154151
Returns a shallow proxy for the given target: only the first level
155152
of the target is made reactive, nested values are returned raw.
156153
"""
157154
return proxy(target, shallow=True)
158155

159156

160-
def shallow_readonly(target: T) -> T:
157+
def shallow_readonly[T](target: T) -> T:
161158
"""
162159
Combination of `shallow_reactive` and `readonly`.
163160
"""
164161
return proxy(target, readonly=True, shallow=True)
165162

166163

167-
def trigger_ref(target: Proxy[T] | T) -> None:
164+
def trigger_ref[T](target: Proxy[T] | T) -> None:
168165
"""
169166
Force-notify the watchers that depend on the given proxy, as if
170167
its first level was written to. This is typically used together
@@ -192,7 +189,7 @@ def trigger_ref(target: Proxy[T] | T) -> None:
192189
dep.notify()
193190

194191

195-
def to_raw(target: Proxy[T] | T) -> T:
192+
def to_raw[T](target: Proxy[T] | T) -> T:
196193
"""
197194
Returns a raw object from which any trace of proxy has been replaced
198195
with its wrapped target value.
@@ -201,7 +198,7 @@ def to_raw(target: Proxy[T] | T) -> T:
201198
# that rebuilding a container from its (recursively unproxied)
202199
# items yields a value of the same type as the original target
203200
if isinstance(target, Proxy):
204-
return to_raw(target.__target__)
201+
return cast(T, to_raw(target.__target__))
205202

206203
if isinstance(target, list):
207204
return cast(T, [to_raw(t) for t in target])

observ/watcher.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,30 @@
1212
from collections.abc import Container
1313
from functools import wraps
1414
from itertools import count
15-
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload
15+
from typing import TYPE_CHECKING, Any, cast, overload
1616
from weakref import ref
1717

1818
from .dep import Dep
1919
from .proxy import PLAIN_TYPES, Proxy, proxy
2020
from .proxy_db import proxy_db
2121
from .scheduler import scheduler
2222

23-
T = TypeVar("T")
24-
2523
if TYPE_CHECKING:
2624
from collections.abc import Awaitable, Callable
2725
from types import MethodType
28-
from typing import ClassVar, Protocol
29-
30-
from typing_extensions import TypeIs
26+
from typing import ClassVar, Protocol, TypeIs
3127

3228
# Something that can be watched: a function (which doesn't have to
3329
# return anything) or a coroutine function, or a proxy (or other
3430
# container of proxies), which implies deep watching
35-
Watchable = Callable[[], T] | Callable[[], Awaitable[T]] | T
31+
type Watchable[T] = Callable[[], T] | Callable[[], Awaitable[T]] | T
3632
# Callbacks may accept zero, one (new value) or two
3733
# (new and old value) arguments
38-
WatchCallback = Callable[[], Any] | Callable[[T], Any] | Callable[[T, T], Any]
34+
type WatchCallback[T] = (
35+
Callable[[], Any] | Callable[[T], Any] | Callable[[T, T], Any]
36+
)
3937

40-
class Computed(Protocol[T]):
38+
class Computed[T](Protocol):
4139
"""
4240
The cached getter returned by `computed`.
4341
"""
@@ -47,7 +45,7 @@ class Computed(Protocol[T]):
4745
def __call__(self) -> T: ...
4846

4947

50-
def watch(
48+
def watch[T](
5149
fn: Watchable[T],
5250
callback: WatchCallback[T] | None = None,
5351
sync: bool = False,
@@ -81,7 +79,7 @@ def watch(
8179
return watcher
8280

8381

84-
def watch_effect(
82+
def watch_effect[T](
8583
fn: Watchable[T],
8684
sync: bool = False,
8785
deep: bool = True,
@@ -95,14 +93,14 @@ def watch_effect(
9593

9694

9795
@overload
98-
def computed(_fn: Callable[[], T]) -> Computed[T]: ...
96+
def computed[T](_fn: Callable[[], T]) -> Computed[T]: ...
9997

10098

10199
@overload
102-
def computed(*, deep: bool = True) -> Callable[[Callable[[], T]], Computed[T]]: ...
100+
def computed[T](*, deep: bool = True) -> Callable[[Callable[[], T]], Computed[T]]: ...
103101

104102

105-
def computed(
103+
def computed[T](
106104
_fn: Callable[[], T] | None = None, *, deep: bool = True
107105
) -> Computed[T] | Callable[[Callable[[], T]], Computed[T]]:
108106
"""
@@ -245,7 +243,7 @@ class WrongNumberOfArgumentsError(TypeError):
245243
pass
246244

247245

248-
class Watcher(Generic[T]):
246+
class Watcher[T]:
249247
__slots__ = (
250248
"__weakref__",
251249
"_active",

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ authors = [
66
{ name = "Korijn van Golen", email = "korijn@gmail.com" },
77
{ name = "Berend Klein Haneveld", email = "berendkleinhaneveld@gmail.com" },
88
]
9-
requires-python = ">=3.9"
9+
requires-python = ">=3.13"
1010
readme = "README.md"
1111
license = "MIT"
1212
classifiers = ["Typing :: Typed"]
@@ -73,7 +73,7 @@ include = ["observ"]
7373
[tool.ty.environment]
7474
# Match the oldest supported Python version (requires-python), so
7575
# that the type checker catches use of newer typing features
76-
python-version = "3.9"
76+
python-version = "3.13"
7777

7878
[tool.pytest.ini_options]
7979
# GC pauses during timed rounds are a major source of benchmark noise

tests/test_asyncio.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@ def plain_loop():
2222

2323

2424
def create_eager_loop():
25-
# only python>=3.12
26-
if not hasattr(asyncio, "eager_task_factory"):
27-
pytest.skip()
28-
2925
loop = create_plain_loop()
3026
loop.set_task_factory(asyncio.eager_task_factory)
3127
return loop

0 commit comments

Comments
 (0)