Skip to content

Commit 23ff5d1

Browse files
committed
Indicate which package a schema comes from when missing
Fixes: #187 Signed-off-by: Aurélien Bompard <[email protected]>
1 parent 0052817 commit 23ff5d1

File tree

5 files changed

+144
-24
lines changed

5 files changed

+144
-24
lines changed

fedora_messaging/message.py

+55-8
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
import datetime
2828
import json
2929
import logging
30+
import re
3031
import uuid
31-
from importlib.metadata import entry_points
32+
from importlib.metadata import distribution as get_distribution
33+
from importlib.metadata import entry_points, PackageNotFoundError
3234

3335
import jsonschema
3436
import pika
@@ -65,6 +67,7 @@
6567
# Maps string names of message types to classes and back
6668
_schema_name_to_class = {}
6769
_class_to_schema_name = {}
70+
_schema_name_to_package = {}
6871

6972
# Used to load the registry automatically on first use
7073
_registry_loaded = False
@@ -83,18 +86,32 @@ def get_class(schema_name):
8386
Returns:
8487
Message: A sub-class of :class:`Message` to create the message from.
8588
"""
89+
90+
return _get_class_from_headers(dict(fedora_messaging_schema=schema_name))
91+
92+
93+
def _get_class_from_headers(headers):
8694
global _registry_loaded
8795
if not _registry_loaded:
8896
load_message_classes()
8997

98+
schema_name = headers["fedora_messaging_schema"]
9099
try:
91100
return _schema_name_to_class[schema_name]
92101
except KeyError:
102+
schema_package = headers.get("fedora_messaging_schema_package")
103+
if schema_package:
104+
package_text = f"You can install the missing schema from package {schema_package!r}"
105+
else:
106+
package_text = (
107+
"Either install the package with its schema definition or define a schema"
108+
)
109+
93110
_log.warning(
94-
'The schema "%s" is not in the schema registry! Either install '
95-
"the package with its schema definition or define a schema. "
111+
'The schema "%s" is not in the schema registry! %s. '
96112
"Falling back to the default schema...",
97113
schema_name,
114+
package_text,
98115
)
99116
return Message
100117

@@ -124,6 +141,24 @@ def get_name(cls):
124141
) from e
125142

126143

144+
def _get_distribution_from_module(module):
145+
if not module:
146+
return None
147+
module_parts = module.split(".")
148+
while module_parts:
149+
try:
150+
distribution = get_distribution(".".join(module_parts))
151+
try:
152+
distribution_name = distribution.name
153+
except AttributeError: # pragma: no cover
154+
# COMPAT: Python <= 3.9
155+
distribution_name = distribution.metadata["Name"]
156+
except PackageNotFoundError:
157+
return _get_distribution_from_module(".".join(module_parts[:-1]))
158+
# Normalize the name: PEP 503 plus dashes as underscores.
159+
return re.sub(r"[-_.]+", "-", distribution_name).lower().replace("-", "_")
160+
161+
127162
def load_message_classes():
128163
"""Load the 'fedora.messages' entry points and register the message classes."""
129164
try:
@@ -141,6 +176,12 @@ def load_message_classes():
141176
)
142177
_schema_name_to_class[message.name] = cls
143178
_class_to_schema_name[cls] = message.name
179+
try:
180+
module = message.module
181+
except AttributeError: # pragma: no cover
182+
# COMPAT: Python <= 3.8
183+
module = message.pattern.match(message.value).group("module")
184+
_schema_name_to_package[message.name] = _get_distribution_from_module(module)
144185
global _registry_loaded
145186
_registry_loaded = True
146187

@@ -167,7 +208,7 @@ def get_message(routing_key, properties, body):
167208
properties.headers = {}
168209

169210
try:
170-
MessageClass = get_class(properties.headers["fedora_messaging_schema"])
211+
MessageClass = _get_class_from_headers(properties.headers)
171212
except KeyError:
172213
_log.error(
173214
"Message (headers=%r, body=%r) arrived without a schema header."
@@ -320,6 +361,7 @@ class attribute, although this is a convenient approach. Users are
320361
"enum": [DEBUG, INFO, WARNING, ERROR],
321362
},
322363
"fedora_messaging_schema": {"type": "string"},
364+
"fedora_messaging_schema_package": {"type": "string"},
323365
"sent-at": {"type": "string"},
324366
},
325367
}
@@ -347,7 +389,10 @@ def _build_properties(self, headers):
347389
# Consumers use this to determine what schema to use and if they're out
348390
# of date.
349391
headers = headers.copy()
350-
headers["fedora_messaging_schema"] = get_name(self.__class__)
392+
headers["fedora_messaging_schema"] = schema_name = get_name(self.__class__)
393+
schema_package = _schema_name_to_package.get(schema_name)
394+
if schema_package:
395+
headers["fedora_messaging_schema_package"] = schema_package
351396
now = datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0)
352397
headers["sent-at"] = now.isoformat()
353398
headers["fedora_messaging_severity"] = self.severity
@@ -723,9 +768,11 @@ def load_message(message_dict):
723768
jsonschema.validate(message_dict, SERIALIZED_MESSAGE_SCHEMA)
724769
except jsonschema.exceptions.ValidationError as e:
725770
raise ValidationError(e) from e
726-
MessageClass = get_class(
727-
message_dict.get("headers", {}).get("fedora_messaging_schema", "base.message")
728-
)
771+
try:
772+
MessageClass = _get_class_from_headers(message_dict.get("headers", {}))
773+
except KeyError:
774+
# No "fedora_messaging_schema" header
775+
MessageClass = Message
729776
message = MessageClass(
730777
body=message_dict["body"],
731778
topic=message_dict["topic"],

news/187.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Indicate which package a schema comes from when missing

tests/integration/test_api.py

+1
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def test_twisted_consume_halt_consumer(queue_and_binding):
120120
expected_headers = {
121121
"fedora_messaging_severity": 20,
122122
"fedora_messaging_schema": "base.message",
123+
"fedora_messaging_schema_package": "fedora_messaging",
123124
"priority": 0,
124125
"niceness": "very",
125126
}

tests/unit/test_cli.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -703,8 +703,10 @@ def test_save_recorded_messages_when_limit_is_reached(self):
703703
test_recorder = cli.Recorder(2, mock_file)
704704
test_recorder.collect_message(msg1)
705705
mock_file.write.assert_called_with(
706-
'{"body": {"test_key1": "test_value1"}, "headers"'
707-
': {"fedora_messaging_schema": "base.message", "fedora_messaging_severity": 20, '
706+
'{"body": {"test_key1": "test_value1"}, "headers": {'
707+
'"fedora_messaging_schema": "base.message", '
708+
'"fedora_messaging_schema_package": "fedora_messaging", '
709+
'"fedora_messaging_severity": 20, '
708710
'"priority": 0, "sent-at": "2018-11-18T10:11:41+00:00"}, '
709711
'"id": "273ed91d-b8b5-487a-9576-95b9fbdf3eec", '
710712
'"priority": 0, "queue": null, "topic": "test_topic1"}\n'
@@ -716,9 +718,11 @@ def test_save_recorded_messages_when_limit_is_reached(self):
716718
assert the_exception.exit_code == 0
717719
assert test_recorder.counter == 2
718720
mock_file.write.assert_called_with(
719-
'{"body": {"test_key2": "test_value2"}, "headers": '
720-
'{"fedora_messaging_schema": "base.message", "fedora_messaging_severity": '
721-
'20, "priority": 0, "sent-at": "2018-11-18T10:11:41+00:00"}, "id": '
721+
'{"body": {"test_key2": "test_value2"}, "headers": {'
722+
'"fedora_messaging_schema": "base.message", '
723+
'"fedora_messaging_schema_package": "fedora_messaging", '
724+
'"fedora_messaging_severity": 20, '
725+
'"priority": 0, "sent-at": "2018-11-18T10:11:41+00:00"}, "id": '
722726
'"273ed91d-b8b5-487a-9576-95b9fbdf3eec", "priority": 0, "queue": null, '
723727
'"topic": "test_topic2"}\n'
724728
)

tests/unit/test_message.py

+78-11
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,23 @@ def test_missing_headers(self):
6060
)
6161
assert isinstance(received_msg, message.Message)
6262

63+
def test_missing_schema(self, caplog):
64+
"""Assert a missing schema package gives an informative log."""
65+
msg = message.Message()
66+
msg._headers = {
67+
"fedora_messaging_schema": "dummy",
68+
"fedora_messaging_schema_package": "dummy-package",
69+
"fedora_messaging_severity": message.INFO,
70+
}
71+
received_msg = message.get_message(
72+
msg._encoded_routing_key, msg._properties, msg._encoded_body
73+
)
74+
assert isinstance(received_msg, message.Message)
75+
assert caplog.messages == [
76+
'The schema "dummy" is not in the schema registry! You can install the missing schema '
77+
"from package 'dummy-package'. Falling back to the default schema..."
78+
]
79+
6380
@mock.patch.dict(message._class_to_schema_name, {DeprecatedMessage: "deprecated_message_id"})
6481
@mock.patch.dict(message._schema_name_to_class, {"deprecated_message_id": DeprecatedMessage})
6582
def test_deprecated(self, caplog):
@@ -86,6 +103,7 @@ def test_proper_message(self):
86103
test_id = "test id"
87104
test_headers = {
88105
"fedora_messaging_schema": "base.message",
106+
"fedora_messaging_schema_package": "fedora_messaging",
89107
"fedora_messaging_severity": message.WARNING,
90108
}
91109
test_properties = pika.BasicProperties(
@@ -100,9 +118,11 @@ def test_proper_message(self):
100118

101119
test_msg.queue = test_queue
102120
expected_json = (
103-
'{"body": {"test_key": "test_value"}, "headers": {"fedora_messaging_schema": '
104-
'"base.message", "fedora_messaging_severity": 30}, "id": "test id", '
105-
'"priority": 2, "queue": "test queue", "topic": "test topic"}\n'
121+
'{"body": {"test_key": "test_value"}, "headers": {'
122+
'"fedora_messaging_schema": "base.message", '
123+
'"fedora_messaging_schema_package": "fedora_messaging", '
124+
'"fedora_messaging_severity": 30'
125+
'}, "id": "test id", "priority": 2, "queue": "test queue", "topic": "test topic"}\n'
106126
)
107127
assert expected_json == message.dumps(test_msg)
108128

@@ -114,6 +134,7 @@ def test_proper_message_multiple(self):
114134
test_id = "test id"
115135
test_headers = {
116136
"fedora_messaging_schema": "base.message",
137+
"fedora_messaging_schema_package": "fedora_messaging",
117138
"fedora_messaging_severity": message.WARNING,
118139
}
119140
test_properties = pika.BasicProperties(
@@ -128,11 +149,17 @@ def test_proper_message_multiple(self):
128149
test_msg.queue = test_queue
129150
test_msg2.queue = test_queue
130151
expected_json = (
131-
'{"body": {"test_key": "test_value"}, "headers": {"fedora_messaging_schema": '
132-
'"base.message", "fedora_messaging_severity": 30}, "id": "test id", '
152+
'{"body": {"test_key": "test_value"}, "headers": {'
153+
'"fedora_messaging_schema": "base.message", '
154+
'"fedora_messaging_schema_package": "fedora_messaging", '
155+
'"fedora_messaging_severity": 30'
156+
'}, "id": "test id", '
133157
'"priority": 0, "queue": "test queue", "topic": "test topic"}\n'
134-
'{"body": {"test_key": "test_value"}, "headers": {"fedora_messaging_schema": '
135-
'"base.message", "fedora_messaging_severity": 30}, "id": "test id", '
158+
'{"body": {"test_key": "test_value"}, "headers": {'
159+
'"fedora_messaging_schema": "base.message", '
160+
'"fedora_messaging_schema_package": "fedora_messaging", '
161+
'"fedora_messaging_severity": 30'
162+
'}, "id": "test id", '
136163
'"priority": 0, "queue": "test queue", "topic": "test topic"}\n'
137164
)
138165

@@ -152,8 +179,11 @@ class TestMessageLoads:
152179
def test_proper_json(self):
153180
"""Assert loading single message from json work."""
154181
message_json = (
155-
'{"topic": "test topic", "headers": {"fedora_messaging_schema": "base.message", '
156-
'"fedora_messaging_severity": 30}, "id": "test id", "body": '
182+
'{"topic": "test topic", "headers": {'
183+
'"fedora_messaging_schema": "base.message", '
184+
'"fedora_messaging_schema_package": "fedora_messaging", '
185+
'"fedora_messaging_severity": 30'
186+
'}, "id": "test id", "body": '
157187
'{"test_key": "test_value"}, "priority": 2, "queue": "test queue"}\n'
158188
)
159189
messages = message.loads(message_json)
@@ -167,6 +197,7 @@ def test_proper_json(self):
167197
assert 2 == test_message.priority
168198
assert message.WARNING == test_message._headers["fedora_messaging_severity"]
169199
assert "base.message" == test_message._headers["fedora_messaging_schema"]
200+
assert "fedora_messaging" == test_message._headers["fedora_messaging_schema_package"]
170201

171202
def test_improper_json(self):
172203
"""Assert proper exception is raised when improper json is provided."""
@@ -184,10 +215,11 @@ def test_missing_headers(self):
184215
}
185216
test_message = message.load_message(message_dict)
186217
assert test_message._headers["fedora_messaging_schema"] == "base.message"
218+
assert test_message._headers["fedora_messaging_schema_package"] == "fedora_messaging"
187219
assert test_message._headers["fedora_messaging_severity"] == message.INFO
188220
assert "sent-at" in test_message._headers
189221

190-
def test_missing_messaging_schema(self):
222+
def test_missing_messaging_schema_header(self, caplog):
191223
"""Assert the default schema is used when messaging schema is missing."""
192224
message_dict = {
193225
"id": "test id",
@@ -198,6 +230,27 @@ def test_missing_messaging_schema(self):
198230
}
199231
test_message = message.load_message(message_dict)
200232
assert isinstance(test_message, message.Message)
233+
assert caplog.messages == []
234+
235+
def test_missing_messaging_schema(self, caplog):
236+
"""Assert a helpful message is logged when the schema is missing."""
237+
message_dict = {
238+
"id": "test id",
239+
"topic": "test topic",
240+
"headers": {
241+
"fedora_messaging_schema": "dummy",
242+
"fedora_messaging_schema_package": "dummy-package",
243+
"fedora_messaging_severity": 30,
244+
},
245+
"body": {"test_key": "test_value"},
246+
"queue": "test queue",
247+
}
248+
test_message = message.load_message(message_dict)
249+
assert isinstance(test_message, message.Message)
250+
assert caplog.messages == [
251+
'The schema "dummy" is not in the schema registry! You can install the missing schema '
252+
"from package 'dummy-package'. Falling back to the default schema..."
253+
]
201254

202255
def test_missing_body(self):
203256
"""Assert proper exception is raised when body is missing."""
@@ -366,13 +419,16 @@ def test_properties_default(self):
366419
assert "sent-at" in msg._properties.headers
367420
assert "fedora_messaging_schema" in msg._properties.headers
368421
assert msg._properties.headers["fedora_messaging_schema"] == "base.message"
422+
assert "fedora_messaging_schema_package" in msg._properties.headers
423+
assert msg._properties.headers["fedora_messaging_schema_package"] == "fedora_messaging"
369424

370425
def test_headers(self):
371426
msg = message.Message(headers={"foo": "bar"})
372427
assert "foo" in msg._properties.headers
373428
assert msg._properties.headers["foo"] == "bar"
374-
# The fedora_messaging_schema key must also be added when headers are given.
429+
# The fedora_messaging_schema keys must also be added when headers are given.
375430
assert msg._properties.headers["fedora_messaging_schema"] == "base.message"
431+
assert msg._properties.headers["fedora_messaging_schema_package"] == "fedora_messaging"
376432

377433
def test_severity_default_header_set(self):
378434
"""Assert the default severity is placed in the header if unspecified."""
@@ -526,9 +582,16 @@ def flatpaks(self):
526582

527583

528584
@mock.patch.dict(message._class_to_schema_name, {CustomMessage: "custom_id"})
585+
@mock.patch.dict(message._schema_name_to_package, {"custom_id": "custom-package"})
529586
class TestCustomMessage:
530587
"""Tests for a Message subclass that provides filter headers"""
531588

589+
def test_schema_headers(self):
590+
"""Assert schema name and package are placed in the message headers."""
591+
msg = CustomMessage(body={})
592+
assert msg._headers.get("fedora_messaging_schema") == "custom_id"
593+
assert msg._headers.get("fedora_messaging_schema_package") == "custom-package"
594+
532595
def test_usernames(self):
533596
"""Assert usernames are placed in the message headers."""
534597
msg = CustomMessage(body={"users": ["jcline", "abompard"]})
@@ -656,3 +719,7 @@ def test_get_name_autoload_once(self):
656719
with mock.patch.dict(message._class_to_schema_name, {}, clear=True):
657720
with pytest.raises(TypeError):
658721
message.get_name("this.is.not.an.entrypoint")
722+
723+
def test_get_distribution_from_module(self):
724+
"""Assert getting the distribution from a non-existing module does not crash."""
725+
assert message._get_distribution_from_module("does.not.exist") is None

0 commit comments

Comments
 (0)