pyvisionauto 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.
@@ -0,0 +1,36 @@
1
+ from .config import DEFAULT_CONFIDENCE, DEFAULT_POLL_INTERVAL
2
+ from .envcheck import EnvCheck
3
+ from .errors import (
4
+ EnvironmentNotSupportedError,
5
+ OverlayError,
6
+ PyVisionAutoError,
7
+ RecorderError,
8
+ TemplateNotFoundError,
9
+ VanishTimeoutError,
10
+ WaitTimeoutError,
11
+ )
12
+ from .highlighter import Highlighter
13
+ from .input import Input
14
+ from .models import EnvironmentReport, Match, TimingProfile
15
+ from .recorder import Recorder
16
+ from .screen import Screen
17
+
18
+ __all__ = [
19
+ "DEFAULT_CONFIDENCE",
20
+ "DEFAULT_POLL_INTERVAL",
21
+ "EnvCheck",
22
+ "EnvironmentNotSupportedError",
23
+ "EnvironmentReport",
24
+ "Highlighter",
25
+ "Input",
26
+ "Match",
27
+ "OverlayError",
28
+ "PyVisionAutoError",
29
+ "Recorder",
30
+ "RecorderError",
31
+ "Screen",
32
+ "TemplateNotFoundError",
33
+ "TimingProfile",
34
+ "VanishTimeoutError",
35
+ "WaitTimeoutError",
36
+ ]
pyvisionauto/config.py ADDED
@@ -0,0 +1,23 @@
1
+ from .models import TimingProfile
2
+
3
+ DEFAULT_CONFIDENCE = 0.88
4
+ DEFAULT_POLL_INTERVAL = 0.5
5
+
6
+ HIGHLIGHT_ENABLED = True
7
+ HIGHLIGHT_COLOR = "#ff0000"
8
+ HIGHLIGHT_THICKNESS = 3
9
+ HIGHLIGHT_DURATION_MS = 700
10
+
11
+ DEFAULT_TIMING = TimingProfile(
12
+ typing_delay_min=0.04,
13
+ typing_delay_max=0.12,
14
+ special_char_extra_delay=0.03,
15
+ key_press_delay_min=0.01,
16
+ key_press_delay_max=0.04,
17
+ key_post_delay_min=0.03,
18
+ key_post_delay_max=0.08,
19
+ hotkey_gap_min=0.02,
20
+ hotkey_gap_max=0.06,
21
+ human_like_default=True,
22
+ deterministic_mode=False,
23
+ )
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+
8
+ from .errors import EnvironmentNotSupportedError
9
+ from .models import EnvironmentReport
10
+
11
+
12
+ class EnvCheck:
13
+ """Validate runtime prerequisites for PyVisionAuto on Linux desktops."""
14
+
15
+ def check(self, strict: bool = True) -> EnvironmentReport:
16
+ """Run platform and dependency checks.
17
+
18
+ Args:
19
+ strict: If ``True``, raise when environment is not supported.
20
+
21
+ Returns:
22
+ Structured environment report.
23
+
24
+ Raises:
25
+ EnvironmentNotSupportedError: If ``strict=True`` and checks fail.
26
+ """
27
+ is_linux = platform.system().lower() == "linux"
28
+ has_display = bool(os.environ.get("DISPLAY"))
29
+
30
+ x11_ok = False
31
+ if has_display:
32
+ try:
33
+ out = subprocess.run(
34
+ ["sh", "-lc", "echo ${XDG_SESSION_TYPE:-unknown}"],
35
+ capture_output=True,
36
+ text=True,
37
+ check=False,
38
+ )
39
+ x11_ok = out.stdout.strip().lower() in {"x11", "unknown"}
40
+ except Exception:
41
+ x11_ok = False
42
+
43
+ tk_ok = True
44
+ try:
45
+ import tkinter # noqa: F401
46
+ except Exception:
47
+ tk_ok = False
48
+
49
+ xdotool_ok = shutil.which("xdotool") is not None
50
+ wmctrl_ok = shutil.which("wmctrl") is not None
51
+ ibus_ok = shutil.which("ibus") is not None
52
+ ffmpeg_ok = shutil.which("ffmpeg") is not None
53
+
54
+ messages: list[str] = []
55
+ if not is_linux:
56
+ messages.append("Only Linux is supported in v0.1")
57
+ if not has_display:
58
+ messages.append("DISPLAY is missing")
59
+ if not x11_ok:
60
+ messages.append("X11 session not detected")
61
+ if not tk_ok:
62
+ messages.append("tkinter is unavailable (install python3-tk)")
63
+ if not (xdotool_ok or wmctrl_ok):
64
+ messages.append("Neither xdotool nor wmctrl is installed")
65
+
66
+ supported = is_linux and has_display and x11_ok and tk_ok and (xdotool_ok or wmctrl_ok)
67
+ report = EnvironmentReport(
68
+ is_supported=supported,
69
+ platform_ok=is_linux,
70
+ display_ok=has_display,
71
+ x11_ok=x11_ok,
72
+ tk_ok=tk_ok,
73
+ xdotool_ok=xdotool_ok,
74
+ wmctrl_ok=wmctrl_ok,
75
+ ibus_ok=ibus_ok,
76
+ ffmpeg_ok=ffmpeg_ok,
77
+ messages=messages,
78
+ )
79
+
80
+ if strict and not report.is_supported:
81
+ raise EnvironmentNotSupportedError("; ".join(report.messages) or "Unsupported environment")
82
+ return report
83
+
84
+
85
+ def check_env(strict: bool = True) -> EnvironmentReport:
86
+ """Convenience wrapper for :meth:`EnvCheck.check`."""
87
+ return EnvCheck().check(strict=strict)
pyvisionauto/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ class PyVisionAutoError(Exception):
2
+ """Base exception for PyVisionAuto."""
3
+
4
+
5
+ class TemplateNotFoundError(PyVisionAutoError):
6
+ """Raised when an image template file path does not exist."""
7
+
8
+
9
+ class WaitTimeoutError(PyVisionAutoError):
10
+ """Raised when waiting for a match times out."""
11
+
12
+
13
+ class VanishTimeoutError(PyVisionAutoError):
14
+ """Raised when waiting for an image to vanish times out."""
15
+
16
+
17
+ class EnvironmentNotSupportedError(PyVisionAutoError):
18
+ """Raised when runtime prerequisites are not met."""
19
+
20
+
21
+ class OverlayError(PyVisionAutoError):
22
+ """Raised when highlight overlay cannot be rendered."""
23
+
24
+
25
+ class RecorderError(PyVisionAutoError):
26
+ """Raised when recording actions fail."""
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .config import HIGHLIGHT_COLOR, HIGHLIGHT_DURATION_MS, HIGHLIGHT_THICKNESS
9
+ from .errors import OverlayError
10
+ from .models import Match
11
+
12
+ LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class Highlighter:
16
+ """Overlay helper used to draw temporary highlight borders for matches."""
17
+
18
+ def __init__(self) -> None:
19
+ """Initialize overlay script path."""
20
+ self._overlay_script = Path(__file__).with_name("overlay.py")
21
+
22
+ def show(
23
+ self,
24
+ match: Match,
25
+ duration_ms: int | None = None,
26
+ color: str | None = None,
27
+ thickness: int | None = None,
28
+ ) -> None:
29
+ """Spawn overlay process to highlight one match rectangle.
30
+
31
+ Args:
32
+ match: Match rectangle to visualize.
33
+ duration_ms: Overlay lifetime in milliseconds. ``None`` uses default.
34
+ color: Border color. ``None`` uses default.
35
+ thickness: Border width in pixels. ``None`` uses default.
36
+
37
+ Raises:
38
+ OverlayError: If the overlay process cannot be started.
39
+ """
40
+ resolved_duration = duration_ms if duration_ms is not None else HIGHLIGHT_DURATION_MS
41
+ resolved_color = color if color is not None else HIGHLIGHT_COLOR
42
+ resolved_thickness = thickness if thickness is not None else HIGHLIGHT_THICKNESS
43
+
44
+ try:
45
+ subprocess.Popen(
46
+ [
47
+ sys.executable,
48
+ str(self._overlay_script),
49
+ str(match.x),
50
+ str(match.y),
51
+ str(match.w),
52
+ str(match.h),
53
+ str(resolved_duration),
54
+ resolved_color,
55
+ str(resolved_thickness),
56
+ ],
57
+ stdout=subprocess.DEVNULL,
58
+ stderr=subprocess.DEVNULL,
59
+ )
60
+ except Exception as exc: # pragma: no cover
61
+ raise OverlayError(str(exc)) from exc
62
+
63
+ def safe_show(
64
+ self,
65
+ match: Match,
66
+ duration_ms: int | None = None,
67
+ color: str | None = None,
68
+ thickness: int | None = None,
69
+ ) -> None:
70
+ """Best-effort highlight wrapper that never interrupts user actions."""
71
+ try:
72
+ self.show(match, duration_ms=duration_ms, color=color, thickness=thickness)
73
+ except OverlayError as exc:
74
+ LOGGER.warning("Highlight failed but action continues: %s", exc)
pyvisionauto/input.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import random
5
+ import time
6
+
7
+ from .config import DEFAULT_TIMING
8
+ from .models import TimingProfile
9
+
10
+
11
+ class Input:
12
+ """Keyboard text/input helper with optional human-like timing behavior."""
13
+
14
+ def __init__(self, timing: TimingProfile | None = None, deterministic: bool | None = None) -> None:
15
+ """Create input helper.
16
+
17
+ Args:
18
+ timing: Timing profile. ``None`` uses ``DEFAULT_TIMING``.
19
+ deterministic: Override deterministic mode. ``None`` follows timing profile.
20
+ """
21
+ self.timing = timing or DEFAULT_TIMING
22
+ self.deterministic = self.timing.deterministic_mode if deterministic is None else deterministic
23
+
24
+ def _get_pyautogui(self):
25
+ """Lazily import pyautogui to reduce module import constraints."""
26
+ return importlib.import_module("pyautogui")
27
+
28
+ def _pause(self, low: float, high: float) -> None:
29
+ """Sleep for a delay interval, deterministic or random based on settings."""
30
+ if self.deterministic:
31
+ time.sleep((low + high) / 2.0)
32
+ return
33
+ time.sleep(random.uniform(low, high))
34
+
35
+ def type_text(
36
+ self,
37
+ text: str,
38
+ human_like: bool | None = None,
39
+ delay_min: float | None = None,
40
+ delay_max: float | None = None,
41
+ ) -> None:
42
+ """Type text into the active UI control.
43
+
44
+ Args:
45
+ text: Text content to type.
46
+ human_like: Whether to use per-character delays. ``None`` uses timing default.
47
+ delay_min: Minimum delay between characters in seconds.
48
+ delay_max: Maximum delay between characters in seconds.
49
+ """
50
+ pyautogui = self._get_pyautogui()
51
+ use_human = self.timing.human_like_default if human_like is None else human_like
52
+ if not use_human:
53
+ pyautogui.write(text)
54
+ return
55
+
56
+ low = self.timing.typing_delay_min if delay_min is None else delay_min
57
+ high = self.timing.typing_delay_max if delay_max is None else delay_max
58
+
59
+ for ch in text:
60
+ pyautogui.write(ch)
61
+ self._pause(low, high)
62
+ if ch in "/-_.":
63
+ time.sleep(self.timing.special_char_extra_delay)
64
+
65
+ def press(self, key: str, human_like: bool | None = None) -> None:
66
+ """Press one key.
67
+
68
+ Args:
69
+ key: Key name understood by pyautogui (for example ``"enter"``).
70
+ human_like: Whether to apply pre/post action delays.
71
+ """
72
+ pyautogui = self._get_pyautogui()
73
+ use_human = self.timing.human_like_default if human_like is None else human_like
74
+ if use_human:
75
+ self._pause(self.timing.key_press_delay_min, self.timing.key_press_delay_max)
76
+ pyautogui.press(key)
77
+ if use_human:
78
+ self._pause(self.timing.key_post_delay_min, self.timing.key_post_delay_max)
79
+
80
+ def hotkey(self, *keys: str, human_like: bool | None = None) -> None:
81
+ """Send a key combination.
82
+
83
+ Args:
84
+ *keys: Ordered key sequence, for example ``("ctrl", "v")``.
85
+ human_like: Whether to apply delay between key down/up events.
86
+ """
87
+ pyautogui = self._get_pyautogui()
88
+ use_human = self.timing.human_like_default if human_like is None else human_like
89
+ if not use_human:
90
+ pyautogui.hotkey(*keys)
91
+ return
92
+
93
+ for key in keys:
94
+ pyautogui.keyDown(key)
95
+ self._pause(self.timing.hotkey_gap_min, self.timing.hotkey_gap_max)
96
+ for key in reversed(keys):
97
+ pyautogui.keyUp(key)
98
+ self._pause(self.timing.hotkey_gap_min, self.timing.hotkey_gap_max)
99
+
100
+ def clear_text(self) -> None:
101
+ """Clear active input field using Ctrl+A followed by Backspace."""
102
+ self.hotkey("ctrl", "a")
103
+ self.press("backspace")
pyvisionauto/models.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Match:
8
+ """Rectangle and confidence returned by template matching.
9
+
10
+ Attributes:
11
+ x: Absolute screen X of the matched top-left corner.
12
+ y: Absolute screen Y of the matched top-left corner.
13
+ w: Matched width in pixels.
14
+ h: Matched height in pixels.
15
+ score: Normalized confidence score in [0.0, 1.0].
16
+ """
17
+
18
+ x: int
19
+ y: int
20
+ w: int
21
+ h: int
22
+ score: float
23
+
24
+ @property
25
+ def center(self) -> tuple[int, int]:
26
+ """Return center point ``(x + w // 2, y + h // 2)``."""
27
+ return (self.x + self.w // 2, self.y + self.h // 2)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TimingProfile:
32
+ """Timing configuration used by :class:`pyvisionauto.input.Input`."""
33
+
34
+ typing_delay_min: float
35
+ typing_delay_max: float
36
+ special_char_extra_delay: float
37
+ key_press_delay_min: float
38
+ key_press_delay_max: float
39
+ key_post_delay_min: float
40
+ key_post_delay_max: float
41
+ hotkey_gap_min: float
42
+ hotkey_gap_max: float
43
+ human_like_default: bool = True
44
+ deterministic_mode: bool = False
45
+
46
+
47
+ @dataclass
48
+ class EnvironmentReport:
49
+ """Result container for environment compatibility checks."""
50
+
51
+ is_supported: bool
52
+ platform_ok: bool
53
+ display_ok: bool
54
+ x11_ok: bool
55
+ tk_ok: bool
56
+ xdotool_ok: bool
57
+ wmctrl_ok: bool
58
+ ibus_ok: bool
59
+ ffmpeg_ok: bool
60
+ messages: list[str] = field(default_factory=list)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Region:
65
+ """Search/capture rectangle in absolute screen coordinates.
66
+
67
+ Attributes:
68
+ x: Absolute screen X of the region top-left corner.
69
+ y: Absolute screen Y of the region top-left corner.
70
+ w: Region width in pixels.
71
+ h: Region height in pixels.
72
+ """
73
+
74
+ x: int
75
+ y: int
76
+ w: int
77
+ h: int
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import tkinter as tk
5
+
6
+
7
+ def main() -> int:
8
+ if len(sys.argv) != 8:
9
+ return 2
10
+
11
+ x = int(sys.argv[1])
12
+ y = int(sys.argv[2])
13
+ w = int(sys.argv[3])
14
+ h = int(sys.argv[4])
15
+ duration_ms = int(sys.argv[5])
16
+ color = sys.argv[6]
17
+ thickness = int(sys.argv[7])
18
+
19
+ # Keep center visually transparent by drawing border only on a canvas.
20
+ root = tk.Tk()
21
+ root.overrideredirect(True)
22
+ root.attributes("-topmost", True)
23
+ root.geometry(f"{w}x{h}+{x}+{y}")
24
+
25
+ bg = "black"
26
+ root.configure(bg=bg)
27
+ try:
28
+ root.wm_attributes("-transparentcolor", bg)
29
+ except tk.TclError:
30
+ # Some X11 window managers do not support transparentcolor.
31
+ root.attributes("-alpha", 0.35)
32
+
33
+ canvas = tk.Canvas(root, width=w, height=h, highlightthickness=0, bg=bg)
34
+ canvas.pack(fill="both", expand=True)
35
+ inset = max(1, thickness // 2)
36
+ canvas.create_rectangle(
37
+ inset,
38
+ inset,
39
+ max(inset + 1, w - inset),
40
+ max(inset + 1, h - inset),
41
+ outline=color,
42
+ width=thickness,
43
+ )
44
+
45
+ root.after(duration_ms, root.destroy)
46
+ root.mainloop()
47
+ return 0
48
+
49
+
50
+ if __name__ == "__main__":
51
+ raise SystemExit(main())
pyvisionauto/py.typed ADDED
File without changes
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from .errors import RecorderError
9
+
10
+
11
+ class Recorder:
12
+ """Simple ffmpeg-based screen recorder for X11 sessions."""
13
+
14
+ def __init__(self, display: str | None = None) -> None:
15
+ """Create recorder.
16
+
17
+ Args:
18
+ display: X11 display string (for example ``":0"``). ``None`` reads ``DISPLAY``.
19
+ """
20
+ self.display = display or os.environ.get("DISPLAY", ":0")
21
+ self._proc: subprocess.Popen[bytes] | None = None
22
+
23
+ def start(self, output_file: str | Path, fps: int = 15) -> None:
24
+ """Start recording the desktop.
25
+
26
+ Args:
27
+ output_file: Video file output path.
28
+ fps: Capture frame rate.
29
+
30
+ Raises:
31
+ RecorderError: If ffmpeg is missing or recorder is already running.
32
+ """
33
+ if shutil.which("ffmpeg") is None:
34
+ raise RecorderError("ffmpeg is required for recording APIs")
35
+ if self._proc is not None and self._proc.poll() is None:
36
+ raise RecorderError("Recorder is already running")
37
+
38
+ out = str(output_file)
39
+ cmd = [
40
+ "ffmpeg",
41
+ "-y",
42
+ "-video_size",
43
+ "1920x1080",
44
+ "-framerate",
45
+ str(fps),
46
+ "-f",
47
+ "x11grab",
48
+ "-i",
49
+ self.display,
50
+ out,
51
+ ]
52
+ self._proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
53
+
54
+ def stop(self) -> None:
55
+ """Stop recording if active. Safe to call repeatedly."""
56
+ if self._proc is None:
57
+ return
58
+ if self._proc.poll() is None:
59
+ self._proc.terminate()
60
+ self._proc.wait(timeout=3)
61
+ self._proc = None
pyvisionauto/screen.py ADDED
@@ -0,0 +1,457 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import logging
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .config import HIGHLIGHT_ENABLED
11
+ from .envcheck import check_env
12
+ from .highlighter import Highlighter
13
+ from .input import Input
14
+ from .models import Match, Region
15
+
16
+ if TYPE_CHECKING:
17
+ from .vision import Vision
18
+
19
+ LOGGER = logging.getLogger(__name__)
20
+
21
+
22
+ def _get_pyautogui():
23
+ """Lazily import pyautogui to keep module import cross-platform friendly."""
24
+ return importlib.import_module("pyautogui")
25
+
26
+
27
+ @dataclass
28
+ class MatchHandle:
29
+ """Chainable handle around a resolved image match.
30
+
31
+ A handle stores the matched rectangle and allows fluent follow-up actions
32
+ such as highlight, click, hover, and waiting for disappearance.
33
+ """
34
+
35
+ screen: "Screen"
36
+ image: str
37
+ match: Match
38
+ region: Region | None = None
39
+
40
+ @property
41
+ def center(self) -> tuple[int, int]:
42
+ """Return the match center in absolute screen coordinates."""
43
+ return self.match.center
44
+
45
+ @property
46
+ def score(self) -> float:
47
+ """Return normalized match confidence in the range [0.0, 1.0]."""
48
+ return self.match.score
49
+
50
+ def highlight(
51
+ self,
52
+ duration_ms: int | None = None,
53
+ color: str | None = None,
54
+ thickness: int | None = None,
55
+ ) -> "MatchHandle":
56
+ """Render a temporary highlight overlay around the current match.
57
+
58
+ Args:
59
+ duration_ms: Overlay lifetime in milliseconds. ``None`` uses config default.
60
+ color: Border color string (for example ``"#ff0000"``). ``None`` uses default.
61
+ thickness: Border thickness in pixels. ``None`` uses default.
62
+
63
+ Returns:
64
+ The current handle for method chaining.
65
+ """
66
+ self.screen._highlight_match(self.match, duration_ms=duration_ms, color=color, thickness=thickness)
67
+ return self
68
+
69
+ def click(self, button: str = "left", highlight: bool = True) -> "MatchHandle":
70
+ """Click at the current match center.
71
+
72
+ Args:
73
+ button: Mouse button name accepted by pyautogui (``"left"``, ``"right"``, etc.).
74
+ highlight: Whether to show highlight overlay before clicking.
75
+
76
+ Returns:
77
+ The current handle for method chaining.
78
+ """
79
+ if highlight:
80
+ self.highlight()
81
+ x, y = self.match.center
82
+ pyautogui = _get_pyautogui()
83
+ pyautogui.click(x=x, y=y, button=button)
84
+ return self
85
+
86
+ def double_click(self, highlight: bool = True) -> "MatchHandle":
87
+ """Double-click at the current match center.
88
+
89
+ Args:
90
+ highlight: Whether to show highlight overlay before the action.
91
+
92
+ Returns:
93
+ The current handle for method chaining.
94
+ """
95
+ if highlight:
96
+ self.highlight()
97
+ x, y = self.match.center
98
+ pyautogui = _get_pyautogui()
99
+ pyautogui.doubleClick(x=x, y=y)
100
+ return self
101
+
102
+ def right_click(self, highlight: bool = True) -> "MatchHandle":
103
+ """Right-click at the current match center.
104
+
105
+ Args:
106
+ highlight: Whether to show highlight overlay before the action.
107
+
108
+ Returns:
109
+ The current handle for method chaining.
110
+ """
111
+ return self.click(button="right", highlight=highlight)
112
+
113
+ def hover(self, highlight: bool = True) -> "MatchHandle":
114
+ """Move the pointer to the current match center.
115
+
116
+ Args:
117
+ highlight: Whether to show highlight overlay before pointer move.
118
+
119
+ Returns:
120
+ The current handle for method chaining.
121
+ """
122
+ if highlight:
123
+ self.highlight()
124
+ x, y = self.match.center
125
+ pyautogui = _get_pyautogui()
126
+ pyautogui.moveTo(x, y)
127
+ return self
128
+
129
+ def wait_vanish(
130
+ self,
131
+ timeout: float,
132
+ confidence: float | None = None,
133
+ poll: float | None = None,
134
+ strict: bool = True,
135
+ ) -> bool:
136
+ """Wait until this handle's image is no longer detected.
137
+
138
+ Args:
139
+ timeout: Max wait duration in seconds.
140
+ confidence: Match threshold. ``None`` uses configured default.
141
+ poll: Poll interval in seconds. ``None`` uses configured default.
142
+ strict: If ``True``, raise on timeout. If ``False``, return ``False``.
143
+
144
+ Returns:
145
+ ``True`` if vanished before timeout; ``False`` only when ``strict=False``.
146
+ """
147
+ return self.screen.wait_vanish(
148
+ self.image,
149
+ timeout=timeout,
150
+ confidence=confidence,
151
+ poll=poll,
152
+ region=self.region,
153
+ strict=strict,
154
+ )
155
+
156
+
157
+ class RegionScreen:
158
+ """A Screen proxy that automatically scopes all operations to one region."""
159
+
160
+ def __init__(self, screen: "Screen", region: Region) -> None:
161
+ self._screen = screen
162
+ self._region = region
163
+
164
+ def find(self, image: str | Path, confidence: float | None = None) -> MatchHandle | None:
165
+ """Find one image inside this pre-scoped region."""
166
+ return self._screen.find(image=image, confidence=confidence, region=self._region)
167
+
168
+ def wait(
169
+ self,
170
+ image: str | Path,
171
+ timeout: float,
172
+ confidence: float | None = None,
173
+ poll: float | None = None,
174
+ ) -> MatchHandle:
175
+ """Wait for an image to appear inside this pre-scoped region."""
176
+ return self._screen.wait(
177
+ image=image,
178
+ timeout=timeout,
179
+ confidence=confidence,
180
+ poll=poll,
181
+ region=self._region,
182
+ )
183
+
184
+ def click(
185
+ self,
186
+ image: str | Path,
187
+ timeout: float = 10,
188
+ confidence: float | None = None,
189
+ poll: float | None = None,
190
+ highlight: bool = True,
191
+ ) -> MatchHandle:
192
+ """Wait for and click an image inside this pre-scoped region."""
193
+ return self._screen.click(
194
+ image=image,
195
+ timeout=timeout,
196
+ confidence=confidence,
197
+ poll=poll,
198
+ highlight=highlight,
199
+ region=self._region,
200
+ )
201
+
202
+ def wait_vanish(
203
+ self,
204
+ image: str | Path,
205
+ timeout: float,
206
+ confidence: float | None = None,
207
+ poll: float | None = None,
208
+ strict: bool = True,
209
+ ) -> bool:
210
+ """Wait for an image to disappear inside this pre-scoped region."""
211
+ return self._screen.wait_vanish(
212
+ image=image,
213
+ timeout=timeout,
214
+ confidence=confidence,
215
+ poll=poll,
216
+ region=self._region,
217
+ strict=strict,
218
+ )
219
+
220
+
221
+ class Screen:
222
+ """High-level image-driven automation API for Linux X11 desktops."""
223
+
224
+ def __init__(self) -> None:
225
+ from .vision import Vision
226
+
227
+ self.vision = Vision()
228
+ self.highlighter = Highlighter()
229
+ self.input = Input()
230
+
231
+ def region(self, x: int, y: int, w: int, h: int) -> RegionScreen:
232
+ """Create a region-scoped helper.
233
+
234
+ Args:
235
+ x: Absolute screen X coordinate of the region's top-left corner.
236
+ y: Absolute screen Y coordinate of the region's top-left corner.
237
+ w: Region width in pixels.
238
+ h: Region height in pixels.
239
+
240
+ Returns:
241
+ A ``RegionScreen`` instance that limits find/wait/click operations
242
+ to ``Region(x, y, w, h)``.
243
+ """
244
+ return RegionScreen(self, Region(x, y, w, h))
245
+
246
+ def _highlight_match(
247
+ self,
248
+ match: Match,
249
+ duration_ms: int | None = None,
250
+ color: str | None = None,
251
+ thickness: int | None = None,
252
+ ) -> None:
253
+ """Display a temporary highlight for one match rectangle."""
254
+ self.highlighter.safe_show(match, duration_ms=duration_ms, color=color, thickness=thickness)
255
+
256
+ @staticmethod
257
+ def _normalize_region(region: Region | tuple[int, int, int, int] | None) -> Region | None:
258
+ """Convert tuple region values to ``Region`` while preserving ``None``."""
259
+ if region is None:
260
+ return None
261
+ if isinstance(region, Region):
262
+ return region
263
+ x, y, w, h = region
264
+ return Region(int(x), int(y), int(w), int(h))
265
+
266
+ def find(
267
+ self,
268
+ image: str | Path,
269
+ confidence: float | None = None,
270
+ region: Region | tuple[int, int, int, int] | None = None,
271
+ highlight: bool = HIGHLIGHT_ENABLED,
272
+ ) -> MatchHandle | None:
273
+ """Find an image once and return a chainable handle when matched.
274
+
275
+ Args:
276
+ image: Template image path.
277
+ confidence: Match threshold. ``None`` uses configured default.
278
+ region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
279
+ highlight: Whether to show highlight overlay when matched.
280
+
281
+ Returns:
282
+ ``MatchHandle`` when found, otherwise ``None``.
283
+ """
284
+ normalized_region = self._normalize_region(region)
285
+ match = self.vision.find(image=image, confidence=confidence, region=region)
286
+ if match is None:
287
+ return None
288
+ if highlight:
289
+ self._highlight_match(match)
290
+ return MatchHandle(screen=self, image=str(image), match=match, region=normalized_region)
291
+
292
+ def wait(
293
+ self,
294
+ image: str | Path,
295
+ timeout: float,
296
+ confidence: float | None = None,
297
+ poll: float | None = None,
298
+ region: Region | tuple[int, int, int, int] | None = None,
299
+ highlight: bool = HIGHLIGHT_ENABLED,
300
+ ) -> MatchHandle:
301
+ """Wait until an image appears and return a chainable handle.
302
+
303
+ Args:
304
+ image: Template image path.
305
+ timeout: Max wait duration in seconds.
306
+ confidence: Match threshold. ``None`` uses configured default.
307
+ poll: Poll interval in seconds. ``None`` uses configured default.
308
+ region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
309
+ highlight: Whether to show highlight overlay on success.
310
+
311
+ Returns:
312
+ A ``MatchHandle`` for fluent actions.
313
+ """
314
+ normalized_region = self._normalize_region(region)
315
+ match = self.vision.wait(image=image, timeout=timeout, confidence=confidence, poll=poll, region=region)
316
+ if highlight:
317
+ self._highlight_match(match)
318
+ return MatchHandle(screen=self, image=str(image), match=match, region=normalized_region)
319
+
320
+ def wait_vanish(
321
+ self,
322
+ image: str | Path,
323
+ timeout: float,
324
+ confidence: float | None = None,
325
+ poll: float | None = None,
326
+ region: Region | tuple[int, int, int, int] | None = None,
327
+ strict: bool = True,
328
+ ) -> bool:
329
+ """Wait until an image disappears from the screen or region.
330
+
331
+ Args:
332
+ image: Template image path.
333
+ timeout: Max wait duration in seconds.
334
+ confidence: Match threshold. ``None`` uses configured default.
335
+ poll: Poll interval in seconds. ``None`` uses configured default.
336
+ region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
337
+ strict: If ``True``, raise on timeout. If ``False``, return ``False``.
338
+
339
+ Returns:
340
+ ``True`` if image vanished before timeout.
341
+ """
342
+ return self.vision.wait_vanish(
343
+ image=image,
344
+ timeout=timeout,
345
+ confidence=confidence,
346
+ poll=poll,
347
+ region=region,
348
+ strict=strict,
349
+ )
350
+
351
+ def click(
352
+ self,
353
+ image: str | Path,
354
+ timeout: float = 10,
355
+ confidence: float | None = None,
356
+ poll: float | None = None,
357
+ highlight: bool = HIGHLIGHT_ENABLED,
358
+ region: Region | tuple[int, int, int, int] | None = None,
359
+ ) -> MatchHandle:
360
+ """Wait for an image and click its center.
361
+
362
+ Args:
363
+ image: Template image path.
364
+ timeout: Max wait duration in seconds.
365
+ confidence: Match threshold. ``None`` uses configured default.
366
+ poll: Poll interval in seconds. ``None`` uses configured default.
367
+ highlight: Whether to show highlight overlay before clicking.
368
+ region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
369
+
370
+ Returns:
371
+ A ``MatchHandle`` representing the clicked match.
372
+ """
373
+ handle = self.wait(
374
+ image=image,
375
+ timeout=timeout,
376
+ confidence=confidence,
377
+ poll=poll,
378
+ region=region,
379
+ highlight=False,
380
+ )
381
+ return handle.click(highlight=highlight)
382
+
383
+ def click_and_wait_vanish(
384
+ self,
385
+ click_image: str | Path,
386
+ vanish_image: str | Path | None = None,
387
+ timeout: float = 10,
388
+ confidence: float | None = None,
389
+ poll: float | None = None,
390
+ highlight: bool = HIGHLIGHT_ENABLED,
391
+ region: Region | tuple[int, int, int, int] | None = None,
392
+ strict: bool = True,
393
+ ) -> bool:
394
+ """Click one image and wait for an image to disappear.
395
+
396
+ Args:
397
+ click_image: Image used for click targeting.
398
+ vanish_image: Image to monitor for disappearance. ``None`` uses ``click_image``.
399
+ timeout: Max wait duration in seconds.
400
+ confidence: Match threshold. ``None`` uses configured default.
401
+ poll: Poll interval in seconds. ``None`` uses configured default.
402
+ highlight: Whether to highlight before click.
403
+ region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
404
+ strict: If ``True``, raise on timeout. If ``False``, return ``False``.
405
+
406
+ Returns:
407
+ ``True`` if the vanish target disappears before timeout.
408
+ """
409
+ self.click(
410
+ image=click_image,
411
+ timeout=timeout,
412
+ confidence=confidence,
413
+ poll=poll,
414
+ highlight=highlight,
415
+ region=region,
416
+ )
417
+ target = vanish_image if vanish_image is not None else click_image
418
+ return self.wait_vanish(
419
+ image=target,
420
+ timeout=timeout,
421
+ confidence=confidence,
422
+ poll=poll,
423
+ region=region,
424
+ strict=strict,
425
+ )
426
+
427
+ def activate_window(self, title_substring: str) -> bool:
428
+ """Attempt to activate a window by title fragment.
429
+
430
+ Tries ``xdotool`` first and falls back to ``wmctrl``.
431
+
432
+ Args:
433
+ title_substring: Case-sensitive fragment used by window manager tools.
434
+
435
+ Returns:
436
+ ``True`` when activation command succeeds, otherwise ``False``.
437
+ """
438
+ xdotool_cmd = ["xdotool", "search", "--name", title_substring, "windowactivate"]
439
+ wmctrl_cmd = ["wmctrl", "-a", title_substring]
440
+
441
+ try:
442
+ proc = subprocess.run(xdotool_cmd, capture_output=True, check=False)
443
+ if proc.returncode == 0:
444
+ return True
445
+ except FileNotFoundError:
446
+ LOGGER.debug("xdotool not available")
447
+
448
+ try:
449
+ proc = subprocess.run(wmctrl_cmd, capture_output=True, check=False)
450
+ return proc.returncode == 0
451
+ except FileNotFoundError:
452
+ LOGGER.debug("wmctrl not available")
453
+ return False
454
+
455
+ def check_env(self, strict: bool = True):
456
+ """Run environment checks for platform, display, and required tools."""
457
+ return check_env(strict=strict)
pyvisionauto/vision.py ADDED
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ import cv2
7
+ import mss
8
+ import numpy as np
9
+
10
+ from .config import DEFAULT_CONFIDENCE, DEFAULT_POLL_INTERVAL
11
+ from .errors import TemplateNotFoundError, VanishTimeoutError, WaitTimeoutError
12
+ from .models import Match, Region
13
+
14
+
15
+ def _as_region(region: Region | tuple[int, int, int, int] | None) -> Region | None:
16
+ """Normalize region input into a ``Region`` instance.
17
+
18
+ Accepts either an existing ``Region`` object or a tuple ``(x, y, w, h)``.
19
+ """
20
+ if region is None:
21
+ return None
22
+ if isinstance(region, Region):
23
+ return region
24
+ x, y, w, h = region
25
+ return Region(int(x), int(y), int(w), int(h))
26
+
27
+
28
+ class Vision:
29
+ """Low-level OpenCV template matching and polling engine."""
30
+
31
+ def __init__(self) -> None:
32
+ """Initialize screen capture backend."""
33
+ self._mss = mss.mss()
34
+
35
+ def _load_template(self, image: str | Path) -> np.ndarray:
36
+ """Load a template image from disk.
37
+
38
+ Args:
39
+ image: Template image path.
40
+
41
+ Returns:
42
+ BGR image array.
43
+
44
+ Raises:
45
+ TemplateNotFoundError: If the file does not exist or cannot be decoded.
46
+ """
47
+ image_path = Path(image)
48
+ if not image_path.exists():
49
+ raise TemplateNotFoundError(f"Template not found: {image_path}")
50
+ template = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
51
+ if template is None:
52
+ raise TemplateNotFoundError(f"Unable to load template: {image_path}")
53
+ return template
54
+
55
+ def _capture_screen(self, region: Region | None = None) -> np.ndarray:
56
+ """Capture the full screen or a specific region.
57
+
58
+ Args:
59
+ region: Optional capture region. ``None`` captures primary monitor.
60
+
61
+ Returns:
62
+ Captured BGR frame as a numpy array.
63
+ """
64
+ if region is None:
65
+ monitor = self._mss.monitors[1]
66
+ frame = self._mss.grab(monitor)
67
+ return np.array(frame)[:, :, :3]
68
+
69
+ monitor = {
70
+ "left": region.x,
71
+ "top": region.y,
72
+ "width": region.w,
73
+ "height": region.h,
74
+ }
75
+ frame = self._mss.grab(monitor)
76
+ return np.array(frame)[:, :, :3]
77
+
78
+ def find(
79
+ self,
80
+ image: str | Path,
81
+ confidence: float | None = None,
82
+ region: Region | tuple[int, int, int, int] | None = None,
83
+ ) -> Match | None:
84
+ """Run one template match operation.
85
+
86
+ Args:
87
+ image: Template image path.
88
+ confidence: Match threshold in [0.0, 1.0]. ``None`` uses configured default.
89
+ region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
90
+
91
+ Returns:
92
+ ``Match`` on success, otherwise ``None``.
93
+ """
94
+ resolved_region = _as_region(region)
95
+ threshold = confidence if confidence is not None else DEFAULT_CONFIDENCE
96
+ template = self._load_template(image)
97
+ screen = self._capture_screen(resolved_region)
98
+
99
+ result = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED)
100
+ _, max_val, _, max_loc = cv2.minMaxLoc(result)
101
+
102
+ if max_val < threshold:
103
+ return None
104
+
105
+ h, w = template.shape[:2]
106
+ abs_x = max_loc[0] + (resolved_region.x if resolved_region else 0)
107
+ abs_y = max_loc[1] + (resolved_region.y if resolved_region else 0)
108
+ return Match(x=abs_x, y=abs_y, w=w, h=h, score=float(max_val))
109
+
110
+ def wait(
111
+ self,
112
+ image: str | Path,
113
+ timeout: float,
114
+ confidence: float | None = None,
115
+ poll: float | None = None,
116
+ region: Region | tuple[int, int, int, int] | None = None,
117
+ ) -> Match:
118
+ """Poll until an image appears or timeout occurs.
119
+
120
+ Args:
121
+ image: Template image path.
122
+ timeout: Max wait duration in seconds.
123
+ confidence: Match threshold in [0.0, 1.0]. ``None`` uses configured default.
124
+ poll: Poll interval in seconds. ``None`` uses configured default.
125
+ region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
126
+
127
+ Returns:
128
+ The first successful ``Match``.
129
+
130
+ Raises:
131
+ WaitTimeoutError: If timeout elapses before any match reaches threshold.
132
+ """
133
+ interval = poll if poll is not None else DEFAULT_POLL_INTERVAL
134
+ deadline = time.time() + timeout
135
+ last_score = 0.0
136
+ resolved_region = _as_region(region)
137
+ threshold = confidence if confidence is not None else DEFAULT_CONFIDENCE
138
+ template = self._load_template(image)
139
+ h, w = template.shape[:2]
140
+
141
+ while time.time() < deadline:
142
+ screen = self._capture_screen(resolved_region)
143
+ result = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED)
144
+ _, max_val, _, max_loc = cv2.minMaxLoc(result)
145
+ score = float(max_val)
146
+ last_score = max(last_score, score)
147
+ if score >= threshold:
148
+ abs_x = max_loc[0] + (resolved_region.x if resolved_region else 0)
149
+ abs_y = max_loc[1] + (resolved_region.y if resolved_region else 0)
150
+ return Match(x=abs_x, y=abs_y, w=w, h=h, score=score)
151
+ time.sleep(interval)
152
+
153
+ raise WaitTimeoutError(
154
+ f"wait timeout image={image} timeout={timeout}s confidence={threshold} last_score={last_score:.4f}"
155
+ )
156
+
157
+ def wait_vanish(
158
+ self,
159
+ image: str | Path,
160
+ timeout: float,
161
+ confidence: float | None = None,
162
+ poll: float | None = None,
163
+ region: Region | tuple[int, int, int, int] | None = None,
164
+ strict: bool = True,
165
+ ) -> bool:
166
+ """Poll until an image can no longer be matched.
167
+
168
+ Args:
169
+ image: Template image path.
170
+ timeout: Max wait duration in seconds.
171
+ confidence: Match threshold in [0.0, 1.0]. ``None`` uses configured default.
172
+ poll: Poll interval in seconds. ``None`` uses configured default.
173
+ region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
174
+ strict: If ``True``, raise on timeout. If ``False``, return ``False``.
175
+
176
+ Returns:
177
+ ``True`` if image vanishes before timeout; otherwise ``False`` when ``strict=False``.
178
+
179
+ Raises:
180
+ VanishTimeoutError: If timeout occurs while ``strict=True``.
181
+ """
182
+ interval = poll if poll is not None else DEFAULT_POLL_INTERVAL
183
+ deadline = time.time() + timeout
184
+
185
+ while time.time() < deadline:
186
+ match = self.find(image=image, confidence=confidence, region=region)
187
+ if match is None:
188
+ return True
189
+ time.sleep(interval)
190
+
191
+ if strict:
192
+ threshold = confidence if confidence is not None else DEFAULT_CONFIDENCE
193
+ raise VanishTimeoutError(
194
+ f"wait_vanish timeout image={image} timeout={timeout}s confidence={threshold}"
195
+ )
196
+ return False
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyvisionauto
3
+ Version: 0.1.0
4
+ Summary: PyVisionAuto: Linux end-to-end automation toolkit with visual image matching, mouse/keyboard control, and screen recording
5
+ Author: PyVisionAuto contributors
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://pypi.org/project/pyvisionauto/
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Environment :: X11 Applications
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: opencv-python>=4.8.0
21
+ Requires-Dist: mss>=9.0.0
22
+ Requires-Dist: numpy>=1.24.0
23
+ Requires-Dist: pyautogui>=0.9.54
24
+ Requires-Dist: pillow>=10.0.0
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8.0; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # PyVisionAuto
30
+
31
+ PyVisionAuto (`pyvisionauto`) is a Linux end-to-end automation testing toolkit.
32
+ It is centered on visual image matching and also includes screen recording, mouse automation, and keyboard automation capabilities.
33
+
34
+ ## Scope
35
+
36
+ - Linux only
37
+ - X11 session only
38
+ - Real physical display required
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install pyvisionauto
44
+ ```
45
+
46
+ ## System dependencies
47
+
48
+ - python3-tk (for border overlay highlight)
49
+ - xdotool (preferred for window activation)
50
+ - wmctrl (fallback for window activation)
51
+ - ffmpeg (optional, only for recording APIs)
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ from pyvisionauto import Screen
57
+
58
+ screen = Screen()
59
+ screen.wait("login_button.png", timeout=10).highlight().click()
60
+ ```
61
+
62
+ ## Notes
63
+
64
+ Wayland-first and headless-only environments are not supported in v0.1.
@@ -0,0 +1,17 @@
1
+ pyvisionauto/__init__.py,sha256=n8d_57ZPt7_L7QvZY2MyOXu_K2wuY36ZyRP4a55NuBk,888
2
+ pyvisionauto/config.py,sha256=0m8FW_LFz3Jm5iUn7qPbbAamPaW00KtSTMgyOdtXjBo,569
3
+ pyvisionauto/envcheck.py,sha256=Ewa9ldTb1GSd5MyOdoay4SFe5f7IyY9qtgm8fL7ibIA,2907
4
+ pyvisionauto/errors.py,sha256=yu1dE2CTNM3xi0Ss9BIIGSfLsQ0Tb1W2l4M1I_UtY_4,733
5
+ pyvisionauto/highlighter.py,sha256=0DvB2_zJaArKeE3C1TcxxFC7PdEfkMUocX6Qv6I8kfc,2581
6
+ pyvisionauto/input.py,sha256=J4sqtTfSBTZPQRanLKJsXvk1w8oDJaby6F4jjhBw2qc,3954
7
+ pyvisionauto/models.py,sha256=482REXLPKHahnx37pq8L7JiqchXDWDjeDVno8JHhVOA,1938
8
+ pyvisionauto/overlay.py,sha256=cHF8sQNH89B0FLh-kpvPJE5UJTRXpQlj4trxL45jVp0,1289
9
+ pyvisionauto/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pyvisionauto/recorder.py,sha256=AaP8Nc7dEm-WCpf0PtzpTYXe8g8HRkB-TqxTxxsNtOw,1856
11
+ pyvisionauto/screen.py,sha256=qFzvjp0skwczU7pFjBiiOHFS9Ffc_-vFPaff4SVPJzU,15861
12
+ pyvisionauto/vision.py,sha256=HjQ7tF5eFbWQcjxCqTvLWMcn2LDt2iWchyFzBSyZ_IY,7177
13
+ pyvisionauto-0.1.0.dist-info/licenses/LICENSE,sha256=ipwM6eUlm4Jvp5a_ixwEJgkko112aJnPjd2rMLmxUsM,411
14
+ pyvisionauto-0.1.0.dist-info/METADATA,sha256=wiz2VCVJgb2UZEW8ueYwFt8MOGSIijBQeidk8wlHobI,1935
15
+ pyvisionauto-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ pyvisionauto-0.1.0.dist-info/top_level.txt,sha256=3obi1phVrm33prkM9B3t0STWnyvkPF0RcBoUvVJhOH8,13
17
+ pyvisionauto-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2026 PyVisionAuto contributors. All rights reserved.
2
+
3
+ This software and its source code are proprietary and confidential.
4
+ Redistribution, modification, or use of the source code in any form is
5
+ prohibited without explicit written permission from the copyright holder.
6
+
7
+ The compiled/distributed package may be used in accordance with the terms
8
+ provided at: https://pypi.org/project/pyvisionauto/
@@ -0,0 +1 @@
1
+ pyvisionauto