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.
Files changed (100) hide show
  1. datamax_dpl/__init__.py +96 -0
  2. datamax_dpl/__init__conf__.py +90 -0
  3. datamax_dpl/__main__.py +9 -0
  4. datamax_dpl/adapters/__init__.py +14 -0
  5. datamax_dpl/adapters/camera/__init__.py +117 -0
  6. datamax_dpl/adapters/camera/command.py +144 -0
  7. datamax_dpl/adapters/camera/file.py +138 -0
  8. datamax_dpl/adapters/camera/http.py +156 -0
  9. datamax_dpl/adapters/cli/__init__.py +56 -0
  10. datamax_dpl/adapters/cli/commands/__init__.py +52 -0
  11. datamax_dpl/adapters/cli/commands/config.py +529 -0
  12. datamax_dpl/adapters/cli/commands/info.py +38 -0
  13. datamax_dpl/adapters/cli/commands/labels.py +272 -0
  14. datamax_dpl/adapters/cli/commands/model_options.py +80 -0
  15. datamax_dpl/adapters/cli/commands/printer.py +264 -0
  16. datamax_dpl/adapters/cli/config_load.py +206 -0
  17. datamax_dpl/adapters/cli/constants.py +28 -0
  18. datamax_dpl/adapters/cli/context.py +184 -0
  19. datamax_dpl/adapters/cli/envelope.py +97 -0
  20. datamax_dpl/adapters/cli/exit_codes.py +59 -0
  21. datamax_dpl/adapters/cli/label_io.py +144 -0
  22. datamax_dpl/adapters/cli/main.py +123 -0
  23. datamax_dpl/adapters/cli/payloads.py +92 -0
  24. datamax_dpl/adapters/cli/py.typed +0 -0
  25. datamax_dpl/adapters/cli/root.py +181 -0
  26. datamax_dpl/adapters/cli/runner.py +90 -0
  27. datamax_dpl/adapters/cli/safe_console.py +315 -0
  28. datamax_dpl/adapters/cli/settings.py +142 -0
  29. datamax_dpl/adapters/cli/typed_click.py +77 -0
  30. datamax_dpl/adapters/config/__init__.py +25 -0
  31. datamax_dpl/adapters/config/defaultconfig.d/40-layered-config.toml +78 -0
  32. datamax_dpl/adapters/config/defaultconfig.d/50-printer.toml +86 -0
  33. datamax_dpl/adapters/config/defaultconfig.d/60-camera.toml +66 -0
  34. datamax_dpl/adapters/config/defaultconfig.d/90-logging.toml +448 -0
  35. datamax_dpl/adapters/config/defaultconfig.toml +56 -0
  36. datamax_dpl/adapters/config/deploy.py +135 -0
  37. datamax_dpl/adapters/config/display.py +60 -0
  38. datamax_dpl/adapters/config/loader.py +210 -0
  39. datamax_dpl/adapters/config/overrides.py +305 -0
  40. datamax_dpl/adapters/config/py.typed +0 -0
  41. datamax_dpl/adapters/logging/__init__.py +13 -0
  42. datamax_dpl/adapters/logging/py.typed +0 -0
  43. datamax_dpl/adapters/logging/setup.py +437 -0
  44. datamax_dpl/adapters/memory/__init__.py +54 -0
  45. datamax_dpl/adapters/memory/camera.py +109 -0
  46. datamax_dpl/adapters/memory/config.py +66 -0
  47. datamax_dpl/adapters/memory/logging.py +80 -0
  48. datamax_dpl/adapters/memory/printer.py +125 -0
  49. datamax_dpl/adapters/preview/__init__.py +112 -0
  50. datamax_dpl/adapters/py.typed +0 -0
  51. datamax_dpl/adapters/transport/__init__.py +114 -0
  52. datamax_dpl/adapters/transport/device.py +84 -0
  53. datamax_dpl/adapters/transport/file.py +101 -0
  54. datamax_dpl/adapters/transport/serial.py +241 -0
  55. datamax_dpl/adapters/transport/tcp.py +167 -0
  56. datamax_dpl/adapters/transport/uri.py +372 -0
  57. datamax_dpl/application/__init__.py +28 -0
  58. datamax_dpl/application/ports.py +155 -0
  59. datamax_dpl/application/py.typed +0 -0
  60. datamax_dpl/application/use_cases.py +217 -0
  61. datamax_dpl/application/verify.py +116 -0
  62. datamax_dpl/composition/__init__.py +146 -0
  63. datamax_dpl/composition/py.typed +0 -0
  64. datamax_dpl/domain/__init__.py +20 -0
  65. datamax_dpl/domain/barcodes.py +496 -0
  66. datamax_dpl/domain/batch.py +79 -0
  67. datamax_dpl/domain/camera.py +71 -0
  68. datamax_dpl/domain/charset.py +205 -0
  69. datamax_dpl/domain/decoder.py +370 -0
  70. datamax_dpl/domain/detect.py +107 -0
  71. datamax_dpl/domain/encode_barcode.py +145 -0
  72. datamax_dpl/domain/encode_common.py +86 -0
  73. datamax_dpl/domain/encode_graphics.py +140 -0
  74. datamax_dpl/domain/encode_text.py +137 -0
  75. datamax_dpl/domain/enums.py +219 -0
  76. datamax_dpl/domain/errors.py +66 -0
  77. datamax_dpl/domain/findings.py +89 -0
  78. datamax_dpl/domain/image_records.py +226 -0
  79. datamax_dpl/domain/job.py +261 -0
  80. datamax_dpl/domain/label.py +369 -0
  81. datamax_dpl/domain/placeholders.py +88 -0
  82. datamax_dpl/domain/png.py +123 -0
  83. datamax_dpl/domain/preview.py +248 -0
  84. datamax_dpl/domain/profiles.py +490 -0
  85. datamax_dpl/domain/py.typed +0 -0
  86. datamax_dpl/domain/raster.py +536 -0
  87. datamax_dpl/domain/raster_fallback.py +120 -0
  88. datamax_dpl/domain/records.py +196 -0
  89. datamax_dpl/domain/render.py +192 -0
  90. datamax_dpl/domain/render_context.py +131 -0
  91. datamax_dpl/domain/status.py +394 -0
  92. datamax_dpl/domain/symbology.py +125 -0
  93. datamax_dpl/domain/units.py +127 -0
  94. datamax_dpl/entry.py +26 -0
  95. datamax_dpl/py.typed +0 -0
  96. datamax_dpl-0.1.0.dist-info/METADATA +343 -0
  97. datamax_dpl-0.1.0.dist-info/RECORD +100 -0
  98. datamax_dpl-0.1.0.dist-info/WHEEL +4 -0
  99. datamax_dpl-0.1.0.dist-info/entry_points.txt +2 -0
  100. datamax_dpl-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -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")
@@ -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
+ ]