coolscanpy 0.1.0__py3-none-any.whl

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 (63) hide show
  1. coolscanpy/__init__.py +102 -0
  2. coolscanpy/_device.py +454 -0
  3. coolscanpy/_logging.py +27 -0
  4. coolscanpy/_roll.py +768 -0
  5. coolscanpy/capture/__init__.py +2 -0
  6. coolscanpy/capture/frame_registration.py +535 -0
  7. coolscanpy/capture/full_negative.py +206 -0
  8. coolscanpy/capture/full_negative_capture.py +219 -0
  9. coolscanpy/capture/full_negative_workflow.py +433 -0
  10. coolscanpy/capture/sane_rgb_geometry.py +196 -0
  11. coolscanpy/capture/single_pass_workflow.py +1288 -0
  12. coolscanpy/cli/__init__.py +1 -0
  13. coolscanpy/cli/practical_parity.py +723 -0
  14. coolscanpy/cli/roll_scan.py +745 -0
  15. coolscanpy/exceptions.py +114 -0
  16. coolscanpy/io/__init__.py +1 -0
  17. coolscanpy/io/encoders.py +341 -0
  18. coolscanpy/protocol/__init__.py +1 -0
  19. coolscanpy/protocol/ls5000_single_pass/__init__.py +104 -0
  20. coolscanpy/protocol/ls5000_single_pass/bundle.py +123 -0
  21. coolscanpy/protocol/ls5000_single_pass/capture_process.py +2287 -0
  22. coolscanpy/protocol/ls5000_single_pass/continuation_plan.py +356 -0
  23. coolscanpy/protocol/ls5000_single_pass/data/__init__.py +1 -0
  24. coolscanpy/protocol/ls5000_single_pass/data/replay-first-rgbi4-manifest.json +114 -0
  25. coolscanpy/protocol/ls5000_single_pass/data/replay-first-rgbi4-plan.jsonl +607 -0
  26. coolscanpy/protocol/ls5000_single_pass/data/replay-next-rgbi4-plan.json +128 -0
  27. coolscanpy/protocol/ls5000_single_pass/meter.py +543 -0
  28. coolscanpy/protocol/ls5000_single_pass/packed.py +210 -0
  29. coolscanpy/protocol/ls5000_single_pass/plan.py +77 -0
  30. coolscanpy/protocol/ls5000_single_pass/roll_index.py +1298 -0
  31. coolscanpy/protocol/ls5000_single_pass/window.py +96 -0
  32. coolscanpy/protocol/ls5000_single_pass/worker.py +4397 -0
  33. coolscanpy/py.typed +0 -0
  34. coolscanpy/receipts/__init__.py +2 -0
  35. coolscanpy/receipts/ice_bundle.py +103 -0
  36. coolscanpy/receipts/outputs.py +266 -0
  37. coolscanpy/receipts/provenance.py +938 -0
  38. coolscanpy/receipts/quality.py +614 -0
  39. coolscanpy/receipts/tiff_contract.py +25 -0
  40. coolscanpy/receipts/writer.py +303 -0
  41. coolscanpy/roll/__init__.py +2 -0
  42. coolscanpy/roll/controls.py +43 -0
  43. coolscanpy/roll/forward.py +385 -0
  44. coolscanpy/roll/preview_session.py +897 -0
  45. coolscanpy/roll/registration.py +344 -0
  46. coolscanpy/roll/service.py +1979 -0
  47. coolscanpy/session/__init__.py +2 -0
  48. coolscanpy/session/backend.py +60 -0
  49. coolscanpy/session/filenames.py +50 -0
  50. coolscanpy/session/params.py +104 -0
  51. coolscanpy/session/result.py +105 -0
  52. coolscanpy/session/service.py +75 -0
  53. coolscanpy/transport/__init__.py +21 -0
  54. coolscanpy/transport/libsane_dual_source.py +1492 -0
  55. coolscanpy/transport/sane.py +2029 -0
  56. coolscanpy/transport/split_alignment_policy.py +64 -0
  57. coolscanpy/types.py +260 -0
  58. coolscanpy-0.1.0.dist-info/METADATA +202 -0
  59. coolscanpy-0.1.0.dist-info/RECORD +63 -0
  60. coolscanpy-0.1.0.dist-info/WHEEL +5 -0
  61. coolscanpy-0.1.0.dist-info/entry_points.txt +3 -0
  62. coolscanpy-0.1.0.dist-info/licenses/LICENSE +674 -0
  63. coolscanpy-0.1.0.dist-info/top_level.txt +1 -0
