watcherobot 0.1.1__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.
- watcherobot/__init__.py +83 -0
- watcherobot/_internal/__init__.py +1 -0
- watcherobot/_internal/audio_status.py +40 -0
- watcherobot/application/__init__.py +30 -0
- watcherobot/application/channels.py +35 -0
- watcherobot/application/context.py +99 -0
- watcherobot/application/desktop.py +26 -0
- watcherobot/application/ignore.py +54 -0
- watcherobot/application/project.py +336 -0
- watcherobot/application/rtc.py +402 -0
- watcherobot/application/transport.py +533 -0
- watcherobot/audio.py +81 -0
- watcherobot/cli.py +1186 -0
- watcherobot/distribution/__init__.py +154 -0
- watcherobot/distribution/catalog_submission.py +176 -0
- watcherobot/distribution/check.py +66 -0
- watcherobot/distribution/cli.py +1077 -0
- watcherobot/distribution/credentials.py +85 -0
- watcherobot/distribution/download.py +242 -0
- watcherobot/distribution/events.py +172 -0
- watcherobot/distribution/hf_marketplace.py +286 -0
- watcherobot/distribution/hf_publish.py +351 -0
- watcherobot/distribution/hub_http.py +128 -0
- watcherobot/distribution/install.py +1010 -0
- watcherobot/distribution/login.py +318 -0
- watcherobot/distribution/marketplace.py +228 -0
- watcherobot/distribution/oauth_http.py +229 -0
- watcherobot/distribution/ports.py +327 -0
- watcherobot/distribution/publish.py +205 -0
- watcherobot/distribution/publish_files.py +140 -0
- watcherobot/distribution/source_files.py +107 -0
- watcherobot/distribution/submit.py +398 -0
- watcherobot/errors.py +50 -0
- watcherobot/inputs.py +180 -0
- watcherobot/job.py +115 -0
- watcherobot/media.py +243 -0
- watcherobot/opus.py +79 -0
- watcherobot/protocol.py +95 -0
- watcherobot/provisioning/__init__.py +41 -0
- watcherobot/provisioning/backend.py +50 -0
- watcherobot/provisioning/bleak_backend.py +271 -0
- watcherobot/provisioning/errors.py +82 -0
- watcherobot/provisioning/models.py +102 -0
- watcherobot/provisioning/protocol.py +190 -0
- watcherobot/provisioning/service.py +526 -0
- watcherobot/py.typed +0 -0
- watcherobot/robot.py +1093 -0
- watcherobot/runtime/__init__.py +1 -0
- watcherobot/runtime/daemon/__init__.py +16 -0
- watcherobot/runtime/daemon/__main__.py +159 -0
- watcherobot/runtime/daemon/application/__init__.py +44 -0
- watcherobot/runtime/daemon/application/bridge.py +237 -0
- watcherobot/runtime/daemon/application/client.py +140 -0
- watcherobot/runtime/daemon/application/launcher.py +396 -0
- watcherobot/runtime/daemon/application/logging.py +89 -0
- watcherobot/runtime/daemon/application/manifest.py +352 -0
- watcherobot/runtime/daemon/application/runtime.py +551 -0
- watcherobot/runtime/daemon/application/session.py +146 -0
- watcherobot/runtime/daemon/connections/__init__.py +39 -0
- watcherobot/runtime/daemon/connections/device_state.py +41 -0
- watcherobot/runtime/daemon/connections/identity.py +95 -0
- watcherobot/runtime/daemon/connections/registry.py +134 -0
- watcherobot/runtime/daemon/connections/websocket_server.py +482 -0
- watcherobot/runtime/daemon/control/__init__.py +5 -0
- watcherobot/runtime/daemon/control/rest.py +641 -0
- watcherobot/runtime/daemon/instance.py +146 -0
- watcherobot/runtime/daemon/logging.py +90 -0
- watcherobot/runtime/daemon/maintenance/__init__.py +5 -0
- watcherobot/runtime/daemon/maintenance/card_reader.py +906 -0
- watcherobot/runtime/daemon/maintenance/releases.py +426 -0
- watcherobot/runtime/daemon/maintenance/service.py +1475 -0
- watcherobot/runtime/daemon/maintenance/work_assets.py +241 -0
- watcherobot/runtime/daemon/maintenance/works.py +722 -0
- watcherobot/runtime/daemon/pairing/__init__.py +14 -0
- watcherobot/runtime/daemon/pairing/protocol.py +434 -0
- watcherobot/runtime/daemon/pairing/session.py +302 -0
- watcherobot/runtime/daemon/pairing/udp.py +659 -0
- watcherobot/runtime/daemon/preview/__init__.py +5 -0
- watcherobot/runtime/daemon/preview/face_tracking.py +113 -0
- watcherobot/runtime/daemon/preview/udp_feedback.py +67 -0
- watcherobot/runtime/daemon/preview/udp_protocol.py +276 -0
- watcherobot/runtime/daemon/preview/udp_service.py +214 -0
- watcherobot/runtime/daemon/routing/__init__.py +5 -0
- watcherobot/runtime/daemon/routing/raw.py +93 -0
- watcherobot/runtime/daemon/runtime.py +543 -0
- watcherobot/runtime/frozen_entry.py +32 -0
- watcherobot/vision.py +523 -0
- watcherobot-0.1.1.dist-info/METADATA +273 -0
- watcherobot-0.1.1.dist-info/RECORD +92 -0
- watcherobot-0.1.1.dist-info/WHEEL +4 -0
- watcherobot-0.1.1.dist-info/entry_points.txt +3 -0
- watcherobot-0.1.1.dist-info/licenses/LICENSE +177 -0
watcherobot/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from .errors import (
|
|
2
|
+
AuthenticationError,
|
|
3
|
+
CommandError,
|
|
4
|
+
ConnectionTimeoutError,
|
|
5
|
+
JobCancelledError,
|
|
6
|
+
JobFailedError,
|
|
7
|
+
WatcheRobotError,
|
|
8
|
+
)
|
|
9
|
+
from .audio import AudioPlayback, PCMAudio
|
|
10
|
+
from .job import Job, JobState
|
|
11
|
+
from .inputs import BackTouchEvent, InputDomain, InputEvent, RollerEvent, ScreenTouchEvent
|
|
12
|
+
from .media import AudioFormat, AudioFrame, AudioRecording, ImageFrame, MicrophoneSession
|
|
13
|
+
from .provisioning import (
|
|
14
|
+
BluetoothConnectionTimeoutError,
|
|
15
|
+
BluetoothDevice,
|
|
16
|
+
BluetoothPermissionError,
|
|
17
|
+
BluetoothProvisioner,
|
|
18
|
+
BluetoothProvisioningError,
|
|
19
|
+
BluetoothUnavailableError,
|
|
20
|
+
DeviceAmbiguityError,
|
|
21
|
+
DeviceNotFoundError,
|
|
22
|
+
PayloadTooLargeError,
|
|
23
|
+
ProtocolMessage,
|
|
24
|
+
ProvisioningCancelledError,
|
|
25
|
+
ProvisioningProtocolError,
|
|
26
|
+
ProvisioningRejectedError,
|
|
27
|
+
ProvisioningResponseTimeoutError,
|
|
28
|
+
ProvisioningResult,
|
|
29
|
+
WifiStatus,
|
|
30
|
+
)
|
|
31
|
+
from .vision import (
|
|
32
|
+
FaceBox,
|
|
33
|
+
FaceTrackingFrame,
|
|
34
|
+
FaceTrackingPreview,
|
|
35
|
+
FaceTrackingStopPolicy,
|
|
36
|
+
FaceTrackingTelemetry,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"AudioFormat",
|
|
41
|
+
"AudioFrame",
|
|
42
|
+
"AudioPlayback",
|
|
43
|
+
"AudioRecording",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"BackTouchEvent",
|
|
46
|
+
"BluetoothConnectionTimeoutError",
|
|
47
|
+
"BluetoothDevice",
|
|
48
|
+
"BluetoothPermissionError",
|
|
49
|
+
"BluetoothProvisioner",
|
|
50
|
+
"BluetoothProvisioningError",
|
|
51
|
+
"BluetoothUnavailableError",
|
|
52
|
+
"CommandError",
|
|
53
|
+
"ConnectionTimeoutError",
|
|
54
|
+
"DeviceAmbiguityError",
|
|
55
|
+
"DeviceNotFoundError",
|
|
56
|
+
"FaceBox",
|
|
57
|
+
"FaceTrackingFrame",
|
|
58
|
+
"FaceTrackingPreview",
|
|
59
|
+
"FaceTrackingStopPolicy",
|
|
60
|
+
"FaceTrackingTelemetry",
|
|
61
|
+
"ImageFrame",
|
|
62
|
+
"InputDomain",
|
|
63
|
+
"InputEvent",
|
|
64
|
+
"Job",
|
|
65
|
+
"JobCancelledError",
|
|
66
|
+
"JobFailedError",
|
|
67
|
+
"JobState",
|
|
68
|
+
"MicrophoneSession",
|
|
69
|
+
"PCMAudio",
|
|
70
|
+
"PayloadTooLargeError",
|
|
71
|
+
"ProtocolMessage",
|
|
72
|
+
"ProvisioningCancelledError",
|
|
73
|
+
"ProvisioningProtocolError",
|
|
74
|
+
"ProvisioningRejectedError",
|
|
75
|
+
"ProvisioningResponseTimeoutError",
|
|
76
|
+
"ProvisioningResult",
|
|
77
|
+
"RollerEvent",
|
|
78
|
+
"ScreenTouchEvent",
|
|
79
|
+
"WatcheRobotError",
|
|
80
|
+
"WifiStatus",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private implementation helpers for the WatcheRobot SDK."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Single source of truth for device audio stream status semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AudioStatusKind(str, Enum):
|
|
9
|
+
RUNNING = "running"
|
|
10
|
+
COMPLETED = "completed"
|
|
11
|
+
FAILED = "failed"
|
|
12
|
+
CANCELLED = "cancelled"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
FAILED_AUDIO_REASONS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"enqueue_timeout",
|
|
18
|
+
"enqueue_failed",
|
|
19
|
+
"queue_lock_timeout",
|
|
20
|
+
"sequence_gap",
|
|
21
|
+
"stale_stream",
|
|
22
|
+
"stale_frame",
|
|
23
|
+
"playback_start_failed",
|
|
24
|
+
"playback_write_failed",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
CANCELLED_AUDIO_REASONS = frozenset({"aborted", "replaced_by_new_stream"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def classify_audio_status(reason: object) -> AudioStatusKind:
|
|
32
|
+
"""Map one protocol reason to the public playback lifecycle."""
|
|
33
|
+
|
|
34
|
+
if reason == "complete":
|
|
35
|
+
return AudioStatusKind.COMPLETED
|
|
36
|
+
if reason in FAILED_AUDIO_REASONS:
|
|
37
|
+
return AudioStatusKind.FAILED
|
|
38
|
+
if reason in CANCELLED_AUDIO_REASONS:
|
|
39
|
+
return AudioStatusKind.CANCELLED
|
|
40
|
+
return AudioStatusKind.RUNNING
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Public API for Daemon-managed WatcheRobot Applications."""
|
|
2
|
+
|
|
3
|
+
from .channels import (
|
|
4
|
+
ApplicationChannel,
|
|
5
|
+
ApplicationChannels,
|
|
6
|
+
Frame,
|
|
7
|
+
FrameCallback,
|
|
8
|
+
)
|
|
9
|
+
from .context import ApplicationContext, ApplicationEnvironmentError
|
|
10
|
+
from .rtc import (
|
|
11
|
+
ApplicationRtc,
|
|
12
|
+
RTC_AUDIO_CAPABILITY,
|
|
13
|
+
RTC_PROTOCOL,
|
|
14
|
+
RTC_VIDEO_CAPABILITY,
|
|
15
|
+
RtcSessionRejectedError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ApplicationChannel",
|
|
20
|
+
"ApplicationChannels",
|
|
21
|
+
"ApplicationContext",
|
|
22
|
+
"ApplicationEnvironmentError",
|
|
23
|
+
"ApplicationRtc",
|
|
24
|
+
"Frame",
|
|
25
|
+
"FrameCallback",
|
|
26
|
+
"RTC_AUDIO_CAPABILITY",
|
|
27
|
+
"RTC_PROTOCOL",
|
|
28
|
+
"RTC_VIDEO_CAPABILITY",
|
|
29
|
+
"RtcSessionRejectedError",
|
|
30
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Low-level channels for advanced Daemon-managed Applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from watcherobot.runtime.daemon.application.client import (
|
|
6
|
+
ApplicationCommunicators,
|
|
7
|
+
Frame,
|
|
8
|
+
FrameCallback,
|
|
9
|
+
)
|
|
10
|
+
from watcherobot.runtime.daemon.application.session import ApplicationChannel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApplicationChannels(ApplicationCommunicators):
|
|
14
|
+
"""Expose source-aware desktop/device frames to an advanced Application.
|
|
15
|
+
|
|
16
|
+
Most Applications should use :class:`ApplicationContext`. This lower-level
|
|
17
|
+
API exists for Applications such as ``watcher_default`` that already own a
|
|
18
|
+
complete business protocol stack and need the original frames unchanged.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def desktop_url(self) -> str:
|
|
23
|
+
return self._desktop_url
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def device_url(self) -> str:
|
|
27
|
+
return self._device_url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ApplicationChannel",
|
|
32
|
+
"ApplicationChannels",
|
|
33
|
+
"Frame",
|
|
34
|
+
"FrameCallback",
|
|
35
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Public context for a Daemon-managed WatcheRobot Application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
from watcherobot.application.desktop import ApplicationDesktop
|
|
12
|
+
from watcherobot.application.rtc import ApplicationRtc
|
|
13
|
+
from watcherobot.application.transport import DaemonApplicationTransport
|
|
14
|
+
from watcherobot.robot import WatcheRobot
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApplicationEnvironmentError(RuntimeError):
|
|
18
|
+
"""Raised when code is not running inside an authorized Application."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApplicationContext:
|
|
22
|
+
"""Own the SDK objects injected into one managed Application process."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
app_id: str,
|
|
28
|
+
transport: DaemonApplicationTransport,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.app_id = app_id
|
|
31
|
+
self._transport = transport
|
|
32
|
+
self.robot = WatcheRobot._from_transport(transport)
|
|
33
|
+
self.desktop = ApplicationDesktop(transport)
|
|
34
|
+
self.rtc = ApplicationRtc(transport)
|
|
35
|
+
self.logger = _build_application_logger(app_id)
|
|
36
|
+
self._entered = False
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_environment(cls) -> "ApplicationContext":
|
|
40
|
+
required = {
|
|
41
|
+
"WATCHER_APP_ID": os.environ.get("WATCHER_APP_ID", "").strip(),
|
|
42
|
+
"WATCHER_APP_RUN_CREDENTIAL": os.environ.get(
|
|
43
|
+
"WATCHER_APP_RUN_CREDENTIAL",
|
|
44
|
+
"",
|
|
45
|
+
).strip(),
|
|
46
|
+
"WATCHER_APP_DESKTOP_WS_URL": os.environ.get(
|
|
47
|
+
"WATCHER_APP_DESKTOP_WS_URL",
|
|
48
|
+
"",
|
|
49
|
+
).strip(),
|
|
50
|
+
"WATCHER_APP_DEVICE_WS_URL": os.environ.get(
|
|
51
|
+
"WATCHER_APP_DEVICE_WS_URL",
|
|
52
|
+
"",
|
|
53
|
+
).strip(),
|
|
54
|
+
}
|
|
55
|
+
missing = [name for name, value in required.items() if not value]
|
|
56
|
+
if missing:
|
|
57
|
+
raise ApplicationEnvironmentError(
|
|
58
|
+
"Application environment is incomplete: "
|
|
59
|
+
+ ", ".join(sorted(missing))
|
|
60
|
+
)
|
|
61
|
+
return cls(
|
|
62
|
+
app_id=required["WATCHER_APP_ID"],
|
|
63
|
+
transport=DaemonApplicationTransport(),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self) -> "ApplicationContext":
|
|
67
|
+
if self._entered:
|
|
68
|
+
raise RuntimeError("ApplicationContext is already entered")
|
|
69
|
+
await asyncio.to_thread(self._transport.start)
|
|
70
|
+
self._entered = True
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(
|
|
74
|
+
self,
|
|
75
|
+
exc_type: type[BaseException] | None,
|
|
76
|
+
exc_value: BaseException | None,
|
|
77
|
+
traceback: TracebackType | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
await asyncio.to_thread(self.close)
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
if not self._entered:
|
|
83
|
+
return
|
|
84
|
+
self.rtc.close()
|
|
85
|
+
self.robot.close()
|
|
86
|
+
self._entered = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_application_logger(app_id: str) -> logging.Logger:
|
|
90
|
+
logger = logging.getLogger(f"watcherobot.application.{app_id}")
|
|
91
|
+
logger.setLevel(logging.INFO)
|
|
92
|
+
logger.propagate = False
|
|
93
|
+
if not logger.handlers:
|
|
94
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
95
|
+
handler.setFormatter(
|
|
96
|
+
logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
|
97
|
+
)
|
|
98
|
+
logger.addHandler(handler)
|
|
99
|
+
return logger
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Desktop-side Application channel exposed without device ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import queue
|
|
7
|
+
|
|
8
|
+
from watcherobot.application.transport import DaemonApplicationTransport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApplicationDesktop:
|
|
12
|
+
"""Send and receive raw desktop frames through the managed Daemon bridge."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, transport: DaemonApplicationTransport) -> None:
|
|
15
|
+
self._transport = transport
|
|
16
|
+
self._frames: queue.Queue[str | bytes] = queue.Queue()
|
|
17
|
+
transport.set_desktop_callback(self._frames.put)
|
|
18
|
+
|
|
19
|
+
async def send(self, frame: str | bytes) -> None:
|
|
20
|
+
await asyncio.wrap_future(self._transport.send_desktop(frame))
|
|
21
|
+
|
|
22
|
+
async def receive(self, *, timeout: float | None = None) -> str | bytes:
|
|
23
|
+
try:
|
|
24
|
+
return await asyncio.to_thread(self._frames.get, True, timeout)
|
|
25
|
+
except queue.Empty as exc:
|
|
26
|
+
raise TimeoutError("Timed out waiting for a desktop frame") from exc
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shared Application source ignore rules for validation and publishing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fnmatch import fnmatchcase
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
APPLICATION_IGNORE_FILE = ".watcherignore"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApplicationIgnoreError(RuntimeError):
|
|
13
|
+
"""Raised when Application ignore rules cannot be loaded safely."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_application_ignore_patterns(source: Path) -> tuple[str, ...]:
|
|
17
|
+
"""Load normalized non-comment patterns from one Application root."""
|
|
18
|
+
|
|
19
|
+
ignore_path = Path(source) / APPLICATION_IGNORE_FILE
|
|
20
|
+
if not ignore_path.is_file():
|
|
21
|
+
return ()
|
|
22
|
+
try:
|
|
23
|
+
lines = ignore_path.read_text(encoding="utf-8").splitlines()
|
|
24
|
+
except (OSError, UnicodeError) as exc:
|
|
25
|
+
raise ApplicationIgnoreError(
|
|
26
|
+
f"Cannot read {APPLICATION_IGNORE_FILE}: {exc}"
|
|
27
|
+
) from exc
|
|
28
|
+
return tuple(
|
|
29
|
+
line.strip().replace("\\", "/")
|
|
30
|
+
for line in lines
|
|
31
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_application_path_ignored(
|
|
36
|
+
relative_path: Path,
|
|
37
|
+
patterns: tuple[str, ...],
|
|
38
|
+
) -> bool:
|
|
39
|
+
"""Return whether a relative Application path matches ignore rules."""
|
|
40
|
+
|
|
41
|
+
relative = PurePosixPath(relative_path.as_posix())
|
|
42
|
+
for pattern in patterns:
|
|
43
|
+
if pattern.endswith("/"):
|
|
44
|
+
prefix = pattern.rstrip("/")
|
|
45
|
+
ancestors = (
|
|
46
|
+
"/".join(relative.parts[:depth])
|
|
47
|
+
for depth in range(1, len(relative.parts))
|
|
48
|
+
)
|
|
49
|
+
if any(fnmatchcase(ancestor, prefix) for ancestor in ancestors):
|
|
50
|
+
return True
|
|
51
|
+
continue
|
|
52
|
+
if relative.match(pattern):
|
|
53
|
+
return True
|
|
54
|
+
return False
|