datamax-dpl 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.
- datamax_dpl/__init__.py +96 -0
- datamax_dpl/__init__conf__.py +90 -0
- datamax_dpl/__main__.py +9 -0
- datamax_dpl/adapters/__init__.py +14 -0
- datamax_dpl/adapters/camera/__init__.py +117 -0
- datamax_dpl/adapters/camera/command.py +144 -0
- datamax_dpl/adapters/camera/file.py +138 -0
- datamax_dpl/adapters/camera/http.py +156 -0
- datamax_dpl/adapters/cli/__init__.py +56 -0
- datamax_dpl/adapters/cli/commands/__init__.py +52 -0
- datamax_dpl/adapters/cli/commands/config.py +529 -0
- datamax_dpl/adapters/cli/commands/info.py +38 -0
- datamax_dpl/adapters/cli/commands/labels.py +272 -0
- datamax_dpl/adapters/cli/commands/model_options.py +80 -0
- datamax_dpl/adapters/cli/commands/printer.py +264 -0
- datamax_dpl/adapters/cli/config_load.py +206 -0
- datamax_dpl/adapters/cli/constants.py +28 -0
- datamax_dpl/adapters/cli/context.py +184 -0
- datamax_dpl/adapters/cli/envelope.py +97 -0
- datamax_dpl/adapters/cli/exit_codes.py +59 -0
- datamax_dpl/adapters/cli/label_io.py +144 -0
- datamax_dpl/adapters/cli/main.py +123 -0
- datamax_dpl/adapters/cli/payloads.py +92 -0
- datamax_dpl/adapters/cli/py.typed +0 -0
- datamax_dpl/adapters/cli/root.py +181 -0
- datamax_dpl/adapters/cli/runner.py +90 -0
- datamax_dpl/adapters/cli/safe_console.py +315 -0
- datamax_dpl/adapters/cli/settings.py +142 -0
- datamax_dpl/adapters/cli/typed_click.py +77 -0
- datamax_dpl/adapters/config/__init__.py +25 -0
- datamax_dpl/adapters/config/defaultconfig.d/40-layered-config.toml +78 -0
- datamax_dpl/adapters/config/defaultconfig.d/50-printer.toml +86 -0
- datamax_dpl/adapters/config/defaultconfig.d/60-camera.toml +66 -0
- datamax_dpl/adapters/config/defaultconfig.d/90-logging.toml +448 -0
- datamax_dpl/adapters/config/defaultconfig.toml +56 -0
- datamax_dpl/adapters/config/deploy.py +135 -0
- datamax_dpl/adapters/config/display.py +60 -0
- datamax_dpl/adapters/config/loader.py +210 -0
- datamax_dpl/adapters/config/overrides.py +305 -0
- datamax_dpl/adapters/config/py.typed +0 -0
- datamax_dpl/adapters/logging/__init__.py +13 -0
- datamax_dpl/adapters/logging/py.typed +0 -0
- datamax_dpl/adapters/logging/setup.py +437 -0
- datamax_dpl/adapters/memory/__init__.py +54 -0
- datamax_dpl/adapters/memory/camera.py +109 -0
- datamax_dpl/adapters/memory/config.py +66 -0
- datamax_dpl/adapters/memory/logging.py +80 -0
- datamax_dpl/adapters/memory/printer.py +125 -0
- datamax_dpl/adapters/preview/__init__.py +112 -0
- datamax_dpl/adapters/py.typed +0 -0
- datamax_dpl/adapters/transport/__init__.py +114 -0
- datamax_dpl/adapters/transport/device.py +84 -0
- datamax_dpl/adapters/transport/file.py +101 -0
- datamax_dpl/adapters/transport/serial.py +241 -0
- datamax_dpl/adapters/transport/tcp.py +167 -0
- datamax_dpl/adapters/transport/uri.py +372 -0
- datamax_dpl/application/__init__.py +28 -0
- datamax_dpl/application/ports.py +155 -0
- datamax_dpl/application/py.typed +0 -0
- datamax_dpl/application/use_cases.py +217 -0
- datamax_dpl/application/verify.py +116 -0
- datamax_dpl/composition/__init__.py +146 -0
- datamax_dpl/composition/py.typed +0 -0
- datamax_dpl/domain/__init__.py +20 -0
- datamax_dpl/domain/barcodes.py +496 -0
- datamax_dpl/domain/batch.py +79 -0
- datamax_dpl/domain/camera.py +71 -0
- datamax_dpl/domain/charset.py +205 -0
- datamax_dpl/domain/decoder.py +370 -0
- datamax_dpl/domain/detect.py +107 -0
- datamax_dpl/domain/encode_barcode.py +145 -0
- datamax_dpl/domain/encode_common.py +86 -0
- datamax_dpl/domain/encode_graphics.py +140 -0
- datamax_dpl/domain/encode_text.py +137 -0
- datamax_dpl/domain/enums.py +219 -0
- datamax_dpl/domain/errors.py +66 -0
- datamax_dpl/domain/findings.py +89 -0
- datamax_dpl/domain/image_records.py +226 -0
- datamax_dpl/domain/job.py +261 -0
- datamax_dpl/domain/label.py +369 -0
- datamax_dpl/domain/placeholders.py +88 -0
- datamax_dpl/domain/png.py +123 -0
- datamax_dpl/domain/preview.py +248 -0
- datamax_dpl/domain/profiles.py +490 -0
- datamax_dpl/domain/py.typed +0 -0
- datamax_dpl/domain/raster.py +536 -0
- datamax_dpl/domain/raster_fallback.py +120 -0
- datamax_dpl/domain/records.py +196 -0
- datamax_dpl/domain/render.py +192 -0
- datamax_dpl/domain/render_context.py +131 -0
- datamax_dpl/domain/status.py +394 -0
- datamax_dpl/domain/symbology.py +125 -0
- datamax_dpl/domain/units.py +127 -0
- datamax_dpl/entry.py +26 -0
- datamax_dpl/py.typed +0 -0
- datamax_dpl-0.1.0.dist-info/METADATA +343 -0
- datamax_dpl-0.1.0.dist-info/RECORD +100 -0
- datamax_dpl-0.1.0.dist-info/WHEEL +4 -0
- datamax_dpl-0.1.0.dist-info/entry_points.txt +2 -0
- datamax_dpl-0.1.0.dist-info/licenses/LICENSE +22 -0
datamax_dpl/__init__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""datamax-dpl: build, check and print Datamax DPL label jobs.
|
|
2
|
+
|
|
3
|
+
The library surface, in the order a caller usually needs it:
|
|
4
|
+
|
|
5
|
+
* the label model (:class:`Label` and its elements) and its enums;
|
|
6
|
+
* :func:`render` (raises on an error finding) and :func:`build_job` (reports every finding);
|
|
7
|
+
* :func:`render_batch` and :func:`fill_label` for one label per data record;
|
|
8
|
+
* :func:`open_printer` to send the bytes, :func:`explain` to read DPL back;
|
|
9
|
+
* :func:`get_profile` / :func:`list_profiles` for the printer models;
|
|
10
|
+
* :func:`render_preview_png` for a picture of the label.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
>>> from datamax_dpl import Label, RenderOptions, Text, render
|
|
14
|
+
>>> job = render(Label(elements=[Text(x=5, y=5, text="Hello")]), RenderOptions(model="dmx-600"))
|
|
15
|
+
>>> job.data[:3]
|
|
16
|
+
b'\\x02L\\r'
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .__init__conf__ import print_info
|
|
22
|
+
from .adapters.preview import render_preview_png
|
|
23
|
+
from .adapters.transport import open_printer
|
|
24
|
+
from .application.use_cases import BatchResult, render_batch
|
|
25
|
+
from .composition import get_config
|
|
26
|
+
from .domain.decoder import explain
|
|
27
|
+
from .domain.enums import Attribute, FallbackPolicy, FillPattern, MediaSensor, Rotation, Severity, SpeedCode, Unit
|
|
28
|
+
from .domain.errors import LabelRejectedError, TransportError
|
|
29
|
+
from .domain.findings import Finding
|
|
30
|
+
from .domain.label import (
|
|
31
|
+
Barcode,
|
|
32
|
+
Box,
|
|
33
|
+
Circle,
|
|
34
|
+
Counter,
|
|
35
|
+
Cut,
|
|
36
|
+
DataMatrixOptions,
|
|
37
|
+
DotSize,
|
|
38
|
+
Image,
|
|
39
|
+
Label,
|
|
40
|
+
Line,
|
|
41
|
+
Pdf417Options,
|
|
42
|
+
Point,
|
|
43
|
+
Polygon,
|
|
44
|
+
ScalableText,
|
|
45
|
+
Text,
|
|
46
|
+
)
|
|
47
|
+
from .domain.placeholders import fill_label
|
|
48
|
+
from .domain.profiles import PrinterProfile, get_profile, list_profiles
|
|
49
|
+
from .domain.render import RenderResult, build_job, render
|
|
50
|
+
from .domain.render_context import RenderOptions
|
|
51
|
+
from .domain.symbology import Symbology
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"Attribute",
|
|
55
|
+
"Barcode",
|
|
56
|
+
"BatchResult",
|
|
57
|
+
"Box",
|
|
58
|
+
"Circle",
|
|
59
|
+
"Counter",
|
|
60
|
+
"Cut",
|
|
61
|
+
"DataMatrixOptions",
|
|
62
|
+
"DotSize",
|
|
63
|
+
"FallbackPolicy",
|
|
64
|
+
"FillPattern",
|
|
65
|
+
"Finding",
|
|
66
|
+
"Image",
|
|
67
|
+
"Label",
|
|
68
|
+
"LabelRejectedError",
|
|
69
|
+
"Line",
|
|
70
|
+
"MediaSensor",
|
|
71
|
+
"Pdf417Options",
|
|
72
|
+
"Point",
|
|
73
|
+
"Polygon",
|
|
74
|
+
"PrinterProfile",
|
|
75
|
+
"RenderOptions",
|
|
76
|
+
"RenderResult",
|
|
77
|
+
"Rotation",
|
|
78
|
+
"ScalableText",
|
|
79
|
+
"Severity",
|
|
80
|
+
"SpeedCode",
|
|
81
|
+
"Symbology",
|
|
82
|
+
"Text",
|
|
83
|
+
"TransportError",
|
|
84
|
+
"Unit",
|
|
85
|
+
"build_job",
|
|
86
|
+
"explain",
|
|
87
|
+
"fill_label",
|
|
88
|
+
"get_config",
|
|
89
|
+
"get_profile",
|
|
90
|
+
"list_profiles",
|
|
91
|
+
"open_printer",
|
|
92
|
+
"print_info",
|
|
93
|
+
"render",
|
|
94
|
+
"render_batch",
|
|
95
|
+
"render_preview_png",
|
|
96
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Static package metadata surfaced to CLI commands and documentation.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Expose the current project metadata as simple constants. These values are kept
|
|
6
|
+
in sync with ``pyproject.toml`` by development automation (tests, push
|
|
7
|
+
pipelines), so runtime code does not query packaging metadata.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
--------
|
|
11
|
+
* Module-level constants describing the published package.
|
|
12
|
+
* :func:`print_info` rendering the constants for the CLI ``info`` command.
|
|
13
|
+
|
|
14
|
+
System Role
|
|
15
|
+
-----------
|
|
16
|
+
Lives in the adapters/platform layer; CLI transports import these constants to
|
|
17
|
+
present authoritative project information without invoking packaging APIs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"LAYEREDCONF_APP",
|
|
26
|
+
"LAYEREDCONF_SLUG",
|
|
27
|
+
"LAYEREDCONF_VENDOR",
|
|
28
|
+
"author",
|
|
29
|
+
"author_email",
|
|
30
|
+
"homepage",
|
|
31
|
+
"name",
|
|
32
|
+
"print_info",
|
|
33
|
+
"shell_command",
|
|
34
|
+
"title",
|
|
35
|
+
"version",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
#: Distribution name declared in ``pyproject.toml``.
|
|
39
|
+
name = "datamax-dpl"
|
|
40
|
+
#: Human-readable summary shown in CLI help output.
|
|
41
|
+
title = "Datamax DPL label printing: library, CLI and LLM-friendly JSON, with per-model capability checks"
|
|
42
|
+
#: Current release version pulled from ``pyproject.toml`` by automation.
|
|
43
|
+
version = "0.1.0"
|
|
44
|
+
#: Repository homepage presented to users.
|
|
45
|
+
homepage = "https://github.com/bitranox/datamax-dpl"
|
|
46
|
+
#: Author attribution surfaced in CLI output.
|
|
47
|
+
author = "bitranox"
|
|
48
|
+
#: Contact email surfaced in CLI output.
|
|
49
|
+
author_email = "bitranox@gmail.com"
|
|
50
|
+
#: Console-script name published by the package.
|
|
51
|
+
shell_command = "datamax-dpl"
|
|
52
|
+
|
|
53
|
+
#: Vendor identifier for lib_layered_config paths (macOS/Windows)
|
|
54
|
+
LAYEREDCONF_VENDOR: str = "bitranox"
|
|
55
|
+
#: Application display name for lib_layered_config paths (macOS/Windows)
|
|
56
|
+
LAYEREDCONF_APP: str = "datamax_dpl"
|
|
57
|
+
#: Configuration slug for lib_layered_config Linux paths and environment variables
|
|
58
|
+
LAYEREDCONF_SLUG: str = "datamax-dpl"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_info() -> None:
|
|
62
|
+
"""Print the summarised metadata block used by the CLI ``info`` command.
|
|
63
|
+
|
|
64
|
+
Why
|
|
65
|
+
Provides a single, auditable rendering function so documentation and
|
|
66
|
+
CLI output always match the system design reference.
|
|
67
|
+
|
|
68
|
+
Side Effects
|
|
69
|
+
Writes to ``stdout``.
|
|
70
|
+
|
|
71
|
+
Examples
|
|
72
|
+
--------
|
|
73
|
+
>>> print_info() # doctest: +ELLIPSIS
|
|
74
|
+
Info for datamax-dpl:
|
|
75
|
+
...
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
fields = [
|
|
79
|
+
("name", name),
|
|
80
|
+
("title", title),
|
|
81
|
+
("version", version),
|
|
82
|
+
("homepage", homepage),
|
|
83
|
+
("author", author),
|
|
84
|
+
("author_email", author_email),
|
|
85
|
+
("shell_command", shell_command),
|
|
86
|
+
]
|
|
87
|
+
pad = max(len(label) for label, _ in fields)
|
|
88
|
+
lines = [f"Info for {name}:", ""]
|
|
89
|
+
lines.extend(f" {label.ljust(pad)} = {value}" for label, value in fields)
|
|
90
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
datamax_dpl/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Module entry point for ``python -m datamax_dpl``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .adapters.cli.main import main
|
|
6
|
+
from .composition import build_production
|
|
7
|
+
|
|
8
|
+
if __name__ == "__main__":
|
|
9
|
+
raise SystemExit(main(services_factory=build_production))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Adapters layer - infrastructure and framework integrations.
|
|
2
|
+
|
|
3
|
+
Contains adapter implementations that connect the application to external
|
|
4
|
+
systems and frameworks (CLI, configuration, logging).
|
|
5
|
+
|
|
6
|
+
Contents:
|
|
7
|
+
* :mod:`.config` - Configuration loading, deployment, and display
|
|
8
|
+
* :mod:`.logging` - Logging setup with lib_log_rich
|
|
9
|
+
* :mod:`.cli` - Click CLI framework integration
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Camera adapters: take one photo of a printed label with the configured camera.
|
|
2
|
+
|
|
3
|
+
:func:`capture_image` implements the :class:`~datamax_dpl.application.ports.CaptureImage` port
|
|
4
|
+
and dispatches on ``[camera] kind``:
|
|
5
|
+
|
|
6
|
+
* ``command`` - :mod:`.command` runs a capture program that writes the image itself.
|
|
7
|
+
* ``http`` - :mod:`.http` fetches a snapshot URL.
|
|
8
|
+
* ``file`` - :mod:`.file` picks up the newest image a camera drops into a folder.
|
|
9
|
+
|
|
10
|
+
Every image is stored in ``output_dir`` as ``capture-<UTC timestamp>.<ext>``. Waiting
|
|
11
|
+
``settle_seconds`` after the print is the caller's job; the adapters take the photo at once.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import TYPE_CHECKING, Protocol
|
|
18
|
+
|
|
19
|
+
from ...domain.camera import CameraKind, CapturedImage
|
|
20
|
+
from ...domain.errors import CameraError
|
|
21
|
+
from .command import capture_with_command
|
|
22
|
+
from .file import MEDIA_TYPE_BY_SUFFIX, capture_from_directory
|
|
23
|
+
from .http import capture_with_http
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ...domain.camera import CameraSettings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Capture(Protocol):
|
|
32
|
+
"""One capture strategy: write the image next to ``stem`` and return its path."""
|
|
33
|
+
|
|
34
|
+
def __call__(self, settings: CameraSettings, *, stem: Path) -> Path: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_CAPTURE_BY_KIND: dict[CameraKind, _Capture] = {
|
|
38
|
+
CameraKind.COMMAND: capture_with_command,
|
|
39
|
+
CameraKind.HTTP: capture_with_http,
|
|
40
|
+
CameraKind.FILE: capture_from_directory,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#: strftime pattern of the timestamp in an image file name; microseconds keep names unique.
|
|
44
|
+
CAPTURE_STAMP_FORMAT = "%Y%m%dT%H%M%S%fZ"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def capture_image(settings: CameraSettings, *, output_dir: Path) -> CapturedImage:
|
|
48
|
+
"""Take one photo with the configured camera and store it in ``output_dir``.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
settings: The ``[camera]`` section.
|
|
52
|
+
output_dir: Folder for the image; created when missing.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The stored image with its media type and the (UTC) moment the capture finished.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
CameraError: No camera is configured, ``output_dir`` cannot be created, or the camera
|
|
59
|
+
could not deliver an image.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
>>> from pathlib import Path
|
|
63
|
+
>>> from datamax_dpl.domain.camera import CameraSettings
|
|
64
|
+
>>> capture_image(CameraSettings(), output_dir=Path("unused"))
|
|
65
|
+
Traceback (most recent call last):
|
|
66
|
+
...
|
|
67
|
+
datamax_dpl.domain.errors.CameraError: no camera configured: set [camera] kind
|
|
68
|
+
"""
|
|
69
|
+
capture = _CAPTURE_BY_KIND.get(settings.kind)
|
|
70
|
+
if capture is None:
|
|
71
|
+
raise CameraError("no camera configured: set [camera] kind")
|
|
72
|
+
try:
|
|
73
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
raise CameraError(f"image folder {output_dir} cannot be created: {exc.strerror or exc}") from exc
|
|
76
|
+
stem = output_dir / f"capture-{datetime.now(timezone.utc).strftime(CAPTURE_STAMP_FORMAT)}"
|
|
77
|
+
path = capture(settings, stem=stem)
|
|
78
|
+
return CapturedImage(path=path, media_type=media_type_for(path), captured_at=datetime.now(timezone.utc))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def media_type_for(path: Path) -> str:
|
|
82
|
+
"""Return the media type of an image file from its suffix.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
path: An image path ending in ``.jpg``, ``.jpeg`` or ``.png`` (any case).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
``image/jpeg`` or ``image/png``.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
CameraError: The suffix is not an image suffix this package writes.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
>>> from pathlib import Path
|
|
95
|
+
>>> media_type_for(Path("capture.PNG"))
|
|
96
|
+
'image/png'
|
|
97
|
+
"""
|
|
98
|
+
media_type = MEDIA_TYPE_BY_SUFFIX.get(path.suffix.lower())
|
|
99
|
+
if media_type is None:
|
|
100
|
+
raise CameraError(f"{path.name} is not a JPEG or PNG file name")
|
|
101
|
+
return media_type
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if TYPE_CHECKING:
|
|
105
|
+
from ...application.ports import CaptureImage
|
|
106
|
+
|
|
107
|
+
_assert_capture_image: CaptureImage = capture_image
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"CAPTURE_STAMP_FORMAT",
|
|
112
|
+
"capture_from_directory",
|
|
113
|
+
"capture_image",
|
|
114
|
+
"capture_with_command",
|
|
115
|
+
"capture_with_http",
|
|
116
|
+
"media_type_for",
|
|
117
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Take a photo by running an external capture program (``[camera] kind = "command"``).
|
|
2
|
+
|
|
3
|
+
The configured argv is run directly, never through a shell. Every occurrence of
|
|
4
|
+
:data:`~datamax_dpl.domain.camera.OUTPUT_PLACEHOLDER` is replaced by the image path.
|
|
5
|
+
|
|
6
|
+
Extension rule:
|
|
7
|
+
The image is a ``.jpg`` unless the argv writes the placeholder immediately followed by an
|
|
8
|
+
image suffix, as in ``"{output}.png"``: then that suffix (``.png``, ``.jpg`` or ``.jpeg``,
|
|
9
|
+
any case) becomes the file's extension and the placeholder plus suffix is replaced by the
|
|
10
|
+
full path. ``["fswebcam", "{output}"]`` writes ``capture-<stamp>.jpg``;
|
|
11
|
+
``["grab", "-o", "{output}.png"]`` writes ``capture-<stamp>.png``. Two different suffixes
|
|
12
|
+
in one argv are refused, since only one file is collected.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess # nosec B404 # running the operator's capture program is this module's job
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from ...domain.camera import OUTPUT_PLACEHOLDER
|
|
23
|
+
from ...domain.errors import CameraError
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ...domain.camera import CameraSettings
|
|
29
|
+
|
|
30
|
+
#: Extension used when the argv does not name one next to the placeholder.
|
|
31
|
+
DEFAULT_EXTENSION = ".jpg"
|
|
32
|
+
|
|
33
|
+
#: How many trailing characters of the program's stderr an error message carries.
|
|
34
|
+
STDERR_TAIL_CHARS = 500
|
|
35
|
+
|
|
36
|
+
_PLACEHOLDER_PATTERN = re.compile(re.escape(OUTPUT_PLACEHOLDER) + r"(\.(?:png|jpe?g))?", re.IGNORECASE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def capture_with_command(settings: CameraSettings, *, stem: Path) -> Path:
|
|
40
|
+
"""Run ``settings.command`` and return the image it wrote.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
settings: The camera settings; ``command`` and ``timeout`` are used.
|
|
44
|
+
stem: Output path without extension; the extension follows the rule in the module
|
|
45
|
+
docstring.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The path of the non-empty image file the program wrote.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
CameraError: The argv is empty or lacks the placeholder, the program is missing, it
|
|
52
|
+
timed out, exited non-zero, or left no non-empty image behind.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> import sys, tempfile
|
|
56
|
+
>>> from pathlib import Path
|
|
57
|
+
>>> from datamax_dpl.domain.camera import CameraKind, CameraSettings
|
|
58
|
+
>>> script = "import sys; open(sys.argv[1], 'wb').write(b'img')"
|
|
59
|
+
>>> settings = CameraSettings(kind=CameraKind.COMMAND, command=[sys.executable, "-c", script, "{output}"])
|
|
60
|
+
>>> with tempfile.TemporaryDirectory() as tmp:
|
|
61
|
+
... capture_with_command(settings, stem=Path(tmp) / "capture").name
|
|
62
|
+
'capture.jpg'
|
|
63
|
+
"""
|
|
64
|
+
argv = list(settings.command)
|
|
65
|
+
if not argv:
|
|
66
|
+
raise CameraError("no camera command configured: set [camera] command")
|
|
67
|
+
output = stem.with_suffix(_extension(argv))
|
|
68
|
+
resolved = [_resolve_program(argv[0]), *(_substitute(arg, output=output) for arg in argv[1:])]
|
|
69
|
+
_run(resolved, program=argv[0], timeout=settings.timeout)
|
|
70
|
+
_require_image(output, program=argv[0])
|
|
71
|
+
return output
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _extension(argv: list[str]) -> str:
|
|
75
|
+
"""Return the image extension the argv asks for, refusing an argv without the placeholder."""
|
|
76
|
+
matches = [match for arg in argv for match in _PLACEHOLDER_PATTERN.finditer(arg)]
|
|
77
|
+
if not matches:
|
|
78
|
+
raise CameraError(f"camera command has no {OUTPUT_PLACEHOLDER} argument to receive the image path")
|
|
79
|
+
suffixes = {_normalise_suffix(match.group(1)) for match in matches if match.group(1)}
|
|
80
|
+
if len(suffixes) > 1:
|
|
81
|
+
raise CameraError(f"camera command names conflicting image suffixes: {sorted(suffixes)}")
|
|
82
|
+
return suffixes.pop() if suffixes else DEFAULT_EXTENSION
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _normalise_suffix(suffix: str) -> str:
|
|
86
|
+
"""Lower-case a suffix so ``.PNG`` and ``.png`` name the same file type."""
|
|
87
|
+
return suffix.lower()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _substitute(arg: str, *, output: Path) -> str:
|
|
91
|
+
"""Replace every placeholder (with its optional suffix) in ``arg`` by the output path."""
|
|
92
|
+
# A lambda keeps re from parsing the path as a replacement template: a Windows path's
|
|
93
|
+
# backslashes would otherwise be read as escapes or group references.
|
|
94
|
+
return _PLACEHOLDER_PATTERN.sub(lambda _match: str(output), arg)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_program(program: str) -> str:
|
|
98
|
+
"""Return the absolute path of ``program``.
|
|
99
|
+
|
|
100
|
+
Windows CreateProcess searches the parent's PATH rather than the child's, so the program is
|
|
101
|
+
resolved here with the same PATH on every platform.
|
|
102
|
+
"""
|
|
103
|
+
resolved = shutil.which(program)
|
|
104
|
+
if resolved is None:
|
|
105
|
+
raise CameraError(f"camera program not found: {program!r}")
|
|
106
|
+
return resolved
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run(argv: list[str], *, program: str, timeout: float) -> None:
|
|
110
|
+
"""Run ``argv`` to completion, turning every failure into a :class:`CameraError`."""
|
|
111
|
+
try:
|
|
112
|
+
completed = subprocess.run( # noqa: S603 - operator-configured argv, no shell # nosec B603
|
|
113
|
+
argv,
|
|
114
|
+
stdin=subprocess.DEVNULL,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
timeout=timeout,
|
|
117
|
+
check=False,
|
|
118
|
+
)
|
|
119
|
+
except subprocess.TimeoutExpired as exc:
|
|
120
|
+
raise CameraError(f"camera program {program!r} timed out after {timeout:g} s") from exc
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
raise CameraError(f"camera program {program!r} could not be started: {exc.strerror or exc}") from exc
|
|
123
|
+
if completed.returncode != 0:
|
|
124
|
+
tail = _stderr_tail(completed.stderr)
|
|
125
|
+
raise CameraError(f"camera program {program!r} exited with status {completed.returncode}: {tail}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _stderr_tail(stderr: bytes) -> str:
|
|
129
|
+
"""Return the last :data:`STDERR_TAIL_CHARS` characters of ``stderr``, decoded leniently."""
|
|
130
|
+
text = stderr.decode("utf-8", errors="replace").strip()
|
|
131
|
+
return text[-STDERR_TAIL_CHARS:] if text else "(no stderr output)"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _require_image(output: Path, *, program: str) -> None:
|
|
135
|
+
"""Refuse a run that left no image, or an empty one, at ``output``."""
|
|
136
|
+
try:
|
|
137
|
+
size = output.stat().st_size
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
raise CameraError(f"camera program {program!r} wrote no image to {output}") from exc
|
|
140
|
+
if size == 0:
|
|
141
|
+
raise CameraError(f"camera program {program!r} wrote an empty image to {output}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = ["DEFAULT_EXTENSION", "STDERR_TAIL_CHARS", "capture_with_command"]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Take a photo by picking up the newest image a camera drops into a folder (``kind = "file"``).
|
|
2
|
+
|
|
3
|
+
Cameras and phone apps that upload into a watched folder are handled here: the newest
|
|
4
|
+
``.jpg``/``.jpeg``/``.png`` whose modification time is at or after the moment the capture began
|
|
5
|
+
(minus :data:`MTIME_GRACE_SECONDS`, for coarse file-system timestamps and clock skew of a
|
|
6
|
+
network share) is copied into the output directory. A file is taken only once its size has
|
|
7
|
+
been the same on two consecutive polls, so an upload still in progress is not collected half
|
|
8
|
+
written.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import stat
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
18
|
+
|
|
19
|
+
from ...domain.errors import CameraError
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
from ...domain.camera import CameraSettings
|
|
25
|
+
|
|
26
|
+
#: Image file suffixes picked up, and the media type each one is reported with.
|
|
27
|
+
MEDIA_TYPE_BY_SUFFIX: dict[str, str] = {
|
|
28
|
+
".jpg": "image/jpeg",
|
|
29
|
+
".jpeg": "image/jpeg",
|
|
30
|
+
".png": "image/png",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#: A file this many seconds older than the start of the capture still counts as new.
|
|
34
|
+
MTIME_GRACE_SECONDS = 1.0
|
|
35
|
+
|
|
36
|
+
#: Pause between two looks at the folder.
|
|
37
|
+
POLL_INTERVAL_SECONDS = 0.25
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Candidate(NamedTuple):
|
|
41
|
+
"""One image file seen in the folder, with the size it had when seen."""
|
|
42
|
+
|
|
43
|
+
path: Path
|
|
44
|
+
size: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def capture_from_directory(
|
|
48
|
+
settings: CameraSettings,
|
|
49
|
+
*,
|
|
50
|
+
stem: Path,
|
|
51
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
52
|
+
clock: Callable[[], float] = time.monotonic,
|
|
53
|
+
) -> Path:
|
|
54
|
+
"""Wait for a new image in ``settings.directory`` and copy it next to ``stem``.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
settings: The camera settings; ``directory`` and ``timeout`` are used.
|
|
58
|
+
stem: Output path without extension; the source file's (lower-cased) suffix is kept.
|
|
59
|
+
sleep: Pause between polls; injected so tests do not wait. Typed as a plain callable
|
|
60
|
+
because ``time.sleep`` takes its argument positional-only, which the
|
|
61
|
+
``ports.Sleep`` Protocol (keyword-capable ``seconds``) does not accept.
|
|
62
|
+
clock: Monotonic seconds for the deadline; injected so tests control time.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The path of the copy.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
CameraError: No directory is configured, it cannot be read, or no new complete image
|
|
69
|
+
appeared within ``settings.timeout`` seconds.
|
|
70
|
+
"""
|
|
71
|
+
directory = _directory(settings)
|
|
72
|
+
since = time.time() - MTIME_GRACE_SECONDS
|
|
73
|
+
deadline = clock() + settings.timeout
|
|
74
|
+
previous: _Candidate | None = None
|
|
75
|
+
while True:
|
|
76
|
+
current = _newest_image(directory, since=since)
|
|
77
|
+
if current is not None and current == previous:
|
|
78
|
+
return _copy(current.path, stem=stem)
|
|
79
|
+
if clock() >= deadline:
|
|
80
|
+
raise CameraError(f"no new complete image appeared in {directory} within {settings.timeout:g} s")
|
|
81
|
+
previous = current
|
|
82
|
+
sleep(POLL_INTERVAL_SECONDS)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _directory(settings: CameraSettings) -> Path:
|
|
86
|
+
"""Return the configured folder, refusing an empty setting or a path that is not a folder."""
|
|
87
|
+
if not settings.directory:
|
|
88
|
+
raise CameraError("no camera directory configured: set [camera] directory")
|
|
89
|
+
directory = Path(settings.directory)
|
|
90
|
+
if not directory.is_dir():
|
|
91
|
+
raise CameraError(f"camera directory {directory} does not exist or is not a directory")
|
|
92
|
+
return directory
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _newest_image(directory: Path, *, since: float) -> _Candidate | None:
|
|
96
|
+
"""Return the newest non-empty image modified at or after ``since``, or None."""
|
|
97
|
+
try:
|
|
98
|
+
entries = list(directory.iterdir())
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
raise CameraError(f"camera directory {directory} cannot be read: {exc.strerror or exc}") from exc
|
|
101
|
+
best: tuple[float, _Candidate] | None = None
|
|
102
|
+
for entry in entries:
|
|
103
|
+
seen = _image_stat(entry, since=since)
|
|
104
|
+
if seen is not None and (best is None or seen[0] > best[0]):
|
|
105
|
+
best = seen
|
|
106
|
+
return None if best is None else best[1]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _image_stat(entry: Path, *, since: float) -> tuple[float, _Candidate] | None:
|
|
110
|
+
"""Return ``(mtime, candidate)`` for a new, non-empty image file, else None."""
|
|
111
|
+
if entry.suffix.lower() not in MEDIA_TYPE_BY_SUFFIX:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
info = entry.stat()
|
|
115
|
+
except OSError:
|
|
116
|
+
# The camera software may rename or delete a file between the listing and the stat.
|
|
117
|
+
return None
|
|
118
|
+
if not stat.S_ISREG(info.st_mode) or info.st_mtime < since or info.st_size == 0:
|
|
119
|
+
return None
|
|
120
|
+
return info.st_mtime, _Candidate(path=entry, size=info.st_size)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _copy(source: Path, *, stem: Path) -> Path:
|
|
124
|
+
"""Copy ``source`` to ``stem`` with the source's lower-cased suffix."""
|
|
125
|
+
output = stem.with_suffix(source.suffix.lower())
|
|
126
|
+
try:
|
|
127
|
+
shutil.copyfile(source, output)
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise CameraError(f"camera image {source} could not be copied: {exc.strerror or exc}") from exc
|
|
130
|
+
return output
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = [
|
|
134
|
+
"MEDIA_TYPE_BY_SUFFIX",
|
|
135
|
+
"MTIME_GRACE_SECONDS",
|
|
136
|
+
"POLL_INTERVAL_SECONDS",
|
|
137
|
+
"capture_from_directory",
|
|
138
|
+
]
|