openmodule 15.2.0__tar.gz → 16.0.0__tar.gz

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 (102) hide show
  1. {openmodule-15.2.0 → openmodule-16.0.0}/PKG-INFO +1 -1
  2. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/alert.py +2 -2
  3. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/core.py +1 -1
  4. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/database/custom_types.py +1 -0
  5. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/database/migration.py +17 -5
  6. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/dispatcher.py +5 -18
  7. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/health.py +38 -35
  8. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/messaging.py +7 -2
  9. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/access_service.py +18 -18
  10. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/base.py +32 -3
  11. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/presence.py +25 -14
  12. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/rpc/server.py +25 -19
  13. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/sentry.py +1 -0
  14. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/charset.py +12 -3
  15. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/io.py +5 -5
  16. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/kv_store.py +12 -8
  17. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/matching.py +43 -11
  18. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/package_reader.py +19 -10
  19. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/presence.py +1 -1
  20. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/schedule.py +1 -0
  21. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/signal_listener.py +7 -4
  22. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/validation.py +1 -1
  23. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule.egg-info/PKG-INFO +1 -1
  24. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule.egg-info/SOURCES.txt +0 -2
  25. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_connection_status.py +1 -1
  26. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_core.py +2 -2
  27. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_database.py +7 -4
  28. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_dispatcher.py +6 -9
  29. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_model.py +23 -0
  30. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_rpc.py +5 -5
  31. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_sentry.py +3 -4
  32. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_access_service.py +18 -15
  33. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_eventlog.py +9 -0
  34. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_kv_store.py +9 -8
  35. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_kv_store_multiple.py +26 -22
  36. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_matching.py +7 -4
  37. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_presence.py +1 -1
  38. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_vehicle.py +1 -1
  39. openmodule-15.2.0/openmodule/utils/schema.py +0 -188
  40. openmodule-15.2.0/tests/test_schema.py +0 -243
  41. {openmodule-15.2.0 → openmodule-16.0.0}/LICENSE +0 -0
  42. {openmodule-15.2.0 → openmodule-16.0.0}/README.md +0 -0
  43. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/__init__.py +0 -0
  44. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/config.py +0 -0
  45. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/connection_status.py +0 -0
  46. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/database/__init__.py +0 -0
  47. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/database/database.py +0 -0
  48. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/database/env.py +0 -0
  49. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/logging.py +0 -0
  50. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/__init__.py +0 -0
  51. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/alert.py +0 -0
  52. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/io.py +0 -0
  53. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/kv_store.py +0 -0
  54. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/privacy.py +0 -0
  55. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/rpc.py +0 -0
  56. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/settings.py +0 -0
  57. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/signals.py +0 -0
  58. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/validation.py +0 -0
  59. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/models/vehicle.py +0 -0
  60. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/rpc/__init__.py +0 -0
  61. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/rpc/client.py +0 -0
  62. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/rpc/common.py +0 -0
  63. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/threading.py +0 -0
  64. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/__init__.py +0 -0
  65. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/access_service.py +0 -0
  66. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/cleanup.py +0 -0
  67. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/csv_export.py +0 -0
  68. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/databox.py +0 -0
  69. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/db_helper.py +0 -0
  70. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/eventlog.py +0 -0
  71. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/misc_functions.py +0 -0
  72. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/settings.py +0 -0
  73. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule/utils/translation.py +0 -0
  74. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule.egg-info/dependency_links.txt +0 -0
  75. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule.egg-info/not-zip-safe +0 -0
  76. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule.egg-info/requires.txt +0 -0
  77. {openmodule-15.2.0 → openmodule-16.0.0}/openmodule.egg-info/top_level.txt +0 -0
  78. {openmodule-15.2.0 → openmodule-16.0.0}/setup.cfg +0 -0
  79. {openmodule-15.2.0 → openmodule-16.0.0}/setup.py +0 -0
  80. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_alembic_migrations.py +0 -0
  81. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_alert.py +0 -0
  82. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_checks.py +0 -0
  83. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_config.py +0 -0
  84. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_health.py +0 -0
  85. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_interrupt.py +0 -0
  86. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_io_listen.py +0 -0
  87. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_logging.py +0 -0
  88. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_messaging.py +0 -0
  89. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_mockrpcclient.py +0 -0
  90. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_test_alert.py +0 -0
  91. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_test_gate.py +0 -0
  92. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_test_zeromq.py +0 -0
  93. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_charset.py +0 -0
  94. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_cleanup.py +0 -0
  95. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_csv_export.py +0 -0
  96. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_databox.py +0 -0
  97. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_misc_functions.py +0 -0
  98. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_package_reader.py +0 -0
  99. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_schedule.py +0 -0
  100. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_settings.py +0 -0
  101. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_signal.py +0 -0
  102. {openmodule-15.2.0 → openmodule-16.0.0}/tests/test_utils_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openmodule
