mirror of
https://github.com/Kozea/Radicale.git
synced 2025-08-07 18:30:54 +00:00
Merge pull request #1835 from pbiering/test-hooks
Test hooks for rabbitmq+email
This commit is contained in:
commit
d92e06e850
8 changed files with 289 additions and 2 deletions
|
@ -12,6 +12,7 @@
|
||||||
* Fix: catch case where getpwuid is not returning a username
|
* Fix: catch case where getpwuid is not returning a username
|
||||||
* Fix: add support for query without comp-type
|
* Fix: add support for query without comp-type
|
||||||
* Fix: expanded event with dates are missing VALUE=DATE
|
* Fix: expanded event with dates are missing VALUE=DATE
|
||||||
|
* Add: [hook] dryrun: option to disable real hook action for testing, add tests for email+rabbitmq
|
||||||
|
|
||||||
## 3.5.4
|
## 3.5.4
|
||||||
* Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute)
|
* Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute)
|
||||||
|
|
|
@ -1529,6 +1529,14 @@ Available types:
|
||||||
|
|
||||||
Default: `none`
|
Default: `none`
|
||||||
|
|
||||||
|
##### dryrun
|
||||||
|
|
||||||
|
_(> 3.5.4)_
|
||||||
|
|
||||||
|
Dry-Run (do not really trigger hook action)
|
||||||
|
|
||||||
|
Default: `False`
|
||||||
|
|
||||||
##### rabbitmq_endpoint
|
##### rabbitmq_endpoint
|
||||||
|
|
||||||
_(>= 3.2.0)_
|
_(>= 3.2.0)_
|
||||||
|
|
7
config
7
config
|
@ -313,9 +313,16 @@
|
||||||
# Hook types
|
# Hook types
|
||||||
# Value: none | rabbitmq | email
|
# Value: none | rabbitmq | email
|
||||||
#type = none
|
#type = none
|
||||||
|
|
||||||
|
# dry-run (do not really trigger hook action)
|
||||||
|
#dryrun = False
|
||||||
|
|
||||||
|
# hook: rabbitmq
|
||||||
#rabbitmq_endpoint =
|
#rabbitmq_endpoint =
|
||||||
#rabbitmq_topic =
|
#rabbitmq_topic =
|
||||||
#rabbitmq_queue_type = classic
|
#rabbitmq_queue_type = classic
|
||||||
|
|
||||||
|
# hook: email
|
||||||
#smtp_server = localhost
|
#smtp_server = localhost
|
||||||
#smtp_port = 25
|
#smtp_port = 25
|
||||||
#smtp_security = starttls
|
#smtp_security = starttls
|
||||||
|
|
|
@ -427,6 +427,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||||
"help": "hook backend",
|
"help": "hook backend",
|
||||||
"type": str,
|
"type": str,
|
||||||
"internal": hook.INTERNAL_TYPES}),
|
"internal": hook.INTERNAL_TYPES}),
|
||||||
|
("dryrun", {
|
||||||
|
"value": "False",
|
||||||
|
"help": "dry-run (do not really trigger hook action)",
|
||||||
|
"type": bool}),
|
||||||
("rabbitmq_endpoint", {
|
("rabbitmq_endpoint", {
|
||||||
"value": "",
|
"value": "",
|
||||||
"help": "endpoint where rabbitmq server is running",
|
"help": "endpoint where rabbitmq server is running",
|
||||||
|
|
|
@ -1,6 +1,20 @@
|
||||||
# This file is related to Radicale - CalDAV and CardDAV server
|
# This file is related to Radicale - CalDAV and CardDAV server
|
||||||
# for email notifications
|
# for email notifications
|
||||||
# Copyright © 2025-2025 Nate Harris
|
# Copyright © 2025-2025 Nate Harris
|
||||||
|
#
|
||||||
|
# This library is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This library is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
|
@ -596,6 +610,7 @@ class EmailConfig:
|
||||||
password: str,
|
password: str,
|
||||||
from_email: str,
|
from_email: str,
|
||||||
send_mass_emails: bool,
|
send_mass_emails: bool,
|
||||||
|
dryrun: bool,
|
||||||
added_template: MessageTemplate,
|
added_template: MessageTemplate,
|
||||||
removed_template: MessageTemplate):
|
removed_template: MessageTemplate):
|
||||||
self.host = host
|
self.host = host
|
||||||
|
@ -606,6 +621,7 @@ class EmailConfig:
|
||||||
self.password = password
|
self.password = password
|
||||||
self.from_email = from_email
|
self.from_email = from_email
|
||||||
self.send_mass_emails = send_mass_emails
|
self.send_mass_emails = send_mass_emails
|
||||||
|
self.dryrun = dryrun
|
||||||
self.added_template = added_template
|
self.added_template = added_template
|
||||||
self.removed_template = removed_template
|
self.removed_template = removed_template
|
||||||
self.updated_template = added_template # Reuse added template for updated events
|
self.updated_template = added_template # Reuse added template for updated events
|
||||||
|
@ -616,7 +632,7 @@ class EmailConfig:
|
||||||
Return a string representation of the EmailConfig.
|
Return a string representation of the EmailConfig.
|
||||||
"""
|
"""
|
||||||
return f"EmailConfig(host={self.host}, port={self.port}, username={self.username}, " \
|
return f"EmailConfig(host={self.host}, port={self.port}, username={self.username}, " \
|
||||||
f"from_email={self.from_email}, send_mass_emails={self.send_mass_emails})"
|
f"from_email={self.from_email}, send_mass_emails={self.send_mass_emails}, dryrun={self.dryrun})"
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
@ -733,6 +749,10 @@ class EmailConfig:
|
||||||
logger.warning("No valid email addresses found in attendees. Cannot send email.")
|
logger.warning("No valid email addresses found in attendees. Cannot send email.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if self.dryrun is True:
|
||||||
|
logger.warning("Hook 'email': DRY-RUN _send_email / to_addresses=%r", to_addresses)
|
||||||
|
return True
|
||||||
|
|
||||||
# Add headers
|
# Add headers
|
||||||
message = MIMEMultipart("mixed")
|
message = MIMEMultipart("mixed")
|
||||||
message["From"] = self.from_email
|
message["From"] = self.from_email
|
||||||
|
@ -805,6 +825,7 @@ def _read_event(vobject_data: str) -> EmailEvent:
|
||||||
class Hook(BaseHook):
|
class Hook(BaseHook):
|
||||||
def __init__(self, configuration):
|
def __init__(self, configuration):
|
||||||
super().__init__(configuration)
|
super().__init__(configuration)
|
||||||
|
self.dryrun = self.configuration.get("hook", "dryrun")
|
||||||
self.email_config = EmailConfig(
|
self.email_config = EmailConfig(
|
||||||
host=self.configuration.get("hook", "smtp_server"),
|
host=self.configuration.get("hook", "smtp_server"),
|
||||||
port=self.configuration.get("hook", "smtp_port"),
|
port=self.configuration.get("hook", "smtp_port"),
|
||||||
|
@ -814,6 +835,7 @@ class Hook(BaseHook):
|
||||||
password=self.configuration.get("hook", "smtp_password"),
|
password=self.configuration.get("hook", "smtp_password"),
|
||||||
from_email=self.configuration.get("hook", "from_email"),
|
from_email=self.configuration.get("hook", "from_email"),
|
||||||
send_mass_emails=self.configuration.get("hook", "mass_email"),
|
send_mass_emails=self.configuration.get("hook", "mass_email"),
|
||||||
|
dryrun=self.configuration.get("hook", "dryrun"),
|
||||||
added_template=MessageTemplate(
|
added_template=MessageTemplate(
|
||||||
subject="You have been added to an event",
|
subject="You have been added to an event",
|
||||||
body=self.configuration.get("hook", "added_template")
|
body=self.configuration.get("hook", "added_template")
|
||||||
|
@ -844,7 +866,10 @@ class Hook(BaseHook):
|
||||||
:type notification_item: HookNotificationItem
|
:type notification_item: HookNotificationItem
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
logger.debug("Received notification item: %s", notification_item)
|
if self.dryrun:
|
||||||
|
logger.warning("Hook 'email': DRY-RUN received notification_item: %r", vars(notification_item))
|
||||||
|
else:
|
||||||
|
logger.debug("Received notification_item: %r", vars(notification_item))
|
||||||
try:
|
try:
|
||||||
notification_type = HookNotificationItemTypes(value=notification_item.type)
|
notification_type = HookNotificationItemTypes(value=notification_item.type)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
|
@ -1,3 +1,20 @@
|
||||||
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
|
# Copyright © 2020-2024 Tuna Celik <tuna@jakpark.com>
|
||||||
|
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
|
#
|
||||||
|
# This library is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This library is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import pika
|
import pika
|
||||||
from pika.exceptions import ChannelWrongStateError, StreamLostError
|
from pika.exceptions import ChannelWrongStateError, StreamLostError
|
||||||
|
|
||||||
|
@ -14,16 +31,26 @@ class Hook(hook.BaseHook):
|
||||||
self._topic = configuration.get("hook", "rabbitmq_topic")
|
self._topic = configuration.get("hook", "rabbitmq_topic")
|
||||||
self._queue_type = configuration.get("hook", "rabbitmq_queue_type")
|
self._queue_type = configuration.get("hook", "rabbitmq_queue_type")
|
||||||
self._encoding = configuration.get("encoding", "stock")
|
self._encoding = configuration.get("encoding", "stock")
|
||||||
|
self._dryrun = configuration.get("hook", "dryrun")
|
||||||
|
logger.info("Hook 'rabbitmq': endpoint=%r topic=%r queue_type=%r dryrun=%s", self._endpoint, self._topic, self._queue_type, self._dryrun)
|
||||||
|
|
||||||
self._make_connection_synced()
|
self._make_connection_synced()
|
||||||
self._make_declare_queue_synced()
|
self._make_declare_queue_synced()
|
||||||
|
|
||||||
def _make_connection_synced(self):
|
def _make_connection_synced(self):
|
||||||
parameters = pika.URLParameters(self._endpoint)
|
parameters = pika.URLParameters(self._endpoint)
|
||||||
|
if self._dryrun is True:
|
||||||
|
logger.warning("Hook 'rabbitmq': DRY-RUN _make_connection_synced / parameters=%r", parameters)
|
||||||
|
return
|
||||||
|
logger.debug("Hook 'rabbitmq': _make_connection_synced / parameters=%r", parameters)
|
||||||
connection = pika.BlockingConnection(parameters)
|
connection = pika.BlockingConnection(parameters)
|
||||||
self._channel = connection.channel()
|
self._channel = connection.channel()
|
||||||
|
|
||||||
def _make_declare_queue_synced(self):
|
def _make_declare_queue_synced(self):
|
||||||
|
if self._dryrun is True:
|
||||||
|
logger.warning("Hook 'rabbitmq': DRY-RUN _make_declare_queue_synced")
|
||||||
|
return
|
||||||
|
logger.debug("Hook 'rabbitmq': _make_declare_queue_synced")
|
||||||
self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type})
|
self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type})
|
||||||
|
|
||||||
def notify(self, notification_item):
|
def notify(self, notification_item):
|
||||||
|
@ -31,6 +58,9 @@ class Hook(hook.BaseHook):
|
||||||
self._notify(notification_item, True)
|
self._notify(notification_item, True)
|
||||||
|
|
||||||
def _notify(self, notification_item, recall):
|
def _notify(self, notification_item, recall):
|
||||||
|
if self._dryrun is True:
|
||||||
|
logger.warning("Hook 'rabbitmq': DRY-RUN _notify / notification_item: %r", vars(notification_item))
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self._channel.basic_publish(
|
self._channel.basic_publish(
|
||||||
exchange='',
|
exchange='',
|
||||||
|
|
110
radicale/tests/test_hook_email.py
Normal file
110
radicale/tests/test_hook_email.py
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
|
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
|
#
|
||||||
|
# This library is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This library is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Radicale tests related to hook 'email'
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from radicale.tests import BaseTest
|
||||||
|
from radicale.tests.helpers import get_file_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestHooks(BaseTest):
|
||||||
|
"""Tests with hooks."""
|
||||||
|
|
||||||
|
def setup_method(self) -> None:
|
||||||
|
BaseTest.setup_method(self)
|
||||||
|
rights_file_path = os.path.join(self.colpath, "rights")
|
||||||
|
with open(rights_file_path, "w") as f:
|
||||||
|
f.write("""\
|
||||||
|
[permit delete collection]
|
||||||
|
user: .*
|
||||||
|
collection: test-permit-delete
|
||||||
|
permissions: RrWwD
|
||||||
|
|
||||||
|
[forbid delete collection]
|
||||||
|
user: .*
|
||||||
|
collection: test-forbid-delete
|
||||||
|
permissions: RrWwd
|
||||||
|
|
||||||
|
[permit overwrite collection]
|
||||||
|
user: .*
|
||||||
|
collection: test-permit-overwrite
|
||||||
|
permissions: RrWwO
|
||||||
|
|
||||||
|
[forbid overwrite collection]
|
||||||
|
user: .*
|
||||||
|
collection: test-forbid-overwrite
|
||||||
|
permissions: RrWwo
|
||||||
|
|
||||||
|
[allow all]
|
||||||
|
user: .*
|
||||||
|
collection: .*
|
||||||
|
permissions: RrWw""")
|
||||||
|
self.configure({"rights": {"file": rights_file_path,
|
||||||
|
"type": "from_file"}})
|
||||||
|
self.configure({"hook": {"type": "email",
|
||||||
|
"dryrun": "True"}})
|
||||||
|
|
||||||
|
def test_add_event(self, caplog) -> None:
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
"""Add an event."""
|
||||||
|
self.mkcalendar("/calendar.ics/")
|
||||||
|
event = get_file_content("event1.ics")
|
||||||
|
path = "/calendar.ics/event1.ics"
|
||||||
|
self.put(path, event)
|
||||||
|
_, headers, answer = self.request("GET", path, check=200)
|
||||||
|
assert "ETag" in headers
|
||||||
|
assert headers["Content-Type"] == "text/calendar; charset=utf-8"
|
||||||
|
assert "VEVENT" in answer
|
||||||
|
assert "Event" in answer
|
||||||
|
assert "UID:event" in answer
|
||||||
|
found = 0
|
||||||
|
for line in caplog.messages:
|
||||||
|
if line.find("notification_item: {'type': 'upsert'") != -1:
|
||||||
|
found = found | 1
|
||||||
|
if line.find("to_addresses=['janedoe@example.com']") != -1:
|
||||||
|
found = found | 2
|
||||||
|
if line.find("to_addresses=['johndoe@example.com']") != -1:
|
||||||
|
found = found | 4
|
||||||
|
if (found != 7):
|
||||||
|
raise ValueError("Logging misses expected log lines, found=%d", found)
|
||||||
|
|
||||||
|
def test_delete_event(self, caplog) -> None:
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
"""Delete an event."""
|
||||||
|
self.mkcalendar("/calendar.ics/")
|
||||||
|
event = get_file_content("event1.ics")
|
||||||
|
path = "/calendar.ics/event1.ics"
|
||||||
|
self.put(path, event)
|
||||||
|
_, responses = self.delete(path)
|
||||||
|
assert responses[path] == 200
|
||||||
|
_, answer = self.get("/calendar.ics/")
|
||||||
|
assert "VEVENT" not in answer
|
||||||
|
found = 0
|
||||||
|
for line in caplog.messages:
|
||||||
|
if line.find("notification_item: {'type': 'delete'") != -1:
|
||||||
|
found = found | 1
|
||||||
|
if line.find("to_addresses=['janedoe@example.com']") != -1:
|
||||||
|
found = found | 2
|
||||||
|
if line.find("to_addresses=['johndoe@example.com']") != -1:
|
||||||
|
found = found | 4
|
||||||
|
if (found != 7):
|
||||||
|
raise ValueError("Logging misses expected log lines, found=%d", found)
|
102
radicale/tests/test_hook_rabbitmq.py
Normal file
102
radicale/tests/test_hook_rabbitmq.py
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
|
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
|
#
|
||||||
|
# This library is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This library is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Radicale tests related to hook 'rabbitmq'
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from radicale.tests import BaseTest
|
||||||
|
from radicale.tests.helpers import get_file_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestHooks(BaseTest):
|
||||||
|
"""Tests with hooks."""
|
||||||
|
|
||||||
|
def setup_method(self) -> None:
|
||||||
|
BaseTest.setup_method(self)
|
||||||
|
rights_file_path = os.path.join(self.colpath, "rights")
|
||||||
|
with open(rights_file_path, "w") as f:
|
||||||
|
f.write("""\
|
||||||
|
[permit delete collection]
|
||||||
|
user: .*
|
||||||
|
collection: test-permit-delete
|
||||||
|
permissions: RrWwD
|
||||||
|
|
||||||
|
[forbid delete collection]
|
||||||
|
user: .*
|
||||||
|
collection: test-forbid-delete
|
||||||
|
permissions: RrWwd
|
||||||
|
|
||||||
|
[permit overwrite collection]
|
||||||
|
user: .*
|
||||||
|
collection: test-permit-overwrite
|
||||||
|
permissions: RrWwO
|
||||||
|
|
||||||
|
[forbid overwrite collection]
|
||||||
|
user: .*
|
||||||
|
collection: test-forbid-overwrite
|
||||||
|
permissions: RrWwo
|
||||||
|
|
||||||
|
[allow all]
|
||||||
|
user: .*
|
||||||
|
collection: .*
|
||||||
|
permissions: RrWw""")
|
||||||
|
self.configure({"rights": {"file": rights_file_path,
|
||||||
|
"type": "from_file"}})
|
||||||
|
self.configure({"hook": {"type": "rabbitmq",
|
||||||
|
"dryrun": "True"}})
|
||||||
|
|
||||||
|
def test_add_event(self, caplog) -> None:
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
"""Add an event."""
|
||||||
|
self.mkcalendar("/calendar.ics/")
|
||||||
|
event = get_file_content("event1.ics")
|
||||||
|
path = "/calendar.ics/event1.ics"
|
||||||
|
self.put(path, event)
|
||||||
|
_, headers, answer = self.request("GET", path, check=200)
|
||||||
|
assert "ETag" in headers
|
||||||
|
assert headers["Content-Type"] == "text/calendar; charset=utf-8"
|
||||||
|
assert "VEVENT" in answer
|
||||||
|
assert "Event" in answer
|
||||||
|
assert "UID:event" in answer
|
||||||
|
found = False
|
||||||
|
for line in caplog.messages:
|
||||||
|
if line.find("notification_item: {'type': 'upsert'") != -1:
|
||||||
|
found = True
|
||||||
|
if (found is False):
|
||||||
|
raise ValueError("Logging misses expected log line")
|
||||||
|
|
||||||
|
def test_delete_event(self, caplog) -> None:
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
"""Delete an event."""
|
||||||
|
self.mkcalendar("/calendar.ics/")
|
||||||
|
event = get_file_content("event1.ics")
|
||||||
|
path = "/calendar.ics/event1.ics"
|
||||||
|
self.put(path, event)
|
||||||
|
_, responses = self.delete(path)
|
||||||
|
assert responses[path] == 200
|
||||||
|
_, answer = self.get("/calendar.ics/")
|
||||||
|
assert "VEVENT" not in answer
|
||||||
|
found = False
|
||||||
|
for line in caplog.messages:
|
||||||
|
if line.find("notification_item: {'type': 'delete'") != -1:
|
||||||
|
found = True
|
||||||
|
if (found is False):
|
||||||
|
raise ValueError("Logging misses expected log line")
|
Loading…
Add table
Add a link
Reference in a new issue