python-libei 0.5.1__tar.gz → 0.6.0__tar.gz

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.
Files changed (35) hide show
  1. {python_libei-0.5.1 → python_libei-0.6.0}/PKG-INFO +33 -36
  2. {python_libei-0.5.1 → python_libei-0.6.0}/README.md +32 -35
  3. {python_libei-0.5.1 → python_libei-0.6.0}/pyproject.toml +41 -2
  4. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/__init__.py +1 -1
  5. python_libei-0.6.0/src/libei/_capi/__init__.py +9 -0
  6. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/_capi/loader.py +7 -0
  7. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/_cobject.py +6 -0
  8. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/ei.py +90 -15
  9. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/eis.py +82 -17
  10. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/oeffis.py +19 -3
  11. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/portal.py +127 -33
  12. {python_libei-0.5.1 → python_libei-0.6.0}/src/python_libei.egg-info/PKG-INFO +33 -36
  13. python_libei-0.6.0/tests/test_documentation_shape.py +478 -0
  14. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_documented_examples.py +0 -27
  15. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_ei_objects.py +2 -3
  16. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_eis_objects.py +2 -3
  17. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_inputcapture.py +135 -12
  18. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_integration_extras.py +4 -4
  19. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_oeffis.py +3 -3
  20. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_portal.py +64 -7
  21. python_libei-0.5.1/src/libei/_capi/__init__.py +0 -6
  22. python_libei-0.5.1/tests/test_documentation_shape.py +0 -270
  23. {python_libei-0.5.1 → python_libei-0.6.0}/LICENSE +0 -0
  24. {python_libei-0.5.1 → python_libei-0.6.0}/setup.cfg +0 -0
  25. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/_capi/libei.py +0 -0
  26. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/_capi/libeis.py +0 -0
  27. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/_capi/liboeffis.py +0 -0
  28. {python_libei-0.5.1 → python_libei-0.6.0}/src/libei/py.typed +0 -0
  29. {python_libei-0.5.1 → python_libei-0.6.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
  30. {python_libei-0.5.1 → python_libei-0.6.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
  31. {python_libei-0.5.1 → python_libei-0.6.0}/src/python_libei.egg-info/requires.txt +0 -0
  32. {python_libei-0.5.1 → python_libei-0.6.0}/src/python_libei.egg-info/top_level.txt +0 -0
  33. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_cobject.py +0 -0
  34. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_integration_socketpair.py +0 -0
  35. {python_libei-0.5.1 → python_libei-0.6.0}/tests/test_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis
5
5
  Author: Dennis K. Paulsen
6
6
  License-Expression: MIT
@@ -37,6 +37,10 @@ Dynamic: license-file
37
37
 
38
38
  # python-libei
39
39
 
40
+ [![CI](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
41
+ [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
42
+ [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
43
+
40
44
  Python bindings for [libei, libeis and liboeffis](https://libinput.pages.freedesktop.org/libei/) —
41
45
  the Wayland input-emulation libraries. Use this to **move the pointer, click,
42
46
  type, or scroll on a Wayland desktop** from Python, the way `xdotool` did on
@@ -103,11 +107,15 @@ to get permission, then `ei` to inject.
103
107
  | **Get permission**, and not be asked again | `libei.portal` | Same handshake over D-Bus directly, with `persist_mode` / `restore_token`. Needs PyGObject |
104
108
  | **Be the server**, for tests or a compositor | `libei.eis` | Drives your client code with no real compositor and no consent dialog |
105
109
 
106
- Each module has `is_available()`, an `Error` exception, and an `EventType` /
107
- `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
108
- `Device`, `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event`, and the frozen
109
- dataclasses its accessors return. The package ships `py.typed`, so callers
110
- type-check against real annotations rather than `Any`.
110
+ Each module has `is_available()`. `ei` and `eis` share the shapes around them:
111
+ an `Error` exception, an `EventType` and `DeviceCapability` enum, and `Device`,
112
+ `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event` with the frozen dataclasses
113
+ its accessors return. `oeffis` and `portal` are smaller — `DeviceType`, no
114
+ `EventType` or `DeviceCapability`, `Activation` as the one frozen result a
115
+ portal wait hands back, and their own exception classes rather than an `Error`.
116
+ [docs/troubleshooting.md](docs/troubleshooting.md) names every class they raise.
117
+ The package ships `py.typed`, so callers type-check against real
118
+ annotations rather than `Any`.
111
119
 
112
120
  ## What's implemented
113
121
 
@@ -149,14 +157,16 @@ Beyond sending input, the wrapper also covers ping/pong round trips
149
157
  (`Device.keymap`), region mapping ids and coordinate conversion,
150
158
  `Context.disconnect()`, `Context.peek_event_type()`, and
151
159
  `Seat.request_device()`. On the server side, `libei.eis` mirrors all of it and
152
- adds `Eis.set_flag()` and `Client.pid`. Underneath, the ctypes layer binds 250
160
+ adds `Eis.set_flag()` (with the `Flag` values it takes), `Client.pid`, and
161
+ `Device.configure()` with the `ConfigureRegion` descriptions it accepts.
162
+ Underneath, the ctypes layer binds 250
153
163
  of the 302 functions the three libraries export as of 1.6.0; what is left out,
154
164
  and why, is in
155
165
  [docs/developers/architecture.md](docs/developers/architecture.md#what-is-bound-and-what-is-deliberately-not).
156
166
 
157
167
  ## Status
158
168
 
159
- Beta (`0.5.1`), published on [PyPI](https://pypi.org/project/python-libei/)
169
+ Beta (`0.6.0`), published on [PyPI](https://pypi.org/project/python-libei/)
160
170
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
161
171
 
162
172
  The injection path is exercised end to end against the real libraries by the
@@ -187,11 +197,11 @@ Exactly what was run, when, and against which versions:
187
197
  portal paths are the part likeliest to come up short off Linux, since
188
198
  they need an xdg-desktop-portal RemoteDesktop backend to talk to.
189
199
  - CPython 3.10 or newer (tested on 3.13)
190
- - The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`;
191
- on FreeBSD, `pkg install libei` (the `x11/libei` port), which supplies all
192
- three sonames including `liboeffis`
193
- - `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
194
- whatever GObject-introspection libraries your distro needs for `Gio` --
200
+ - The native `libei`, `libeis` and `liboeffis` libraries, which `pip` cannot
201
+ supply. Which package provides them on your distribution — and what to do
202
+ when the name does not match — is in [docs/install.md](docs/install.md).
203
+ - `libei.portal` only: PyGObject, via the `portal` extra, plus whatever
204
+ GObject-introspection libraries your distribution needs for `Gio`, since
195
205
  PyPI's PyGObject wheel supplies the Python side only. Not needed for
196
206
  `libei.ei`, `libei.eis` or `libei.oeffis`.
197
207
  - libei 1.0.0 or newer for the core: connecting, binding a seat, and
@@ -224,22 +234,11 @@ Exactly what was run, when, and against which versions:
224
234
 
225
235
  ## Install
226
236
 
227
- From [PyPI](https://pypi.org/project/python-libei/):
228
-
229
237
  ```sh
230
238
  pip install python-libei
231
239
  ```
232
240
 
233
- The distribution is named `python-libei`, the import is `libei` -- so
234
- `pip show python-libei`, but `from libei import ei`.
235
-
236
- Pure Python, no build step: the wheel is `py3-none-any` and ctypes talks to
237
- the native libraries directly, so there is no compiler, no headers and no
238
- `libei-devel` involved at install time. What `pip` does *not* bring is the
239
- native libraries themselves -- see [Requirements](#requirements) above; on
240
- Fedora, `sudo dnf install libei libeis liboeffis`.
241
-
242
- To track `main` instead, or to hack on it, install from a checkout:
241
+ From a checkout instead, to track `main` or to work on the package:
243
242
 
244
243
  ```sh
245
244
  git clone https://github.com/ctrondlp/python-libei.git
@@ -247,15 +246,11 @@ cd python-libei
247
246
  pip install . # or `pip install -e '.[dev]'` to develop
248
247
  ```
249
248
 
250
- Importing is always safe, even where the native libraries are missing — they
251
- are loaded on first use, not at import. Check before you rely on them:
252
-
253
- ```python
254
- from libei import ei
255
-
256
- if not ei.is_available():
257
- ... # fall back to another input backend
258
- ```
249
+ That is the half `pip` can do. The native `libei`/`libeis`/`liboeffis`
250
+ libraries it cannot supply, the `portal` extra, and how to check what actually
251
+ loaded are all in [docs/install.md](docs/install.md) — see
252
+ [Requirements](#requirements) above for the version floors. Importing is safe
253
+ without any of it: those libraries are loaded on first *use*, not at import.
259
254
 
260
255
  ## Concepts
261
256
 
@@ -409,8 +404,10 @@ a list of error messages. Start there when nothing happens.
409
404
  happens" checklist
410
405
  - [docs/vs-snegg.md](docs/vs-snegg.md) — how this differs from the reference
411
406
  bindings, and two signature issues found by cross-checking the C source
412
- - [docs/developers/](docs/developers/) — the four-layer architecture, and what
413
- has actually been verified against which libei versions
407
+ - [docs/developers/architecture.md](docs/developers/architecture.md) and
408
+ [docs/developers/verification.md](docs/developers/verification.md) — the
409
+ four-layer architecture, and what has actually been verified against which
410
+ libei versions
414
411
  - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, checks, testing against an old
415
412
  libei, releasing
416
413
 
@@ -1,5 +1,9 @@
1
1
  # python-libei
2
2
 
3
+ [![CI](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
5
+ [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
6
+
3
7
  Python bindings for [libei, libeis and liboeffis](https://libinput.pages.freedesktop.org/libei/) —
4
8
  the Wayland input-emulation libraries. Use this to **move the pointer, click,
5
9
  type, or scroll on a Wayland desktop** from Python, the way `xdotool` did on
@@ -66,11 +70,15 @@ to get permission, then `ei` to inject.
66
70
  | **Get permission**, and not be asked again | `libei.portal` | Same handshake over D-Bus directly, with `persist_mode` / `restore_token`. Needs PyGObject |
67
71
  | **Be the server**, for tests or a compositor | `libei.eis` | Drives your client code with no real compositor and no consent dialog |
68
72
 
69
- Each module has `is_available()`, an `Error` exception, and an `EventType` /
70
- `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
71
- `Device`, `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event`, and the frozen
72
- dataclasses its accessors return. The package ships `py.typed`, so callers
73
- type-check against real annotations rather than `Any`.
73
+ Each module has `is_available()`. `ei` and `eis` share the shapes around them:
74
+ an `Error` exception, an `EventType` and `DeviceCapability` enum, and `Device`,
75
+ `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event` with the frozen dataclasses
76
+ its accessors return. `oeffis` and `portal` are smaller — `DeviceType`, no
77
+ `EventType` or `DeviceCapability`, `Activation` as the one frozen result a
78
+ portal wait hands back, and their own exception classes rather than an `Error`.
79
+ [docs/troubleshooting.md](docs/troubleshooting.md) names every class they raise.
80
+ The package ships `py.typed`, so callers type-check against real
81
+ annotations rather than `Any`.
74
82
 
75
83
  ## What's implemented
76
84
 
@@ -112,14 +120,16 @@ Beyond sending input, the wrapper also covers ping/pong round trips
112
120
  (`Device.keymap`), region mapping ids and coordinate conversion,
113
121
  `Context.disconnect()`, `Context.peek_event_type()`, and
114
122
  `Seat.request_device()`. On the server side, `libei.eis` mirrors all of it and
115
- adds `Eis.set_flag()` and `Client.pid`. Underneath, the ctypes layer binds 250
123
+ adds `Eis.set_flag()` (with the `Flag` values it takes), `Client.pid`, and
124
+ `Device.configure()` with the `ConfigureRegion` descriptions it accepts.
125
+ Underneath, the ctypes layer binds 250
116
126
  of the 302 functions the three libraries export as of 1.6.0; what is left out,
117
127
  and why, is in
118
128
  [docs/developers/architecture.md](docs/developers/architecture.md#what-is-bound-and-what-is-deliberately-not).
119
129
 
120
130
  ## Status
121
131
 
122
- Beta (`0.5.1`), published on [PyPI](https://pypi.org/project/python-libei/)
132
+ Beta (`0.6.0`), published on [PyPI](https://pypi.org/project/python-libei/)
123
133
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
124
134
 
125
135
  The injection path is exercised end to end against the real libraries by the
@@ -150,11 +160,11 @@ Exactly what was run, when, and against which versions:
150
160
  portal paths are the part likeliest to come up short off Linux, since
151
161
  they need an xdg-desktop-portal RemoteDesktop backend to talk to.
152
162
  - CPython 3.10 or newer (tested on 3.13)
153
- - The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`;
154
- on FreeBSD, `pkg install libei` (the `x11/libei` port), which supplies all
155
- three sonames including `liboeffis`
156
- - `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
157
- whatever GObject-introspection libraries your distro needs for `Gio` --
163
+ - The native `libei`, `libeis` and `liboeffis` libraries, which `pip` cannot
164
+ supply. Which package provides them on your distribution — and what to do
165
+ when the name does not match — is in [docs/install.md](docs/install.md).
166
+ - `libei.portal` only: PyGObject, via the `portal` extra, plus whatever
167
+ GObject-introspection libraries your distribution needs for `Gio`, since
158
168
  PyPI's PyGObject wheel supplies the Python side only. Not needed for
159
169
  `libei.ei`, `libei.eis` or `libei.oeffis`.
160
170
  - libei 1.0.0 or newer for the core: connecting, binding a seat, and
@@ -187,22 +197,11 @@ Exactly what was run, when, and against which versions:
187
197
 
188
198
  ## Install
189
199
 
190
- From [PyPI](https://pypi.org/project/python-libei/):
191
-
192
200
  ```sh
193
201
  pip install python-libei
194
202
  ```
195
203
 
196
- The distribution is named `python-libei`, the import is `libei` -- so
197
- `pip show python-libei`, but `from libei import ei`.
198
-
199
- Pure Python, no build step: the wheel is `py3-none-any` and ctypes talks to
200
- the native libraries directly, so there is no compiler, no headers and no
201
- `libei-devel` involved at install time. What `pip` does *not* bring is the
202
- native libraries themselves -- see [Requirements](#requirements) above; on
203
- Fedora, `sudo dnf install libei libeis liboeffis`.
204
-
205
- To track `main` instead, or to hack on it, install from a checkout:
204
+ From a checkout instead, to track `main` or to work on the package:
206
205
 
207
206
  ```sh
208
207
  git clone https://github.com/ctrondlp/python-libei.git
@@ -210,15 +209,11 @@ cd python-libei
210
209
  pip install . # or `pip install -e '.[dev]'` to develop
211
210
  ```
212
211
 
213
- Importing is always safe, even where the native libraries are missing — they
214
- are loaded on first use, not at import. Check before you rely on them:
215
-
216
- ```python
217
- from libei import ei
218
-
219
- if not ei.is_available():
220
- ... # fall back to another input backend
221
- ```
212
+ That is the half `pip` can do. The native `libei`/`libeis`/`liboeffis`
213
+ libraries it cannot supply, the `portal` extra, and how to check what actually
214
+ loaded are all in [docs/install.md](docs/install.md) — see
215
+ [Requirements](#requirements) above for the version floors. Importing is safe
216
+ without any of it: those libraries are loaded on first *use*, not at import.
222
217
 
223
218
  ## Concepts
224
219
 
@@ -372,8 +367,10 @@ a list of error messages. Start there when nothing happens.
372
367
  happens" checklist
373
368
  - [docs/vs-snegg.md](docs/vs-snegg.md) — how this differs from the reference
374
369
  bindings, and two signature issues found by cross-checking the C source
375
- - [docs/developers/](docs/developers/) — the four-layer architecture, and what
376
- has actually been verified against which libei versions
370
+ - [docs/developers/architecture.md](docs/developers/architecture.md) and
371
+ [docs/developers/verification.md](docs/developers/verification.md) — the
372
+ four-layer architecture, and what has actually been verified against which
373
+ libei versions
377
374
  - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, checks, testing against an old
378
375
  libei, releasing
379
376
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-libei"
7
- version = "0.5.1"
7
+ version = "0.6.0"
8
8
  description = "Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -73,6 +73,14 @@ libei = ["py.typed"]
73
73
 
74
74
  [tool.pytest.ini_options]
75
75
  testpaths = ["tests"]
76
+ # The suite imports `libei` from the checkout rather than requiring an
77
+ # install, so `scripts/pre-commit-test.sh` runs on an interpreter with only
78
+ # the dev tools in it -- which is what that script's own header asks for, and
79
+ # what pyguitest and pyguitest-recorder already do. CI installs the package,
80
+ # so the installed path is still covered there. Without this, conftest's
81
+ # `from libei import ei` fails at collection and the whole check reports an
82
+ # ImportError instead of running anything.
83
+ pythonpath = ["src"]
76
84
  markers = [
77
85
  "integration: requires the real libei/libeis/liboeffis shared libraries to be installed",
78
86
  ]
@@ -87,8 +95,39 @@ select = [
87
95
  "E", "W", # pycodestyle
88
96
  "F", # pyflakes
89
97
  "I", # isort
90
- "UP", # pyupgrade
98
+ "D", # pydocstyle
91
99
  "B", # bugbear
100
+ "UP", # pyupgrade
101
+ "C4", # comprehensions
102
+ "SIM", # simplification
103
+ "RET", # return consistency
104
+ "ARG", # unused arguments
105
+ "C901", # cyclomatic complexity
106
+ ]
107
+ ignore = [
108
+ "D203", # incompatible with D211; keep no blank line before class docstring
109
+ "D213", # incompatible with D212; keep the summary on the first line
110
+ "D401", # properties read better as noun phrases than imperatives
111
+ "D105", # __enter__/__exit__ and friends document themselves
112
+ ]
113
+
114
+ [tool.ruff.lint.mccabe]
115
+ # pyguitest's ceiling; python-libei's own worst case (InputCaptureSession.
116
+ # negotiate(), forking on portal version) sits at 14, just under it. A
117
+ # ceiling on what gets added, not a claim everything else is close to it.
118
+ max-complexity = 15
119
+
120
+ [tool.ruff.lint.pydocstyle]
121
+ convention = "pep257"
122
+
123
+ [tool.ruff.lint.per-file-ignores]
124
+ # Tests use fakes with deliberately unused arguments and multi-with blocks
125
+ # that read better nested; matching pyguitest's/pyguitest-recorder's own
126
+ # per-file-ignores for the same reasons.
127
+ "tests/*" = [
128
+ "ARG001", "ARG002", "ARG003", "ARG005", # fakes accept arguments they ignore
129
+ "SIM117", # nested with-blocks read better in tests
130
+ "D100", "D101", "D102", "D103", "D104", "D107",
92
131
  ]
93
132
 
94
133
  [tool.mypy]
@@ -31,6 +31,6 @@ the full breakdown, including which features need which libei version.
31
31
  Beta: the API is not frozen.
32
32
  """
33
33
 
34
- __version__ = "0.5.1"
34
+ __version__ = "0.6.0"
35
35
 
36
36
  __all__ = ["__version__"]
@@ -0,0 +1,9 @@
1
+ """Low-level ctypes bindings.
2
+
3
+ Not part of the public API -- use ``libei.ei``, ``libei.eis`` and
4
+ ``libei.oeffis`` instead.
5
+ """
6
+
7
+ from . import libei, libeis, liboeffis
8
+
9
+ __all__ = ["libei", "libeis", "liboeffis"]
@@ -25,12 +25,18 @@ class LazyLibrary:
25
25
  """A ctypes.CDLL that only opens the library on first real use."""
26
26
 
27
27
  def __init__(self, soname: str) -> None:
28
+ """Remember the soname; nothing is opened until first use."""
28
29
  self._soname = soname
29
30
  self._lib: ctypes.CDLL | None = None
30
31
  self._load_error: OSError | None = None
31
32
  self._lock = threading.Lock()
32
33
 
33
34
  def _ensure_loaded(self) -> ctypes.CDLL:
35
+ """Return the opened library, opening it on first call.
36
+
37
+ Raises LibraryNotFoundError on every later call too after a failure:
38
+ the failed load is cached rather than retried.
39
+ """
34
40
  # Double-checked: the unlocked read is the fast path taken by every
35
41
  # call after the first, and the repeated check inside the lock is
36
42
  # what makes it safe -- two threads can both fall through the first
@@ -91,6 +97,7 @@ class LazyLibrary:
91
97
  cache: dict[str, Any] = {}
92
98
 
93
99
  def call(*args: Any) -> Any:
100
+ """Resolve the C function on first call, then pass straight through."""
94
101
  # Resolution happens here, on first call, not at bind time --
95
102
  # that is the whole point of this module (see its docstring).
96
103
  bound = cache.get("f")
@@ -66,6 +66,7 @@ class CObject:
66
66
  _instances_lock: ClassVar[threading.RLock]
67
67
 
68
68
  def __init_subclass__(cls, **kwargs: Any) -> None:
69
+ """Give each wrapper hierarchy one identity cache, shared by subclasses."""
69
70
  super().__init_subclass__(**kwargs)
70
71
  # One cache per wrapper *hierarchy*, not per class. Only a root
71
72
  # wrapper class -- one whose only CObject ancestor is CObject
@@ -98,6 +99,7 @@ class CObject:
98
99
  cls._instances_lock = threading.RLock()
99
100
 
100
101
  def __init__(self, pointer: int, *, _adopt: bool = False) -> None:
102
+ """Take a reference on a non-NULL pointer and cache this wrapper for it."""
101
103
  if not pointer:
102
104
  raise ValueError(f"{type(self).__name__} cannot wrap a NULL pointer")
103
105
  self._pointer = pointer
@@ -121,6 +123,7 @@ class CObject:
121
123
 
122
124
  @property
123
125
  def _as_parameter_(self) -> int:
126
+ """The raw pointer ctypes passes to C -- an error once it is released."""
124
127
  if self._pointer == 0:
125
128
  raise RuntimeError(
126
129
  f"{type(self).__name__} has already been released; "
@@ -159,6 +162,7 @@ class CObject:
159
162
 
160
163
  @classmethod
161
164
  def _get_or_create(cls: type[T], pointer: int | None, *, adopt: bool) -> T | None:
165
+ """The wrapper already caching this pointer, or a new one; None for NULL."""
162
166
  if not pointer:
163
167
  return None
164
168
  if not cls._wrappable:
@@ -232,6 +236,7 @@ class CObject:
232
236
  return cls._get_or_create(pointer, adopt=True)
233
237
 
234
238
  def __eq__(self, other: object) -> bool:
239
+ """Equal when the types match and the wrapped pointers are the same."""
235
240
  if not isinstance(other, CObject):
236
241
  return NotImplemented
237
242
  if type(self) is not type(other):
@@ -245,6 +250,7 @@ class CObject:
245
250
  return self._pointer == other._pointer
246
251
 
247
252
  def __hash__(self) -> int:
253
+ """Stable for the object's life: the original pointer, not the live one."""
248
254
  # Deliberately keyed on _hash_key, not _pointer: release() zeroes
249
255
  # _pointer, and an object whose hash changes mid-life vanishes
250
256
  # from any set or dict it was placed in. Two wrappers can share a