3
- Version: 15.2.0
3
+ Version: 16.0.0
4
4
  Summary: Libraries for developing the arivo openmodule
5
5
  Home-page: https://gitlab.com/arivo-public/device-python/openmodule.git
6
6
  Author: ARIVO
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  import threading
3
- from typing import TYPE_CHECKING
3
+ from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from openmodule.models.alert import AlertHandleType, AlertMessage, AlertStatus
6
6
 
@@ -83,7 +83,7 @@ class AlertHandler(object):
83
83
  Returns True if alert was send else False"""
84
84
  assert status in [AlertStatus.error, AlertStatus.ok, AlertStatus.offline]
85
85
 
86
- status_dict = {
86
+ status_dict: dict[str, Any] = {
87
87
  "status": status,
88
88
  "alert_meta": (meta or {}),
89
89
  }
@@ -51,7 +51,7 @@ class OpenModuleCore(threading.Thread):
51
51
  self.alerts = AlertHandler(self)
52
52
  self.connection_listener = ConnectionStatusListener(self._messages_internal, self.rpc_client)
53
53
  self._messages_internal.register_handler("healthz", HealthPingMessage, self.health.process_message,
54
- match_type=True, register_schema=False)
54
+ match_type=True)
55
55
 
56
56
  def init_database(self):
57
57
  from openmodule.database.database import Database
@@ -11,6 +11,7 @@ class CustomTypeImportRegistration(type):
11
11
  This metaclass is used to register custom types with their module names.
12
12
  This is used in generating alembic migrations.
13
13
  """
14
+
14
15
  def __new__(cls, name, bases, dct):
15
16
  x = super().__new__(cls, name, bases, dct)
16
17
 
@@ -3,13 +3,12 @@ import shutil
3
3
  import sys
4
4
  import warnings
5
5
  from typing import Optional
6
-
6
+
7
7
  from alembic import command, context
8
8
  from alembic.autogenerate import comparators, renderers
9
9
  from alembic.config import Config
10
10
  from alembic.operations import Operations, MigrateOperation
11
11
  from alembic.runtime.migration import MigrationContext
12
-
13
12
  from sqlalchemy import MetaData, DateTime, inspect, text
14
13
  from sqlalchemy.engine import Engine
15
14
 
@@ -21,13 +20,26 @@ class PreUpgradeOp(MigrateOperation):
21
20
  @classmethod
22
21
  def pre_upgrade(cls, operations, **kw):
23
22
  migration_context: MigrationContext = operations.migration_context
24
- basename, _ = os.path.splitext(os.path.basename(migration_context.connection.engine.url.database))
23
+ db_file = migration_context.connection.engine.url.database
24
+ db_dir = os.path.dirname(db_file)
25
+ basename, _ = os.path.splitext(os.path.basename(db_file))
25
26
  timestamp = utcnow().strftime('%Y%m%d%H%M%S')
26
27
  migration_revision = migration_context.get_current_revision()
27
28
  filename = f"{basename}_{timestamp}_{migration_revision}.sqlite3.backup"
28
29
 
29
- shutil.copy(migration_context.connection.engine.url.database,
30
- os.path.join(os.path.dirname(migration_context.connection.engine.url.database), filename))
30
+ for file in os.listdir(db_dir):
31
+ if file.startswith(f"{basename}_") and file.endswith(".sqlite3.backup"):
32
+ try:
33
+ file_timestamp = file[len(basename) + 1:len(basename) + 15]
34
+ file_time = utcnow().strptime(file_timestamp, '%Y%m%d%H%M%S')
35
+ if (utcnow() - file_time).seconds < 60:
36
+ # No backup if we created one in the last 60 seconds
37
+ break
38
+ except Exception:
39
+ # ignore any issues here
40
+ pass
41
+ else:
42
+ shutil.copy(db_file, os.path.join(db_dir, filename))
31
43
 
32
44
  op = PreUpgradeOp(**kw)
33
45
  return operations.invoke(op)
@@ -18,7 +18,6 @@ from sentry_sdk.utils import qualname_from_function
18
18
  from openmodule import sentry
19
19
  from openmodule.config import settings
20
20
  from openmodule.models.base import ZMQMessage
21
- from openmodule.utils.schema import Schema
22
21
 