coolscanpy/__init__.py ADDED
@@ -0,0 +1,102 @@
1
+ """coolscanpy -- direct-USB acquisition library for Nikon Coolscan film scanners.
2
+
3
+ A python-sane-style API (module-level ``get_devices()``/``open()``, a
4
+ ``Device`` with typed option attributes, ``dev.scan() -> ndarray``) plus a
5
+ roll-feeder extension (``Device.roll() -> Roll``: whole-roll preview,
6
+ spacing-offset registration, fingerprint-refusal safety, one-shot batch fine
7
+ scanning, receipts). See the package README for the hardware this has
8
+ actually been validated against and what remains untested.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ try:
14
+ from importlib.metadata import version as _pkg_version
15
+
16
+ __version__ = _pkg_version("coolscanpy")
17
+ except Exception: # pragma: no cover - only when the package isn't installed
18
+ __version__ = "0.1.0"
19
+
20
+ from coolscanpy._device import Device, get_devices, open
21
+ from coolscanpy._roll import Roll
22
+ from coolscanpy.exceptions import (
23
+ BatchIntegrityError,
24
+ DeviceBusy,
25
+ DeviceNotFound,
26
+ EjectFailed,
27
+ FeederParked,
28
+ FingerprintRefused,
29
+ GeometryValidationError,
30
+ ManualReviewRequired,
31
+ PyCoolscanError,
32
+ RollMismatch,
33
+ SafeStopRequested,
34
+ SplitAlignmentError,
35
+ TransportSmearDetected,
36
+ )
37
+ from coolscanpy.types import (
38
+ ApprovalReceipt,
39
+ ArtifactEvidence,
40
+ Capabilities,
41
+ ClippingTelemetry,
42
+ DeviceInfo,
43
+ ExposureVector,
44
+ FingerprintComparison,
45
+ FocusDetailTelemetry,
46
+ Frame,
47
+ Material,
48
+ Option,
49
+ OptionType,
50
+ OptionUnit,
51
+ Progress,
52
+ Receipt,
53
+ RollFingerprint,
54
+ SplitAlignment,
55
+ Thumbnail,
56
+ TransportSmearAssessment,
57
+ )
58
+
59
+ __all__ = [
60
+ "__version__",
61
+ # module-level (python-sane-shaped)
62
+ "get_devices",
63
+ "open",
64
+ # core objects
65
+ "Device",
66
+ "Option",
67
+ "OptionType",
68
+ "OptionUnit",
69
+ "Capabilities",
70
+ "DeviceInfo",
71
+ # roll extension
72
+ "Roll",
73
+ "Material",
74
+ "Thumbnail",
75
+ "Frame",
76
+ "RollFingerprint",
77
+ "FingerprintComparison",
78
+ "Progress",
79
+ # receipts
80
+ "Receipt",
81
+ "ExposureVector",
82
+ "SplitAlignment",
83
+ "ClippingTelemetry",
84
+ "FocusDetailTelemetry",
85
+ "TransportSmearAssessment",
86
+ "ArtifactEvidence",
87
+ "ApprovalReceipt",
88
+ # exceptions
89
+ "PyCoolscanError",
90
+ "DeviceNotFound",
91
+ "DeviceBusy",
92
+ "EjectFailed",
93
+ "SafeStopRequested",
94
+ "FeederParked",
95
+ "RollMismatch",
96
+ "FingerprintRefused",
97
+ "ManualReviewRequired",
98
+ "GeometryValidationError",
99
+ "TransportSmearDetected",
100
+ "SplitAlignmentError",
101
+ "BatchIntegrityError",
102
+ ]
coolscanpy/_device.py ADDED
@@ -0,0 +1,454 @@
1
+ """The python-sane-shaped plain surface: ``get_devices``/``open``/``Device``.
2
+
3
+ This module is thin by design: it adapts ``session.service.ScannerService``
4
+ (itself backed by ``transport.sane.SaneBackend``) to the fixed, typed option
5
+ attributes and constraint-introspection shape the public API contract
6
+ describes. It adds exactly two pieces of new behavior beyond adaptation and
7
+ argument validation, both explicitly called for by the API contract:
8
+
9
+ * an in-process registry so a second ``open()`` of an already-open device
10
+ raises :class:`~coolscanpy.exceptions.DeviceBusy` instead of silently
11
+ racing it (there is no cross-process reservation for the plain scan path);
12
+ * a non-blocking per-device lock so a concurrent ``scan()``/``roll()`` call
13
+ raises :class:`~coolscanpy.exceptions.DeviceBusy` rather than blocking.
14
+
15
+ ``coolscanpy.session.service.ScannerService`` and ``coolscanpy.transport.sane``
16
+ are imported lazily (inside functions, not at module level) so that plain
17
+ ``import coolscanpy`` stays cheap and does not pull in ``cv2``/``python-sane``
18
+ -- matching this package's existing convention that only the code paths
19
+ which actually need the SANE-backed transport pay for it (see the
20
+ ``[scanner]`` extra in ``pyproject.toml``).
21
+
22
+ Enumeration itself never requires python-sane to be installed: ``get_devices()``
23
+ tries the SANE route first, and only when python-sane is not importable falls
24
+ back to direct USB enumeration (``pyusb``, already a runtime dependency,
25
+ matching the LS-5000's fixed vendor/product id) -- see
26
+ ``_usb_fallback_device_infos``. That fallback device carries reduced,
27
+ conservative capabilities, since there is no SANE session to negotiate the
28
+ rest against. SANE-only operations (``Device.scan()``, ``Device.eject()``)
29
+ still raise ``ImportError`` when actually called on such a device -- only
30
+ enumeration and the roll-feeder extension, which never uses SANE, are
31
+ unaffected by its absence.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import threading
37
+ from typing import TYPE_CHECKING, Callable
38
+
39
+ from coolscanpy.exceptions import DeviceBusy, DeviceNotFound, EjectFailed
40
+ from coolscanpy.session.backend import ScannerCapabilities, ScannerDevice
41
+ from coolscanpy.session.params import ScanParams
42
+ from coolscanpy.types import Capabilities, DeviceInfo, Material, Option, OptionType, OptionUnit
43
+
44
+ if TYPE_CHECKING:
45
+ from coolscanpy._roll import Roll
46
+ from coolscanpy.session.service import ScannerService
47
+
48
+ _COOLSCAN3_PREFIX = "coolscan3:"
49
+
50
+ _OPTION_NAMES: tuple[str, ...] = ("resolution", "depth", "samples", "autofocus", "auto_exposure")
51
+
52
+ # The Nikon Coolscan LS-5000's fixed USB identity. The roll engine's capture
53
+ # subprocess (protocol.ls5000_single_pass.worker) locates the physical unit
54
+ # itself with this exact pair -- it never consumes a device id from this
55
+ # module -- so these are duplicated constants, not a shared import, matching
56
+ # that module's pinned/untouched status.
57
+ _LS5000_USB_VENDOR_ID = 0x04B0
58
+ _LS5000_USB_PRODUCT_ID = 0x4002
59
+ _USB_FALLBACK_ID_PREFIX = "usb"
60
+
61
+
62
+ def _default_service_factory() -> "ScannerService":
63
+ from coolscanpy.session.service import ScannerService
64
+
65
+ return ScannerService()
66
+
67
+
68
+ # Rebindable so tests can substitute a service that wraps a fake backend
69
+ # without touching hardware or python-sane. See tests/test_facade.py.
70
+ _service_factory: Callable[[], "ScannerService"] = _default_service_factory
71
+
72
+ _open_devices: set[str] = set()
73
+ _open_devices_lock = threading.Lock()
74
+
75
+
76
+ def _register_open_device(device_id: str) -> None:
77
+ with _open_devices_lock:
78
+ if device_id in _open_devices:
79
+ raise DeviceBusy(f"device {device_id!r} is already open in this process")
80
+ _open_devices.add(device_id)
81
+
82
+
83
+ def _unregister_open_device(device_id: str) -> None:
84
+ with _open_devices_lock:
85
+ _open_devices.discard(device_id)
86
+
87
+
88
+ def _is_coolscan_device_id(device_id: str) -> bool:
89
+ """True for a Coolscan LS-5000 SANE device id, direct or via saned.
90
+
91
+ ``get_devices()`` scope is one vendor: filter out every other SANE
92
+ backend's device (pieusb, generic flatbeds, etc.) so a caller never sees
93
+ a non-Coolscan unit, matching the module-level scope note. Imports
94
+ ``transport.sane`` lazily -- see the module docstring.
95
+ """
96
+
97
+ from coolscanpy.transport.sane import _strip_net_prefix
98
+
99
+ return _strip_net_prefix(device_id).startswith(_COOLSCAN3_PREFIX)
100
+
101
+
102
+ def _capabilities_from(caps: ScannerCapabilities) -> Capabilities:
103
+ return Capabilities(
104
+ ir_channel=caps.ir_channel,
105
+ supported_dpi=caps.supported_dpi,
106
+ supported_depths=caps.supported_depths,
107
+ multi_sample=caps.multi_sample,
108
+ adapter_frame_capacity=caps.adapter_frame_capacity,
109
+ adapter_frame_control=caps.adapter_frame_control,
110
+ auto_exposure=caps.auto_exposure,
111
+ registered_geometry=caps.registered_geometry,
112
+ can_eject=caps.can_eject,
113
+ )
114
+
115
+
116
+ def _device_info_from(device: ScannerDevice) -> DeviceInfo:
117
+ return DeviceInfo(
118
+ id=device.id,
119
+ vendor=device.vendor,
120
+ model=device.model,
121
+ capabilities=_capabilities_from(device.capabilities),
122
+ )
123
+
124
+
125
+ def _usb_fallback_capabilities() -> Capabilities:
126
+ """Conservative capabilities for a device found only via direct USB.
127
+
128
+ No SANE session exists to negotiate a real capability set, so this
129
+ reports only what holds for every LS-5000 regardless of software stack:
130
+ a fixed 4000 dpi / 16-bit native fine-scan (what the roll engine's
131
+ single-pass capture always requests, independent of ``Device.resolution``/
132
+ ``.depth``) and frame-position addressing, so ``Device.roll()``'s
133
+ capability gate passes. Everything SANE would otherwise negotiate
134
+ per-unit (hardware multi-sample, auto-exposure, registered-geometry
135
+ positioning, medium eject) is conservatively false/absent rather than
136
+ guessed -- those remain genuinely SANE-only.
137
+ """
138
+
139
+ return Capabilities(
140
+ ir_channel=True,
141
+ supported_dpi=(4_000,),
142
+ supported_depths=(16,),
143
+ multi_sample=False,
144
+ adapter_frame_capacity=None,
145
+ adapter_frame_control=True,
146
+ auto_exposure=False,
147
+ registered_geometry=False,
148
+ can_eject=False,
149
+ )
150
+
151
+
152
+ def _usb_fallback_device_infos() -> list[DeviceInfo]:
153
+ """Enumerate attached LS-5000 units directly over USB, no python-sane.
154
+
155
+ Used by :func:`get_devices` only when python-sane is not importable.
156
+ ``usb.core`` is imported lazily here, matching this package's convention
157
+ of scoping USB imports to the code that actually touches the bus.
158
+
159
+ The returned id is synthetic (``"usb:<bus>:<address>"``): honest about
160
+ the USB topology it was found on, but not a SANE device string, since
161
+ none was ever negotiated. Nothing downstream needs it to be one --
162
+ :meth:`Device.roll`'s capture subprocess locates the physical unit itself
163
+ by the same fixed vendor/product id (see
164
+ ``protocol.ls5000_single_pass.worker``), never through this id; the id
165
+ here only labels the in-process open-device registry and the ``Receipt``
166
+ metadata a roll capture carries.
167
+ """
168
+
169
+ import usb.core
170
+
171
+ capabilities = _usb_fallback_capabilities()
172
+ found = usb.core.find(
173
+ find_all=True,
174
+ idVendor=_LS5000_USB_VENDOR_ID,
175
+ idProduct=_LS5000_USB_PRODUCT_ID,
176
+ )
177
+ return [
178
+ DeviceInfo(
179
+ id=f"{_USB_FALLBACK_ID_PREFIX}:{device.bus}:{device.address}",
180
+ vendor="Nikon",
181
+ model="LS-5000 ED",
182
+ capabilities=capabilities,
183
+ )
184
+ for device in found
185
+ ]
186
+
187
+
188
+ def get_devices(local_only: bool = False) -> list[DeviceInfo]:
189
+ """Enumerate attached Coolscan LS-5000 units.
190
+
191
+ Mirrors ``sane.get_devices()``; unlike SANE, this never returns a
192
+ non-Coolscan device -- there is no backend negotiation. ``local_only`` is
193
+ accepted for signature-compatibility with ``sane.get_devices()`` and is
194
+ currently always true (no network transport exists for this package).
195
+
196
+ Tries the SANE route first. When python-sane is not importable, falls
197
+ back to direct USB enumeration instead of raising -- see
198
+ :func:`_usb_fallback_device_infos`.
199
+ """
200
+
201
+ del local_only
202
+ try:
203
+ service = _service_factory()
204
+ devices = service.list_devices()
205
+ except ImportError:
206
+ return _usb_fallback_device_infos()
207
+ return [_device_info_from(device) for device in devices if _is_coolscan_device_id(device.id)]
208
+
209
+
210
+ def open(devname: str) -> "Device":
211
+ """Open one Coolscan LS-5000. Mirrors ``sane.open(devname)``.
212
+
213
+ ``devname`` is either ``"ls5000"`` (friendly alias for "the one attached
214
+ unit") or an exact :class:`~coolscanpy.types.DeviceInfo.id` string
215
+ returned by :func:`get_devices`.
216
+ """
217
+
218
+ infos = get_devices()
219
+ if devname == "ls5000":
220
+ if not infos:
221
+ raise DeviceNotFound("no Coolscan LS-5000 unit is attached")
222
+ if len(infos) > 1:
223
+ raise DeviceNotFound(
224
+ "more than one Coolscan LS-5000 unit is attached; "
225
+ "disambiguate via get_devices()"
226
+ )
227
+ info = infos[0]
228
+ else:
229
+ matches = [candidate for candidate in infos if candidate.id == devname]
230
+ if not matches:
231
+ raise DeviceNotFound(f"no attached Coolscan LS-5000 unit matches {devname!r}")
232
+ info = matches[0]
233
+
234
+ _register_open_device(info.id)
235
+ try:
236
+ return Device(info, _service_factory())
237
+ except BaseException:
238
+ _unregister_open_device(info.id)
239
+ raise
240
+
241
+
242
+ class Device:
243
+ """One opened Coolscan LS-5000 unit.
244
+
245
+ Construct via :func:`open`, not directly, except in tests that want to
246
+ drive a specific :class:`DeviceInfo`/service pair without going through
247
+ device enumeration.
248
+ """
249
+
250
+ def __init__(self, info: DeviceInfo, service: ScannerService) -> None:
251
+ self._info = info
252
+ self._service = service
253
+ self._lock = threading.Lock()
254
+ self._roll_lock = threading.Lock()
255
+ self._cancel_event: threading.Event | None = None
256
+ self._closed = False
257
+
258
+ caps = info.capabilities
259
+ self.resolution = max(caps.supported_dpi) if caps.supported_dpi else 4000
260
+ self.depth = 16 if 16 in caps.supported_depths else (
261
+ max(caps.supported_depths) if caps.supported_depths else 16
262
+ )
263
+ self.samples = 1
264
+ self.autofocus = True
265
+ self.auto_exposure = False
266
+
267
+ def __setattr__(self, name: str, value: object) -> None:
268
+ if name in _OPTION_NAMES:
269
+ self._validate_option(name, value)
270
+ object.__setattr__(self, name, value)
271
+
272
+ def _validate_option(self, name: str, value: object) -> None:
273
+ caps = self._info.capabilities
274
+ if name == "resolution":
275
+ if isinstance(value, bool) or not isinstance(value, int) or value not in caps.supported_dpi:
276
+ raise ValueError(f"resolution {value!r} is not in supported_dpi {caps.supported_dpi}")
277
+ elif name == "depth":
278
+ if isinstance(value, bool) or not isinstance(value, int) or value not in caps.supported_depths:
279
+ raise ValueError(f"depth {value!r} is not in supported_depths {caps.supported_depths}")
280
+ elif name == "samples":
281
+ allowed = (1, 4) if caps.multi_sample else (1,)
282
+ if isinstance(value, bool) or not isinstance(value, int) or value not in allowed:
283
+ raise ValueError(f"samples {value!r} is not in {allowed}")
284
+ elif name == "autofocus":
285
+ if not isinstance(value, bool):
286
+ raise TypeError("autofocus must be a bool")
287
+ elif name == "auto_exposure":
288
+ if not isinstance(value, bool):
289
+ raise TypeError("auto_exposure must be a bool")
290
+ if value and not caps.auto_exposure:
291
+ raise ValueError("device has no auto_exposure capability")
292
+
293
+ @property
294
+ def capabilities(self):
295
+ return self._info.capabilities
296
+
297
+ @property
298
+ def option_names(self) -> list[str]:
299
+ """Mirrors sane's ``optlist``."""
300
+
301
+ return list(_OPTION_NAMES)
302
+
303
+ def __getitem__(self, name: str) -> Option:
304
+ """Mirrors sane's ``dev['name']`` descriptor lookup."""
305
+
306
+ caps = self._info.capabilities
307
+ if name == "resolution":
308
+ return Option(
309
+ name="resolution",
310
+ title="Resolution",
311
+ desc="Scan resolution, in dots per inch.",
312
+ type=OptionType.INT,
313
+ unit=OptionUnit.DPI,
314
+ constraint=tuple(caps.supported_dpi),
315
+ active=True,
316
+ settable=len(caps.supported_dpi) > 1,
317
+ )
318
+ if name == "depth":
319
+ return Option(
320
+ name="depth",
321
+ title="Depth",
322
+ desc="Bits per sample.",
323
+ type=OptionType.INT,
324
+ unit=OptionUnit.NONE,
325
+ constraint=tuple(caps.supported_depths),
326
+ active=True,
327
+ settable=len(caps.supported_depths) > 1,
328
+ )
329
+ if name == "samples":
330
+ constraint = (1, 4) if caps.multi_sample else (1,)
331
+ return Option(
332
+ name="samples",
333
+ title="Samples",
334
+ desc="Hardware oversampling passes per line.",
335
+ type=OptionType.INT,
336
+ unit=OptionUnit.NONE,
337
+ constraint=constraint,
338
+ active=True,
339
+ settable=caps.multi_sample,
340
+ )
341
+ if name == "autofocus":
342
+ return Option(
343
+ name="autofocus",
344
+ title="Autofocus",
345
+ desc="Autofocus before each scan.",
346
+ type=OptionType.BOOL,
347
+ unit=OptionUnit.NONE,
348
+ constraint=None,
349
+ active=True,
350
+ settable=True,
351
+ )
352
+ if name == "auto_exposure":
353
+ return Option(
354
+ name="auto_exposure",
355
+ title="Auto exposure",
356
+ desc="Hardware auto-exposure metering.",
357
+ type=OptionType.BOOL,
358
+ unit=OptionUnit.NONE,
359
+ constraint=None,
360
+ active=caps.auto_exposure,
361
+ settable=caps.auto_exposure,
362
+ )
363
+ raise KeyError(name)
364
+
365
+ def scan(self, *, progress: Callable[[float], None] | None = None):
366
+ """Blocking. Returns a uint16 ``(H, W, 3)`` scanner-linear RGB array.
367
+
368
+ The plain, roll-independent scan path: a single ad-hoc capture at
369
+ whatever resolution/depth/samples are currently set. Never touches
370
+ roll bookkeeping, never produces a Receipt, never returns IR.
371
+ """
372
+
373
+ self._require_open()
374
+ if not self._lock.acquire(blocking=False):
375
+ raise DeviceBusy(f"device {self._info.id} is already scanning")
376
+ try:
377
+ cancel_event = threading.Event()
378
+ self._cancel_event = cancel_event
379
+ params = ScanParams(
380
+ dpi=self.resolution,
381
+ depth=self.depth,
382
+ capture_ir=False,
383
+ autofocus=self.autofocus,
384
+ samples_per_scan=self.samples,
385
+ auto_exposure=self.auto_exposure,
386
+ )
387
+ result = self._service.run_scan(
388
+ self._info.id,
389
+ params,
390
+ progress if progress is not None else (lambda _fraction: None),
391
+ cancel_event,
392
+ )
393
+ return result.rgb
394
+ finally:
395
+ self._cancel_event = None
396
+ self._lock.release()
397
+
398
+ def cancel(self) -> None:
399
+ """Mirrors ``sane.SaneDev.cancel()``. Call from a different thread
400
+ than the one blocked in :meth:`scan`."""
401
+
402
+ event = self._cancel_event
403
+ if event is not None:
404
+ event.set()
405
+
406
+ def roll(self, *, material: Material = Material.COLOR_NEGATIVE) -> "Roll":
407
+ """Open the 40-slot roll-feeder extension."""
408
+
409
+ self._require_open()
410
+ caps = self._info.capabilities
411
+ if caps.adapter_frame_capacity is None and not caps.adapter_frame_control:
412
+ raise ValueError("no roll adapter is attached/detected on this device")
413
+ if not self._roll_lock.acquire(blocking=False):
414
+ raise DeviceBusy(f"a Roll is already open on device {self._info.id}")
415
+ try:
416
+ from coolscanpy._roll import Roll
417
+
418
+ return Roll(self, material)
419
+ except BaseException:
420
+ self._roll_lock.release()
421
+ raise
422
+
423
+ def _release_roll_lock(self) -> None:
424
+ try:
425
+ self._roll_lock.release()
426
+ except RuntimeError:
427
+ pass
428
+
429
+ def eject(self) -> bool:
430
+ """Capability-gated vendor eject/unload."""
431
+
432
+ self._require_open()
433
+ try:
434
+ return bool(self._service.eject(self._info.id))
435
+ except RuntimeError as error:
436
+ raise EjectFailed(str(error)) from error
437
+
438
+ def close(self) -> None:
439
+ """Idempotent. Releases any transport claim this Device holds."""
440
+
441
+ if self._closed:
442
+ return
443
+ self._closed = True
444
+ _unregister_open_device(self._info.id)
445
+
446
+ def _require_open(self) -> None:
447
+ if self._closed:
448
+ raise RuntimeError("this Device has been closed")
449
+
450
+ def __enter__(self) -> "Device":
451
+ return self
452
+
453
+ def __exit__(self, *exc: object) -> None:
454
+ self.close()
coolscanpy/_logging.py ADDED
@@ -0,0 +1,27 @@
1
+ """Internal logging helper.
2
+
3
+ A thin wrapper over the standard library's ``logging`` module so the rest of
4
+ this package can call ``get_logger(__name__)`` without depending on any host
5
+ application's logging configuration. A ``NullHandler`` is attached to the
6
+ package's root logger so that an application which never configures logging
7
+ gets no output and no "no handlers could be found" warning, per the standard
8
+ library's guidance for library authors.
9
+ """
10
+
11
+ import logging
12
+
13
+ logging.getLogger("coolscanpy").addHandler(logging.NullHandler())
14
+
15
+
16
+ def get_logger(name: str | None = None) -> logging.Logger:
17
+ """Return a logger namespaced under ``coolscanpy``.
18
+
19
+ ``name`` is typically a module's ``__name__`` (already fully qualified as
20
+ ``coolscanpy.<submodule>``), in which case it is used as-is. A bare,
21
+ unqualified name is nested under the package logger instead.
22
+ """
23
+ if not name:
24
+ return logging.getLogger("coolscanpy")
25
+ if name == "coolscanpy" or name.startswith("coolscanpy."):
26
+ return logging.getLogger(name)
27
+ return logging.getLogger(f"coolscanpy.{name}")