harbor-python 1.1.0__py3-none-any.whl → 1.2.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.
harbor/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from .config import HarborCameraConfig
2
2
  from .core import Harbor
3
3
  from .data.mqtt_models import (
4
+ GetCameraSettingsRequest,
4
5
  HeartbeatEvent,
5
6
  LocalLivekitHeartbeatEvent,
6
7
  MotionDetectedEvent,
@@ -49,6 +50,7 @@ __all__ = [
49
50
  "MotionDetectedUpdate",
50
51
  "ViewerInfo",
51
52
  "parse_message",
53
+ "GetCameraSettingsRequest",
52
54
  "HeartbeatEvent",
53
55
  "LocalLivekitHeartbeatEvent",
54
56
  "SettingsEvent",
harbor/core.py CHANGED
@@ -2,8 +2,9 @@ import logging
2
2
  from typing import Any
3
3
 
4
4
  from .config import HarborCameraConfig
5
+ from .data.mqtt_models import SettingsEvent
5
6
  from .device import HarborDevice
6
- from .mqtt import HarborMQTTClient
7
+ from .mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient
7
8
 
8
9
  _LOGGER = logging.getLogger(__name__)
9
10
 
@@ -32,12 +33,13 @@ class Harbor:
32
33
  if config.serial in self._clients:
33
34
  _LOGGER.warning("Camera connection already exists: %s", config.serial)
34
35
  return
35
- topics = list(self._topics_cache)
36
+ topics = list({*self._topics_cache, f"cameras/{config.serial}/responses/#"})
36
37
  client = HarborMQTTClient(
37
38
  config=config,
38
39
  topics=topics,
39
40
  message_handler=self.handle_message,
40
41
  client_id=f"harbor-client-{config.serial}",
42
+ initial_commands=DEFAULT_INITIAL_COMMANDS,
41
43
  )
42
44
  self._clients[config.serial] = client
43
45
  _LOGGER.info("Added MQTT client for camera: %s", config.serial)
@@ -54,6 +56,35 @@ class Harbor:
54
56
  for device in self._devices.values():
55
57
  device.shutdown()
56
58
 
59
+ async def publish_camera_command(
60
+ self,
61
+ serial: str,
62
+ command: str,
63
+ payload: Any,
64
+ ) -> None:
65
+ """Publish a command to a camera."""
66
+ await self._get_client(serial).publish_command(command, payload)
67
+
68
+ async def request_camera_command(
69
+ self,
70
+ serial: str,
71
+ command: str,
72
+ payload: dict[str, Any],
73
+ *,
74
+ timeout: float = 10.0,
75
+ ) -> Any:
76
+ """Publish a command to a camera and wait for the matching response."""
77
+ return await self._get_client(serial).request_command(command, payload, timeout=timeout)
78
+
79
+ async def get_camera_settings(
80
+ self,
81
+ serial: str,
82
+ *,
83
+ timeout: float = 10.0,
84
+ ) -> SettingsEvent:
85
+ """Request the camera settings payload."""
86
+ return await self._get_client(serial).get_settings(timeout=timeout)
87
+
57
88
  async def handle_message(self, topic: str, payload: Any) -> None:
58
89
  """
59
90
  Central message handler.
@@ -61,3 +92,9 @@ class Harbor:
61
92
  """
62
93
  for device in self._devices.values():
63
94
  await device.handle_message(topic, payload)
95
+
96
+ def _get_client(self, serial: str) -> HarborMQTTClient:
97
+ try:
98
+ return self._clients[serial]
99
+ except KeyError as exc:
100
+ raise KeyError(f"No camera connection exists for serial {serial!r}") from exc
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
8
8
  class HarborMQTTPayload(BaseModel):
9
9
  """Base model for Harbor MQTT payloads."""
10
10
 
11
- model_config = ConfigDict(extra="allow")
11
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
12
12
 
13
13
 
14
14
  class LocalLivekitHeartbeatEvent(HarborMQTTPayload):
@@ -82,14 +82,22 @@ class SettingsEvent(HarborMQTTPayload):
82
82
  """Payload for a settings event."""
83
83
 
84
84
  client: str | None = None
85
- is_updating: bool | None = None
85
+ is_updating: bool | None = Field(default=None, alias="isUpdating")
86
86
  seq: str | None = None
87
87
  settings: Settings | None = None
88
88
  state: SettingsState | None = None
89
- triggered_by: str | None = None
89
+ triggered_by: str | None = Field(default=None, alias="triggeredBy")
90
90
  updated: dict[str, Any] = Field(default_factory=dict)
91
91
 
92
92
 
93
+ class GetCameraSettingsRequest(HarborMQTTPayload):
94
+ """Payload for the get-settings camera command."""
95
+
96
+ seq: str
97
+ client: str
98
+ triggered_by: str = Field(alias="triggeredBy")
99
+
100
+
93
101
  class ViewerJoinedEvent(HarborMQTTPayload):
94
102
  """Payload for a viewer joined event."""
95
103
 
harbor/devices/camera.py CHANGED
@@ -23,9 +23,10 @@ DEFAULT_EVENT_ACTIVE_SECONDS = 5.0
23
23
  # Known values for enumerated state fields. The device reports these in
24
24
  # mixed/upper case (e.g. "PLAYING", "GOOD"); they are normalized to the
25
25
  # lowercase values below before being stored in ``HarborDeviceState.values``.
26
- # A value outside these sets is still stored lowercased (and logged once),
27
- # so consumers that declare options up front should treat these sets as the
28
- # baseline, not a hard guarantee.
26
+ # A value outside these sets is mapped to ``UNKNOWN_ENUM_VALUE`` (and logged
27
+ # once), so a stored enum value is always a member of its set. Consumers can
28
+ # rely on that guarantee instead of clamping the value themselves.
29
+ UNKNOWN_ENUM_VALUE = "unknown"
29
30
  SPEAKER_STATES = frozenset({"idle", "muted", "off", "paused", "playing", "unknown"})
30
31
  STREAM_QUALITIES = frozenset({"excellent", "fair", "good", "poor", "unknown"})
31
32
 
@@ -46,7 +47,10 @@ class HarborCamera(HarborDevice):
46
47
  def get_topics(self) -> list[str]:
47
48
  """Return topics that should be subscribed for this device."""
48
49
 
49
- return [f"cameras/{self.serial}/events/#"]
50
+ return [
51
+ f"cameras/{self.serial}/events/#",
52
+ f"cameras/{self.serial}/responses/#",
53
+ ]
50
54
 
51
55
  def _apply_event(self, event: HarborEvent) -> None:
52
56
  """Apply a Harbor event to camera state, including camera-only events."""
@@ -99,21 +103,28 @@ class HarborCamera(HarborDevice):
99
103
  value: str | None,
100
104
  known_values: frozenset[str],
101
105
  ) -> str | None:
102
- """Normalize an enumerated device value to a stable lowercase string."""
106
+ """Normalize an enumerated device value to a known lowercase member.
107
+
108
+ Returns ``None`` only when the device omitted the field. A value that
109
+ does not match ``known_values`` is mapped to ``UNKNOWN_ENUM_VALUE`` so
110
+ callers can rely on the result always being a member of the set.
111
+ """
103
112
  if value is None:
104
113
  return None
105
114
  normalized = value.strip().lower()
106
115
  if not normalized:
107
116
  return None
108
- if normalized not in known_values and (field_name, normalized) not in self._unexpected_enum_values:
109
- self._unexpected_enum_values.add((field_name, normalized))
110
- _LOGGER.warning(
111
- "Camera %s reported unexpected %s value %r (known values: %s)",
112
- self.serial,
113
- field_name,
114
- normalized,
115
- sorted(known_values),
116
- )
117
+ if normalized not in known_values:
118
+ if (field_name, normalized) not in self._unexpected_enum_values:
119
+ self._unexpected_enum_values.add((field_name, normalized))
120
+ _LOGGER.warning(
121
+ "Camera %s reported unexpected %s value %r (known values: %s)",
122
+ self.serial,
123
+ field_name,
124
+ normalized,
125
+ sorted(known_values),
126
+ )
127
+ return UNKNOWN_ENUM_VALUE
117
128
  return normalized
118
129
 
119
130
  def _apply_viewer_joined(self, viewer: ViewerInfo | None) -> None:
harbor/events.py CHANGED
@@ -178,7 +178,7 @@ def parse_topic(
178
178
  return None, None, None
179
179
 
180
180
  root, serial, event_root, *rest = parts
181
- if event_root != "events" or not rest:
181
+ if event_root not in {"events", "responses"} or not rest:
182
182
  return None, None, None
183
183
 
184
184
  if root == "cameras":
@@ -336,7 +336,7 @@ def parse_message(
336
336
  )
337
337
  return RawEventUpdate(payload=raw_payload, **base_kwargs)
338
338
 
339
- if event_key == "settings":
339
+ if event_key in {"settings", "get_settings"}:
340
340
  if typed_payload := _validate_payload(SettingsEvent, raw_payload, topic):
341
341
  return SettingsUpdate(payload=typed_payload, **base_kwargs)
342
342
  return RawEventUpdate(payload=raw_payload, **base_kwargs)
harbor/mqtt.py CHANGED
@@ -4,15 +4,21 @@ import logging
4
4
  import sys
5
5
  from collections.abc import Awaitable, Callable
6
6
  from typing import Any
7
+ from uuid import uuid4
7
8
 
8
9
  from aiomqtt import Client, MqttError
9
10
 
10
11
  from .config import HarborCameraConfig
12
+ from .data.mqtt_models import GetCameraSettingsRequest, SettingsEvent
11
13
  from .utils import get_camera_host, get_ssl_cache_key, get_ssl_context
12
14
 
13
15
  _LOGGER = logging.getLogger(__name__)
14
16
 
15
17
  DEFAULT_CONNECTION_GRACE_PERIOD = 90.0
18
+ DEFAULT_COMMAND_QOS = 2
19
+ DEFAULT_REQUEST_TIMEOUT = 10.0
20
+ GET_SETTINGS_COMMAND = "get-settings"
21
+ DEFAULT_INITIAL_COMMANDS = (GET_SETTINGS_COMMAND,)
16
22
 
17
23
 
18
24
  class HarborMQTTClient:
@@ -25,6 +31,7 @@ class HarborMQTTClient:
25
31
  ssl_context_cache: dict | None = None,
26
32
  on_connection_change: Callable[[bool], Awaitable[None]] | None = None,
27
33
  connection_grace_period: float = DEFAULT_CONNECTION_GRACE_PERIOD,
34
+ initial_commands: list[str] | tuple[str, ...] | None = None,
28
35
  ) -> None:
29
36
  """Initialize the MQTT client.
30
37
 
@@ -42,9 +49,12 @@ class HarborMQTTClient:
42
49
  self.ssl_context_cache = ssl_context_cache or {}
43
50
  self.on_connection_change = on_connection_change
44
51
  self.connection_grace_period = connection_grace_period
52
+ self.initial_commands = tuple(initial_commands or ())
45
53
  self.connected: bool = False
46
54
  self._stop_event = asyncio.Event()
47
55
  self._task: asyncio.Task | None = None
56
+ self._client: Client | None = None
57
+ self._pending_responses: dict[str, asyncio.Future[Any]] = {}
48
58
  self._reported_connected: bool | None = None
49
59
  self._disconnect_grace_task: asyncio.Task | None = None
50
60
 
@@ -55,6 +65,23 @@ class HarborMQTTClient:
55
65
  payload = payload_raw
56
66
 
57
67
  await self.message_handler(topic, payload)
68
+ self._resolve_pending_response(topic, payload)
69
+
70
+ def _resolve_pending_response(self, topic: str, payload: Any) -> None:
71
+ """Resolve a pending request when a camera response echoes its seq."""
72
+ if not topic.startswith(f"cameras/{self.config.serial}/responses/"):
73
+ return
74
+ if not isinstance(payload, dict):
75
+ return
76
+
77
+ seq = payload.get("seq")
78
+ if not isinstance(seq, str):
79
+ return
80
+
81
+ future = self._pending_responses.pop(seq, None)
82
+ if future is None or future.done():
83
+ return
84
+ future.set_result(payload)
58
85
 
59
86
  async def _set_connected(self, connected: bool) -> None:
60
87
  """Update the raw connection flag and debounce listener notifications."""
@@ -108,6 +135,12 @@ class HarborMQTTClient:
108
135
  def _invalidate_ssl_cache(self) -> None:
109
136
  self.ssl_context_cache.pop(get_ssl_cache_key(self.config), None)
110
137
 
138
+ def _fail_pending_responses(self, exc: Exception) -> None:
139
+ for future in self._pending_responses.values():
140
+ if not future.done():
141
+ future.set_exception(exc)
142
+ self._pending_responses.clear()
143
+
111
144
  async def run(self) -> None:
112
145
  reconnect_delay = 2
113
146
 
@@ -143,6 +176,7 @@ class HarborMQTTClient:
143
176
  timeout=10,
144
177
  identifier=self.client_id,
145
178
  ) as client:
179
+ self._client = client
146
180
  _LOGGER.info(
147
181
  "Harbor: MQTT connected to %s:%s for camera %s",
148
182
  host,
@@ -159,6 +193,8 @@ class HarborMQTTClient:
159
193
  self.config.serial,
160
194
  )
161
195
 
196
+ await self._publish_initial_commands()
197
+
162
198
  async for message in client.messages:
163
199
  if self._stop_event.is_set():
164
200
  break
@@ -177,6 +213,8 @@ class HarborMQTTClient:
177
213
 
178
214
  reconnect_delay = 2
179
215
  finally:
216
+ self._client = None
217
+ self._fail_pending_responses(ConnectionError("Harbor MQTT client disconnected"))
180
218
  await self._set_connected(False)
181
219
 
182
220
  except TimeoutError as e:
@@ -247,9 +285,131 @@ class HarborMQTTClient:
247
285
  _LOGGER.info("Harbor: MQTT client stopped for camera %s", self.config.serial)
248
286
  # An intentional stop is a stable disconnect: skip the grace period.
249
287
  self._cancel_disconnect_grace()
288
+ self._client = None
289
+ self._fail_pending_responses(ConnectionError("Harbor MQTT client stopped"))
250
290
  if self._reported_connected:
251
291
  await self._notify_connection_change(False)
252
292
 
293
+ async def publish(
294
+ self,
295
+ topic: str,
296
+ payload: Any,
297
+ *,
298
+ qos: int = DEFAULT_COMMAND_QOS,
299
+ retain: bool = False,
300
+ ) -> None:
301
+ """Publish a JSON-compatible payload to a Harbor MQTT topic."""
302
+ client = self._client
303
+ if client is None or not self.connected:
304
+ raise ConnectionError(f"Harbor MQTT client is not connected for camera {self.config.serial}")
305
+
306
+ if isinstance(payload, str):
307
+ payload_raw = payload
308
+ else:
309
+ payload_raw = json.dumps(payload, separators=(",", ":"))
310
+
311
+ _LOGGER.debug(
312
+ "Harbor: MQTT publishing to topic '%s' for camera %s: %s",
313
+ topic,
314
+ self.config.serial,
315
+ payload_raw,
316
+ )
317
+ await client.publish(topic, payload_raw, qos=qos, retain=retain)
318
+
319
+ async def publish_command(
320
+ self,
321
+ command: str,
322
+ payload: Any,
323
+ *,
324
+ qos: int = DEFAULT_COMMAND_QOS,
325
+ ) -> None:
326
+ """Publish a camera command using the app's command topic layout."""
327
+ await self.publish(f"cameras/{self.config.serial}/{command}", payload, qos=qos)
328
+
329
+ async def _publish_initial_commands(self) -> None:
330
+ """Publish configured one-shot state population commands after connect."""
331
+ for command in self.initial_commands:
332
+ try:
333
+ if command == GET_SETTINGS_COMMAND:
334
+ await self.publish_command(command, self._build_get_settings_payload())
335
+ else:
336
+ _LOGGER.warning(
337
+ "Harbor: skipping unsupported initial command %s for camera %s",
338
+ command,
339
+ self.config.serial,
340
+ )
341
+ except Exception:
342
+ _LOGGER.exception(
343
+ "Harbor: failed to publish initial command %s for camera %s",
344
+ command,
345
+ self.config.serial,
346
+ )
347
+
348
+ def _build_get_settings_payload(
349
+ self,
350
+ *,
351
+ client: str | None = None,
352
+ triggered_by: str | None = None,
353
+ seq: str | None = None,
354
+ ) -> dict[str, Any]:
355
+ request = GetCameraSettingsRequest(
356
+ seq=seq or _generate_seq(),
357
+ client=client or self.client_id or f"harbor-client-{self.config.serial}",
358
+ triggered_by=triggered_by or "harbor-python",
359
+ )
360
+ return request.model_dump(by_alias=True)
361
+
362
+ async def request_command(
363
+ self,
364
+ command: str,
365
+ payload: dict[str, Any],
366
+ *,
367
+ seq: str | None = None,
368
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
369
+ qos: int = DEFAULT_COMMAND_QOS,
370
+ ) -> Any:
371
+ """Publish a command and wait for a response carrying the same seq."""
372
+ request_seq = seq or _generate_seq()
373
+ payload = {**payload, "seq": request_seq}
374
+ loop = asyncio.get_running_loop()
375
+ future: asyncio.Future[Any] = loop.create_future()
376
+ self._pending_responses[request_seq] = future
377
+
378
+ try:
379
+ await self.publish_command(command, payload, qos=qos)
380
+ return await asyncio.wait_for(future, timeout=timeout)
381
+ except Exception:
382
+ pending = self._pending_responses.pop(request_seq, None)
383
+ if pending is not None and not pending.done():
384
+ pending.cancel()
385
+ raise
386
+
387
+ async def get_settings(
388
+ self,
389
+ *,
390
+ client: str | None = None,
391
+ triggered_by: str | None = None,
392
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
393
+ ) -> SettingsEvent:
394
+ """Request camera settings via the get-settings command."""
395
+ seq = _generate_seq()
396
+ response = await self.request_command(
397
+ GET_SETTINGS_COMMAND,
398
+ self._build_get_settings_payload(
399
+ seq=seq,
400
+ client=client,
401
+ triggered_by=triggered_by,
402
+ ),
403
+ seq=seq,
404
+ timeout=timeout,
405
+ )
406
+ return SettingsEvent.model_validate(response)
407
+
253
408
  def __del__(self) -> None:
254
409
  if self._stop_event and not self._stop_event.is_set():
255
410
  self._stop_event.set()
411
+
412
+
413
+ def _generate_seq() -> str:
414
+ """Generate a request sequence string echoed by Harbor responses."""
415
+ return uuid4().hex
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harbor-python
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: A package to locally connect to a Harbor Sleep Camera
5
5
  Author: Andres Garcia, Lash-L
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1,17 @@
1
+ harbor/__init__.py,sha256=F2Yo9T1Hpf0lSioVAk-m8GJnEh1tLd2En4Mzz9RxczY,1606
2
+ harbor/config.py,sha256=LXMDaTC_0Z9wF_vTXJG9C0vQ0XHXo4EuFL0-Kwa4-S4,1373
3
+ harbor/core.py,sha256=Alm6ojRBCibsQ5xGGyaAOQimi-_kE6Q6fuS2ZAxzaNM,3468
4
+ harbor/device.py,sha256=YvaFWieXJ-EzQJU8VQo_ZERl65CynITTAnMFto-PF_I,5933
5
+ harbor/events.py,sha256=4Y3JT_NP1x8rUR7U6n2-LvAENt9zpqD4jVy28AAYo4Q,20400
6
+ harbor/mqtt.py,sha256=ZhGuR90U449q4OY9WCCJVLIuYVEHrG1uT32EGjux7rU,16712
7
+ harbor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ harbor/state.py,sha256=jQDLV8zpkLV2f6zR4LuOtk_B5BtGdp-8dq4NyGZCa-8,1210
9
+ harbor/utils.py,sha256=7A3Tn1aZ93SlxRKY-0b-50ntQL4Pw1j5bXwkJvnarpY,3312
10
+ harbor/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ harbor/data/mqtt_models.py,sha256=OF36ZoUCOeoaKp4TPggM6HCbowgtkH1zLFTozVJUZYY,4314
12
+ harbor/devices/camera.py,sha256=ciq8ynsAaZgW0CWYjKiJGAZwk04pNz4uYfnsN0GT0O4,8429
13
+ harbor/devices/monitor.py,sha256=hzx4lu92h3i_34QyYK392gE7CihqERiOZuLhyzDtDk0,452
14
+ harbor_python-1.2.0.dist-info/METADATA,sha256=ZEPjl-fqvicWCUsIa5dbS18zAFgm5YeYXbGUqvMFe9A,688
15
+ harbor_python-1.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ harbor_python-1.2.0.dist-info/licenses/LICENSE,sha256=QEOXEfrOQetemKy4k1WN5XCApJdpiRtyV5jLMSToVVw,11344
17
+ harbor_python-1.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,17 +0,0 @@
1
- harbor/__init__.py,sha256=b43EC904n7dBigKTlU8600P5XxG4Zip0It4I7GVsRDc,1544
2
- harbor/config.py,sha256=LXMDaTC_0Z9wF_vTXJG9C0vQ0XHXo4EuFL0-Kwa4-S4,1373
3
- harbor/core.py,sha256=lfO1FAwYKSSW7YZ8fqDdhgpWXS6eA-vL1NgZKmN_H_g,2186
4
- harbor/device.py,sha256=YvaFWieXJ-EzQJU8VQo_ZERl65CynITTAnMFto-PF_I,5933
5
- harbor/events.py,sha256=q_WNG_yIkCczOzloe9mDwulgHhXmeQdZFDyyKpCz1Ig,20363
6
- harbor/mqtt.py,sha256=ZqHVjHaocXtOK6wh0FO6lLF2UJQfDX3W6DG8R9YD30c,10816
7
- harbor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- harbor/state.py,sha256=jQDLV8zpkLV2f6zR4LuOtk_B5BtGdp-8dq4NyGZCa-8,1210
9
- harbor/utils.py,sha256=7A3Tn1aZ93SlxRKY-0b-50ntQL4Pw1j5bXwkJvnarpY,3312
10
- harbor/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- harbor/data/mqtt_models.py,sha256=yir1maFUxmk_9E2J_JEnTzDJwiAGDdSrbkGN1YaZd_o,4031
12
- harbor/devices/camera.py,sha256=jjgXFeASsSnQqyGTmhMbeLZJ3zHrdxLAKXG2UwHYWSk,7968
13
- harbor/devices/monitor.py,sha256=hzx4lu92h3i_34QyYK392gE7CihqERiOZuLhyzDtDk0,452
14
- harbor_python-1.1.0.dist-info/METADATA,sha256=FodUxQmoYkqajgqYsSxXzrLxHm9W_pdBnXkSs5eIs5s,688
15
- harbor_python-1.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
- harbor_python-1.1.0.dist-info/licenses/LICENSE,sha256=QEOXEfrOQetemKy4k1WN5XCApJdpiRtyV5jLMSToVVw,11344
17
- harbor_python-1.1.0.dist-info/RECORD,,