23
22
 
24
23
  class DummyExecutor(Executor):
@@ -142,22 +141,11 @@ class MessageDispatcher:
142
141
  except ValueError:
143
142
  pass
144
143
 
145
- def _is_test_handler(self, handler):
146
- """
147
- this breaks separation a bit by including test specific code in the main module
148
- but it improves developer usability drastically
149
- """
150
- if settings.TESTING and "Mock" in str(handler):
151
- return True
152
- else:
153
- return False
154
-
155
144
  def register_handler(self, topic: str,
156
145
  message_class: type[ZMQMessageSub],
157
146
  handler: Callable[[ZMQMessageSub], None], *,
158
147
  filter: dict | Callable[[dict], bool] | None = None,
159
- match_type=True,
160
- register_schema=True):
148
+ match_type=True):
161
149
  """
162
150
  registers a message handler. without any filters all messages from the topic are
163
151
  sent to the message handler.
@@ -184,8 +172,9 @@ class MessageDispatcher:
184
172
  listener = Listener(message_class, type_, filter, handler)
185
173
  self.listeners[topic].append(listener)
186
174
 
187
- if register_schema and not self._is_test_handler(handler):
188
- Schema.save_message(topic, message_class, handler, filter)
175
+ if settings.TESTING and hasattr(handler, "__global__") and "/tests/" not in handler.__globals__['__file__']:
176
+ # only doc string necessary for functions or lambdas (so not for mocks) outside of tests folder
177
+ assert handler.__doc__, f"You need to describe the message handler {handler} with a doc string"
189
178
 
190
179
  return listener
191
180
 
@@ -243,13 +232,11 @@ class SubscribingMessageDispatcher(MessageDispatcher):
243
232
  message_class: type[ZMQMessageSub],
244
233
  handler: Callable[[ZMQMessageSub], None], *,
245
234
  filter: dict | None = None,
246
- register_schema=True,
247
235
  match_type=True):
248
236
  assert isinstance(topic, str), "channel must be a string"
249
237
 
250
238
  self.subscribe(topic)
251
- return super().register_handler(topic, message_class, handler, filter=filter,
252
- register_schema=register_schema, match_type=match_type)
239
+ return super().register_handler(topic, message_class, handler, filter=filter, match_type=match_type)
253
240
 
254
241
  def unregister_handler(self, listener: Listener):
255
242
  super().unregister_handler(listener)
@@ -34,25 +34,26 @@ class HealthMetricType(StrEnum):
34
34
 
35
35
 
36
36
  class HealthMetric(OpenModuleModel):
37
- id: str = Field(..., description="A identifier for this metric. Must be unique per `package` this check is "
38
- "assigned to, independently of the source service which is returning the metric.")
39
- name: str = Field(..., description="Human readable name of the Metric.")
40
- description: str = Field(..., description="Human-readable description of the metric. The same description will be"
41
- "displayed when the metric is displayed.")
42
- type: HealthMetricType = Field(..., description="The type of this metric. This is used to determine how the metric"
43
- "is used.")
44
- labels: list[str] = Field(..., description="List of label names for this metric. In debug and testing mode exactly"
45
- "those labels are required for the metric to be valid. In production"
46
- "wrong labels are still accepted.")
47
- values: dict[str, Any] = Field(
48
- {}, description="Contains the values for this metric, grouped by labels. The labels are "
49
- "represented as a the string of the sorted list of label items. str(list(<label_dict>.items()))"
50
- "'[(<label_name>, <label_value>), (<label_name>, <label_value>), ...]: <metric_value>'")
51
- package: str = Field(..., description="The package this metric is assigned to. It may be your own service"
52
- "but can also be any other hardware or software service.")
53
- source: str | None = Field(None,
37
+ id: str = Field(description="A identifier for this metric. Must be unique per `package` this check is "
38
+ "assigned to, independently of the source service which is returning the metric.")
39
+ name: str = Field(description="Human readable name of the Metric.")
40
+ description: str = Field(description="Human-readable description of the metric. The same description will be"
41
+ "displayed when the metric is displayed.")
42
+ type: HealthMetricType = Field(description="The type of this metric. This is used to determine how the metric"
43
+ "is used.")
44
+ labels: list[str] = Field(description="List of label names for this metric. In debug and testing mode exactly"
45
+ "those labels are required for the metric to be valid. In production"
46
+ "wrong labels are still accepted.")
47
+ values: dict[str, Any] = Field(default={},
48
+ description="Contains the values for this metric, grouped by labels. The labels are "
49
+ "represented as a the string of the sorted list of label items. "
50
+ "str(list(<label_dict>.items()))'[(<label_name>, <label_value>), "
51
+ "(<label_name>, <label_value>), ...]: <metric_value>'")
52
+ package: str = Field(description="The package this metric is assigned to. It may be your own service"
53
+ "but can also be any other hardware or software service.")
54
+ source: str | None = Field(default=None,
54
55
  description="The service which reported the health check. This value is "
55
- "automatically set by health service and must not be set manually.")
56
+ "automatically set by health service and must not be set manually.")
56
57
 
57
58
 
58
59
  class HealthCheckState(StrEnum):
@@ -62,25 +63,24 @@ class HealthCheckState(StrEnum):
62
63
 
63
64
 
64
65
  class HealthCheck(OpenModuleModel):
65
- id: str = Field(..., description="A identifier for this check. Must be unique per `package` this check is "
66
- "assigned to, independently of the source service which is returning the check.")
67
- package: str = Field(..., description="The package this check is assigned to. It may be your own service"
68
- "but can also be any other hardware or software service.")
69
- state: HealthCheckState = Field(HealthCheckState.no_data, description="The state of this check")
70
- name: str = Field(..., description="Human readable name of the check. ")
71
- description: str | None = Field(...,
72
- description="Human-readable description of the check. The same description"
73
- "will be displayed when the check fails or succeeds. Consider this"
74
- "when choosing the wording.")
75
- message: str | None = Field(None,
66
+ id: str = Field(description="A identifier for this check. Must be unique per `package` this check is "
67
+ "assigned to, independently of the source service which is returning the check.")
68
+ package: str = Field(description="The package this check is assigned to. It may be your own service"
69
+ "but can also be any other hardware or software service.")
70
+ state: HealthCheckState = Field(default=HealthCheckState.no_data, description="The state of this check")
71
+ name: str = Field(description="Human readable name of the check. ")
72
+ description: str | None = Field(description="Human-readable description of the check. The same description"
73
+ "will be displayed when the check fails or succeeds. Consider this"
74
+ "when choosing the wording.")
75
+ message: str | None = Field(default=None,
76
76
  description="A human readable text which is displayed next to the check in the "
77
- "user interface. Can be used to convey more information. Please "
78
- "note that no efforts are taken to notify the user about changes "
79
- "in this value (i.e. no new e-mails are sent if the message "
80
- "changes).")
81
- source: str | None = Field(None,
77
+ "user interface. Can be used to convey more information. Please "
78
+ "note that no efforts are taken to notify the user about changes "
79
+ "in this value (i.e. no new e-mails are sent if the message "
80
+ "changes).")
81
+ source: str | None = Field(default=None,
82
82
  description="The service which reported the health check. This value is "
83
- "automatically set by health service and must not be set manually.")
83
+ "automatically set by health service and must not be set manually.")
84
84
 
85
85
 
86
86
  class HealthPongMessage(ZMQMessage):
@@ -262,6 +262,9 @@ class Healthz:
262
262
  metric.values[self._labels_string(labels)] = value
263
263
 
264
264
  def process_message(self, _: HealthPingMessage):
265
+ """
266
+ Process a health ping message and respond with the current health status.
267
+ """
265
268
  checks = list(self.checks.values())
266
269
  status = "ok"
267
270
  message = None
@@ -4,6 +4,7 @@ import random
4
4
  import string
5
5
  import time
6
6
  from contextlib import contextmanager
7
+ from typing import Generator
7
8
 
8
9
  import orjson
9
10
  import zmq
@@ -42,7 +43,10 @@ def wait_for_connection(dispatcher: MessageDispatcher, pub_socket=None, pub_lock
42
43
  """
43
44
  waits until the pub_socket's messages can be received at the dispatcher.
44
45
  This dispatcher needs to run in a separate thread as this method is blocking
46
+ :param dispatcher: message dispatcher
47
+ :param pub_socket: socket for message publishing (defaults to the core's pub socket, if None)
45
48
  :param pub_lock: optionally locks the lock during publishing
49
+ :param timeout: timeout in sec
46
50
  """
47
51
 
48
52
  if pub_socket is None:
@@ -56,13 +60,14 @@ def wait_for_connection(dispatcher: MessageDispatcher, pub_socket=None, pub_lock
56
60
  received = False
57
61
 
58
62
  def handler(_):
63
+ """ handler to set received true"""
59
64
  nonlocal received
60
65
  received = True
61
66
 
62
67
  # topic needs to start with __ping because these messages are ignored by the deeplog
63
68
  random_topic = "__ping_" + "".join(random.choices(string.ascii_letters, k=10))
64
69
 
65
- listener = dispatcher.register_handler(random_topic, ZMQMessage, handler, register_schema=False, match_type=False)
70
+ listener = dispatcher.register_handler(random_topic, ZMQMessage, handler, match_type=False)
66
71
 
67
72
  check_delay = 0.1
68
73
  check_iterations = int(timeout / check_delay) + 1
@@ -82,7 +87,7 @@ def wait_for_connection(dispatcher: MessageDispatcher, pub_socket=None, pub_lock
82
87
 
83
88
 
84
89
  @contextmanager
85
- def receive_message_from_socket(sub_socket: zmq.Socket) -> tuple[str | None, dict | None]:
90
+ def receive_message_from_socket(sub_socket: zmq.Socket) -> Generator[tuple[str | None, dict | None], None, None]:
86
91
  """
87
92
  :param sub_socket: zmq socket to receive
88
93
  Enters a sentry trace for the received message. For this reason, the function should be used as a context manager.
@@ -50,24 +50,24 @@ class AccessCheckRejectReason(StrEnum): # see DEV-A-916
50
50
 
51
51
 
52
52
  class AccessCheckAccess(OpenModuleModel):
53
- id: str # id of the access
54
- group_id: str | None = None # id for group-passback, in Digimon Contract ID, optional for 3rd Party Backends
55
- group_limit: int | None = Field(None, ge=1) # group_limit must be greater or equal 1
56
- customer_id: str | None = None # in Digimon Customer ID, optional for 3rd Party Backends
57
- car_id: str | None = None # in Digimon Customer Car ID, optional for 3rd Party Backends
58
- source: str # Access Service providing the access
59
- access_infos: dict = {} # Infos, which are added everywhere for later usage
60
- parksettings_id: str | None = None # None is no cost entries and access at any gate
61
- clearing: str | None = None # Clearing
62
- clearing_infos: dict | None = None # Infos, which are added everywhere for later usage when using clearing
63
- category: AccessCategory # category used for sorting and eventlog
64
- used_medium: Medium # medium of the access
65
- access_data: dict # complete access data, will be used only for display and debug purposes
66
- valid_from: Datetime | None = None # access is valid from this time. Only used for reservations
67
- valid_to: Datetime | None = None # access is valid until this time. Only used for reservations
68
-
69
- accepted: bool # if access service decided access can enter
70
- reject_reason: AccessCheckRejectReason | None = None # only if not accepted: reason for not accepted
53
+ id: str # id of the access
54
+ group_id: str | None = None # id for group-passback, in Digimon Contract ID, optional for 3rd Party Backends
55
+ group_limit: int | None = Field(default=None, ge=1) # group_limit must be greater or equal 1
56
+ customer_id: str | None = None # in Digimon Customer ID, optional for 3rd Party Backends
57
+ car_id: str | None = None # in Digimon Customer Car ID, optional for 3rd Party Backends
58
+ source: str # Access Service providing the access
59
+ access_infos: dict = {} # Infos, which are added everywhere for later usage
60
+ parksettings_id: str | None = None # None is no cost entries and access at any gate
61
+ clearing: str | None = None # Clearing
62
+ clearing_infos: dict | None = None # Infos, which are added everywhere for later usage when using clearing
63
+ category: AccessCategory # category used for sorting and eventlog
64
+ used_medium: Medium # medium of the access
65
+ access_data: dict # complete access data, will be used only for display and debug purposes
66
+ valid_from: Datetime | None = None # access is valid from this time. Only used for reservations
67
+ valid_to: Datetime | None = None # access is valid until this time. Only used for reservations
68
+
69
+ accepted: bool # if access service decided access can enter
70
+ reject_reason: AccessCheckRejectReason | None = None # only if not accepted: reason for not accepted
71
71
  # additional infos shown in events if reject reason is "custom". Required if reject_reason == "custom"
72
72
  supplementary_infos: str | None = None
73
73
 
@@ -5,13 +5,13 @@ from datetime import datetime
5
5
  from decimal import Decimal
6
6
  from enum import StrEnum
7
7
  from json.encoder import ESCAPE_ASCII
8
- from typing import Annotated
8
+ from typing import Annotated, Optional, Any, Self
9
9
 
10
10
  import orjson
11
11
  from pydantic_core import PydanticUndefined
12
12
  import zmq
13
13
  from dateutil.tz import UTC
14
- from pydantic import AfterValidator, ConfigDict, Field, BaseModel, RootModel
14
+ from pydantic import AfterValidator, ConfigDict, Field, BaseModel, PrivateAttr, RootModel, model_validator, field_validator
15
15
 
16
16
  from openmodule import sentry
17
17
  from openmodule.config import run_checks, settings
@@ -158,11 +158,18 @@ def datetime_to_timestamp(dt: datetime):
158
158
 
159
159
 
160
160
  class ZMQMessage(OpenModuleModel):
161
+ _ALLOW_CUSTOM_TIMESTAMP: bool = PrivateAttr(default=False) # set to true for tools scripts, where you need to set the timestamp manually
162
+ _using_default_timestamp: bool = PrivateAttr()
163
+
161
164
  timestamp: Datetime = Field(default_factory=lambda: utcnow())
162
165
  name: str = Field(default_factory=lambda: settings.NAME)
163
166
  type: str
164
167
  baggage: str | None = None
165
- sentry_trace: str | None = Field(None, alias="sentry-trace")
168
+ sentry_trace: str | None = Field(
169
+ default=None,
170
+ validation_alias="sentry-trace",
171
+ serialization_alias="sentry-trace",
172
+ )
166
173
 
167
174
  def publish_on_topic(self, pub_socket: zmq.Socket, topic: str):
168
175
  assert isinstance(topic, str), "topic must be a string"
@@ -175,6 +182,28 @@ class ZMQMessage(OpenModuleModel):
175
182
  data["timestamp"] = datetime_to_timestamp(data["timestamp"])
176
183
  return data
177
184
 
185
+ @classmethod
186
+ def model_validate(cls, obj: Any, *, context: Optional[Any] = None, **kwargs) -> Self:
187
+ context = {**(context or {}), "from_model_validate": True}
188
+ return super().model_validate(obj, context=context, **kwargs)
189
+
190
+ @model_validator(mode="before")
191
+ @classmethod
192
+ def check_default_timestamp(cls, data: dict):
193
+ cls._using_default_timestamp = "timestamp" not in data
194
+ return data
195
+
196
+ @field_validator("timestamp", mode="before")
197
+ @classmethod
198
+ def check_custom_timestamp(cls, v, info):
199
+ from_model_validate = (info.context or {}).get("from_model_validate", False)
200
+ using_default_timestamp = cls._using_default_timestamp
201
+ custom_constructor_timestamp_allowed = cls._ALLOW_CUSTOM_TIMESTAMP
202
+
203
+ if not using_default_timestamp and not custom_constructor_timestamp_allowed and not from_model_validate:
204
+ raise ValueError("timestamp must not be set manually")
205
+ return v
206
+
178
207
 
179
208
  class Direction(StrEnum):
180
209
  UNKNOWN = ""
@@ -16,25 +16,23 @@ class PresenceMedia(OpenModuleModel):
16
16
  class PresenceBaseData(OpenModuleModel):
17
17
  vehicle_id: int
18
18
  source: str
19
- present_area_name: str = Field(..., alias="present-area-name")
19
+ present_area_name: str = Field(
20
+ validation_alias="present-area-name",
21
+ serialization_alias="present-area-name",
22
+ )
20
23
  last_update: Datetime
21
24
  gateway: Gateway
22
25
  medium: PresenceMedia
23
26
  make_model: MakeModel | None = None
24
27
  all_ids: PresenceAllIds
25
28
  enter_direction: EnterDirection = EnterDirection.unknown
26
- enter_time: Datetime
29
+ enter_time: Datetime | None = None
27
30
 
28
31
  model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
29
32
 
30
- @model_validator(mode="before")
31
- @classmethod
32
- def set_enter_time_default(cls, values_dict: dict) -> dict:
33
- """for backward compatibility, set enter_time based on vehicle_id"""
34
- if values_dict.get("enter_time") is None:
35
- utc_datetime = datetime.datetime.fromtimestamp(values_dict["vehicle_id"] / 1e9, datetime.timezone.utc)
36
- values_dict["enter_time"] = utc_datetime # timestamp from vehicle_id
37
- return values_dict
33
+ def model_post_init(self, *_) -> None:
34
+ if self.enter_time is None:
35
+ self.enter_time = datetime.datetime.fromtimestamp(self.vehicle_id / 1e9, datetime.timezone.utc)
38
36
 
39
37
 
40
38
  class PresenceBaseMessage(PresenceBaseData, ZMQMessage):
@@ -44,21 +42,34 @@ class PresenceBaseMessage(PresenceBaseData, ZMQMessage):
44
42
  class PresenceBackwardMessage(PresenceBaseMessage):
45
43
  type: str = "backward"
46
44
  unsure: bool = False
47
- leave_time: Datetime = Field(..., alias="leave-time")
45
+ leave_time: Datetime = Field(
46
+ validation_alias="leave-time",
47
+ serialization_alias="leave-time",
48
+ )
48
49
  bidirectional_inverse: bool = False
49
50
 
50
51
 
51
52
  class PresenceForwardMessage(PresenceBaseMessage):
52
53
  type: str = "forward"
53
54
  unsure: bool = False
54
- leave_time: Datetime = Field(..., alias="leave-time")
55
+ leave_time: Datetime = Field(
56
+ validation_alias="leave-time",
57
+ serialization_alias="leave-time",
58
+ )
55
59
  bidirectional_inverse: bool = False
56
60
 
57
61
 
58
62
  class PresenceLeaveMessage(PresenceBaseMessage):
59
63
  type: str = "leave"
60
- num_presents: int = Field(0, alias="num-presents")
61
- leave_time: Datetime = Field(..., alias="leave-time")
64
+ num_presents: int = Field(
65
+ default=0,
66
+ validation_alias="num-presents",
67
+ serialization_alias="num-presents",
68
+ )
69
+ leave_time: Datetime = Field(
70
+ validation_alias="leave-time",
71
+ serialization_alias="leave-time",
72
+ )
62
73
 
63
74
  @model_validator(mode="before")
64
75
  @classmethod
@@ -2,10 +2,10 @@ import logging
2
2
  import threading
3
3
  import warnings
4
4
  from collections import namedtuple
5
- from typing import Callable, TypeVar
5
+ from typing import Callable, TypeVar, Any
6
6
 
7
7
  import zmq
8
- from pydantic import ValidationError, BaseModel
8
+ from pydantic import ValidationError
9
9
  from sentry_sdk.utils import qualname_from_function
10
10
 
11
11
  from openmodule import sentry
@@ -16,14 +16,22 @@ from openmodule.messaging import get_sub_socket, receive_message_from_socket
16
16
  from openmodule.models.rpc import RPCResponse, RPCRequest, RPCServerError
17
17
  from openmodule.rpc.common import channel_to_request_topic, channel_to_response_topic
18
18
  from openmodule.threading import get_thread_wrapper
19
- from openmodule.utils.schema import Schema
20
19
 
21
20
  CallbackEntry = namedtuple("CallbackEntry", ["timestamp", "result"])
22
21
  HandlerEntry = namedtuple("HandlerEntry", ["request_class", "response_class", "handler"])
23
22
 
23
+ FilterFunction = Callable[[Any, Any, Any], bool]
24
+ """
25
+ Each filter function must have 3 parameters:
26
+ - request: OpenModuleModel | None
27
+ - message: RPCRequest | None
28
+ - handler: Callable[[OpenModuleModel, dict[str, Any]], OpenModuleModel | dict[str, Any]] | None
29
+ (NOTE: Use 'None' if the parameter is ignored.)
30
+ """
31
+
24
32
 
25
33
  def gateway_filter(gate=None, direction=None):
26
- def _filter(request, message, handler):
34
+ def _filter(request, _message, _handler):
27
35
  gateway = request.get("gateway")
28
36
  if not gateway:
29
37
  return False
@@ -34,14 +42,14 @@ def gateway_filter(gate=None, direction=None):
34
42
 
35
43
 
36
44
  class Filter:
37
- def __init__(self, filter, channel, type):
45
+ def __init__(self, filter: FilterFunction, channel, type):
38
46
  self.filter = filter
39
47
  self.channel = channel
40
48
  self.type = type
41
49
 
42
50
  def check(self, request, message, handler, channel):
43
51
  if (self.channel is None or channel == self.channel) and (self.type is None or self.type == message.type):
44
- if self.filter(request=request, message=message, handler=handler):
52
+ if self.filter(request, message, handler):
45
53
  return True
46
54
  return False
47
55
  # filter does not apply
@@ -51,7 +59,6 @@ class Filter:
51
59
  RequestType = TypeVar("RequestType")
52
60
  ResponseType = TypeVar("ResponseType")
53
61
 
54
-
55
62
  class RPCServer(object):
56
63
  def __init__(self, context, config=None, *, filter_resource=True, executor=None):
57
64
  """
@@ -89,12 +96,12 @@ class RPCServer(object):
89
96
  "This is ok for debugging / testing containers, but must not happen in production."
90
97
  )
91
98
 
92
- def add_filter(self, filter: Callable[..., bool], channel=None, type=None):
99
+ def add_filter(self, filter: FilterFunction, channel=None, type=None):
93
100
  """
94
- :param filter: filter function with parameters (keyword arguments):
95
- request: OpenModuleModel
96
- message: RPCRequest
97
- handler: Callable[[OpenModuleModel, dict[str, Any]], OpenModuleModel | dict[str, Any]]
101
+ :param filter: filter function with parameters (arguments in order, use 'None' if ignored):
102
+ - request: OpenModuleModel | None
103
+ - message: RPCRequest | None
104
+ - handler: Callable[[OpenModuleModel, dict[str, Any]], OpenModuleModel | dict[str, Any]] | None
98
105
  :param channel: specific channel to filter or all channels if None
99
106
  :param type: specific type to filter or all types if None
100
107
  """
@@ -111,8 +118,7 @@ class RPCServer(object):
111
118
  def register_handler(self, channel: str, type: str,
112
119
  request_class: type[RequestType],
113
120
  response_class: type[ResponseType],
114
- handler: Callable[[RequestType, BaseModel], ResponseType | dict | None],
115
- register_schema=True):
121
+ handler: Callable[[RequestType, RPCRequest], ResponseType | dict | None]):
116
122
  """
117
123
  :param channel: rpc channel you want to subscribe to, patterns/wildcards are not supported
118
124
  :param type: the request type
@@ -122,7 +128,6 @@ class RPCServer(object):
122
128
  if the handler returns a dict which contains the key "status",
123
129
  then the value of "status" will be used as the rpc response status.
124
130
  There are no other reserved keys except "status".
125
- :param register_schema: register the schema and models of this rpc during testing to create an automatic doc
126
131
  """
127
132
  if (channel, type) in self.handlers:
128
133
  raise ValueError(f"handler for {channel}:{type} is already registered")
@@ -132,17 +137,18 @@ class RPCServer(object):
132
137
  traced_handler = sentry.trace(f"rpc_handler.{qualname_from_function(handler)}")(handler)
133
138
  self.handlers[(channel, type)] = HandlerEntry(request_class, response_class, traced_handler)
134
139
  self.registered_channels = set(x[0] for x in self.handlers.keys())
135
- if register_schema:
136
- Schema.save_rpc(channel, type, request_class, response_class, handler)
140
+ if settings.TESTING and hasattr(handler, "__global__") and "/tests/" not in handler.__globals__['__file__']:
141
+ # only doc string necessary for functions or lambdas (so not for mocks) outside of tests folder
142
+ assert handler.__doc__, f"You need to describe the RPC handler {handler} with a doc string"
137
143
 
138
144
  def _channel_from_topic(self, topic: str) -> str:
139
145
  return topic.split("-", 2)[-1]
140
146
 
141
- def should_process_message(self, request, message, handler, channel: str):
147
+ def should_process_message(self, *args):
142
148
  if self.filters:
143
149
  result = True
144
150
  for filter in self.filters:
145
- ok = filter.check(request=request, message=message, handler=handler, channel=channel)
151
+ ok = filter.check(*args)
146
152
  if ok:
147
153
  return True
148
154
  # filter applies and is not ok
@@ -11,6 +11,7 @@ import orjson
11
11
  import requests
12
12
  import sentry_sdk.integrations.logging
13
13
  import sentry_sdk.tracing
14
+ import sentry_sdk.utils
14
15
  import urllib3.exceptions
15
16
  from sentry_sdk.consts import EndpointType
16
17
  from sentry_sdk.envelope import Envelope
@@ -1,11 +1,18 @@
1
1
  from pydantic import field_validator, Field
2
+ from pydantic import TypeAdapter
2
3
 
3
4
  from openmodule.models.base import OpenModuleModel
4
5
 
5
6
 
6
7
  class Replacement(OpenModuleModel):
7
- c_from: str = Field(alias="from")
8
- c_to: str = Field(alias="to")
8
+ c_from: str = Field(
9
+ validation_alias="from",
10
+ serialization_alias="from",
11
+ )
12
+ c_to: str = Field(
13
+ validation_alias="to",
14
+ serialization_alias="to",
15
+ )
9
16
 
10
17
  @field_validator("c_from")
11
18
  @classmethod
@@ -48,7 +55,9 @@ class CharsetConverter:
48
55
  def _build_charset(allowed: str, replacements: list | tuple) -> Charset:
49
56
  return Charset(
50
57
  allowed=allowed,
51
- replacements=({"from": f, "to": t} for f, t in replacements)
58
+ replacements=TypeAdapter(list[Replacement]).validate_python(
59
+ {"from": f, "to": t} for f, t in replacements
60
+ )
52
61
  )
53
62
 
54
63