diff --git a/mobilizon_bots/config/config.py b/mobilizon_bots/config/config.py index d67c240..e3048bd 100644 --- a/mobilizon_bots/config/config.py +++ b/mobilizon_bots/config/config.py @@ -11,7 +11,6 @@ from mobilizon_bots.config.publishers import publisher_names def build_settings( settings_files: List[str] = None, validators: List[Validator] = None ): - SETTINGS_FILE = ( settings_files or os.environ.get("MOBILIZON_BOTS_SETTINGS_FILE") @@ -38,29 +37,8 @@ def build_and_validate_settings(settings_files: List[str] = None): specific for each publisher, notifier and publication strategy. """ - # we first do a preliminary load of the settings without validation. We will later use them to determine which - # publishers, notifiers and strategy have been selected - raw_settings = build_settings(settings_files=settings_files) - - # These validators are always applied - base_validators = ( - [ - # strategy to decide events to publish - Validator("selection.strategy", must_exist=True, is_type_of=str), - Validator( - "publishing.window.begin", - must_exist=True, - is_type_of=int, - gte=0, - lte=24, - ), - Validator( - "publishing.window.end", must_exist=True, is_type_of=int, gte=0, lte=24 - ), - # url of the main Mobilizon instance to download events from - Validator("source.mobilizon.url", must_exist=True, is_type_of=str), - Validator("source.mobilizon.group", must_exist=True, is_type_of=str), - ] + preliminary_validators = ( + [Validator("selection.strategy", must_exist=True, is_type_of=str)] + [ Validator( f"publisher.{publisher_name}.active", must_exist=True, is_type_of=bool @@ -75,6 +53,26 @@ def build_and_validate_settings(settings_files: List[str] = None): ] ) + # we first do a preliminary load of the settings without validation. We will later use them to determine which + # publishers, notifiers and strategy have been selected + raw_settings = build_settings( + settings_files=settings_files, validators=preliminary_validators + ) + + # These validators are always applied + base_validators = [ + # strategy to decide events to publish + Validator( + "publishing.window.begin", must_exist=True, is_type_of=int, gte=0, lte=24, + ), + Validator( + "publishing.window.end", must_exist=True, is_type_of=int, gte=0, lte=24 + ), + # url of the main Mobilizon instance to download events from + Validator("source.mobilizon.url", must_exist=True, is_type_of=str), + Validator("source.mobilizon.group", must_exist=True, is_type_of=str), + ] + preliminary_validators + # we retrieve validators that are conditional. Each module will analyze the settings and decide which validators # need to be applied. strategy_validators = strategies.get_validators(raw_settings) @@ -107,11 +105,11 @@ class CustomConfig: if cls._instance is None: print("Creating the object") cls._instance = super(CustomConfig, cls).__new__(cls) - cls.settings = build_settings(settings_files) + cls.settings = build_and_validate_settings(settings_files) return cls._instance def update(self, settings_files: List[str] = None): - self.settings = build_settings(settings_files) + self.settings = build_and_validate_settings(settings_files) def get_settings(): diff --git a/mobilizon_bots/event/event_selection_strategies.py b/mobilizon_bots/event/event_selection_strategies.py index 3d04c86..21b6817 100644 --- a/mobilizon_bots/event/event_selection_strategies.py +++ b/mobilizon_bots/event/event_selection_strategies.py @@ -89,23 +89,6 @@ class SelectNextEventStrategy(EventSelectionStrategy): return first_unpublished_event -class EventSelector: - def __init__( - self, - published_events: List[MobilizonEvent], - unpublished_events: List[MobilizonEvent], - ): - self.published_events = published_events.sort(key=lambda x: x.begin_datetime) - self.unpublished_events = unpublished_events.sort( - key=lambda x: x.begin_datetime - ) - - def select_event_to_publish( - self, strategy: EventSelectionStrategy - ) -> Optional[MobilizonEvent]: - return strategy._select(self.published_events, self.unpublished_events) - - STRATEGY_NAME_TO_STRATEGY_CLASS = {"next_event": SelectNextEventStrategy} diff --git a/mobilizon_bots/publishers/abstract.py b/mobilizon_bots/publishers/abstract.py index 2a25f36..81e20d2 100644 --- a/mobilizon_bots/publishers/abstract.py +++ b/mobilizon_bots/publishers/abstract.py @@ -163,9 +163,5 @@ class AbstractPublisher(AbstractNotifier): """ Retrieves publisher's message template. """ - template_path = ( - self.conf.msg_template_path - if hasattr(self.conf, "msg_template_path") - else self.default_template_path - ) + template_path = self.conf.msg_template_path or self.default_template_path return JINJA_ENV.get_template(template_path) diff --git a/poetry.lock b/poetry.lock index 12badf0..654c489 100644 --- a/poetry.lock +++ b/poetry.lock @@ -32,7 +32,7 @@ python-versions = ">=3.5" name = "atomicwrites" version = "1.4.0" description = "Atomic file writes." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -40,7 +40,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "attrs" version = "21.2.0" description = "Classes Without Boilerplate" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -110,6 +110,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "iso8601" version = "0.1.14" @@ -140,19 +148,11 @@ category = "main" optional = false python-versions = ">=3.6" -[[package]] -name = "more-itertools" -version = "8.8.0" -description = "More routines for operating on iterables, beyond itertools" -category = "dev" -optional = false -python-versions = ">=3.5" - [[package]] name = "packaging" version = "21.0" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -163,7 +163,7 @@ pyparsing = ">=2.0.2" name = "pluggy" version = "0.13.1" description = "plugin and hook calling mechanisms for python" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -174,7 +174,7 @@ dev = ["pre-commit", "tox"] name = "py" version = "1.10.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -182,7 +182,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "pyparsing" version = "2.4.7" description = "Python parsing module" -category = "dev" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" @@ -196,39 +196,38 @@ python-versions = ">=3.7,<4.0" [[package]] name = "pytest" -version = "5.4.3" +version = "6.2.4" description = "pytest: simple powerful testing with Python" -category = "dev" +category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=17.4.0" +attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} -more-itertools = ">=4.0.0" +iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0" -py = ">=1.5.0" -wcwidth = "*" +pluggy = ">=0.12,<1.0.0a1" +py = ">=1.8.2" +toml = "*" [package.extras] -checkqa-mypy = ["mypy (==v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.10.0" +version = "0.15.1" description = "Pytest support for asyncio." category = "dev" optional = false -python-versions = ">= 3.5" +python-versions = ">= 3.6" [package.dependencies] -pytest = ">=3.0.6" +pytest = ">=5.4.0" [package.extras] -testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=3.64)"] +testing = ["coverage", "hypothesis (>=5.7.1)"] [[package]] name = "python-dateutil" @@ -291,6 +290,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "tortoise-orm" version = "0.17.4" @@ -333,18 +340,10 @@ brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -[[package]] -name = "wcwidth" -version = "0.2.5" -description = "Measures the displayed width of unicode strings in a terminal" -category = "dev" -optional = false -python-versions = "*" - [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "0879accc273f3fc21de4ca7a01682392ed44f1173b17b1e09e884a5b2c7ad51d" +content-hash = "8b2e404c14110b5a47d3ec0480b838c4811ce1ebfbe68170bd60a3d414dbb7c8" [metadata.files] aiosqlite = [ @@ -391,6 +390,10 @@ idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] iso8601 = [ {file = "iso8601-0.1.14-py2.py3-none-any.whl", hash = "sha256:e7e1122f064d626e17d47cd5106bed2c620cb38fe464999e0ddae2b6d2de6004"}, {file = "iso8601-0.1.14.tar.gz", hash = "sha256:8aafd56fa0290496c5edbb13c311f78fa3a241f0853540da09d9363eae3ebd79"}, @@ -435,10 +438,6 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, ] -more-itertools = [ - {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, - {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, -] packaging = [ {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, @@ -460,12 +459,12 @@ pypika-tortoise = [ {file = "pypika_tortoise-0.1.1-py3-none-any.whl", hash = "sha256:860020094e01058ea80602c90d4a843d0a42cffefcf4f3cb1a7f2c18b880c638"}, ] pytest = [ - {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, - {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, + {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, + {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.10.0.tar.gz", hash = "sha256:9fac5100fd716cbecf6ef89233e8590a4ad61d729d1732e0a96b84182df1daaf"}, - {file = "pytest_asyncio-0.10.0-py3-none-any.whl", hash = "sha256:d734718e25cfc32d2bf78d346e99d33724deeba774cc4afdf491530c6184b63b"}, + {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, + {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, @@ -487,6 +486,10 @@ six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] tortoise-orm = [ {file = "tortoise-orm-0.17.4.tar.gz", hash = "sha256:8314a9ae63d3f009bac5da3e7d1f7e3f2de8f9bad43ce1efcd3e059209cd3f9d"}, {file = "tortoise_orm-0.17.4-py3-none-any.whl", hash = "sha256:f052b6089e30748afec88669f1a1cf01a3662cdac81cf5427dfb338839ad6027"}, @@ -500,7 +503,3 @@ urllib3 = [ {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, ] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] diff --git a/pyproject.toml b/pyproject.toml index 891277b..4a35409 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,12 +13,12 @@ Jinja2 = "^2.11.3" requests = "^2.25.1" arrow = "^1.1.0" click = "^8.0.1" +pytest = "^6.2.4" [tool.poetry.dev-dependencies] -asynctest = "^0.13" -pytest = "^5.3" responses = "^0.13.3" -pytest-asyncio = "^0.10" +pytest-asyncio = "^0.15.1" +asynctest = "^0.13.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/test_config_singleton.py b/tests/config/test_config_singleton.py new file mode 100644 index 0000000..e0b71c4 --- /dev/null +++ b/tests/config/test_config_singleton.py @@ -0,0 +1,15 @@ +from mobilizon_bots.config.config import get_settings, update_settings_files + + +def test_singleton(): + config_1 = get_settings() + config_2 = get_settings() + assert id(config_1) == id(config_2) + + +def test_singleton_update(): + config_1 = get_settings() + config_2 = update_settings_files([]) + config_3 = get_settings() + assert id(config_1) != id(config_2) + assert id(config_2) == id(config_3) diff --git a/tests/config/test_validation.py b/tests/config/test_validation.py new file mode 100644 index 0000000..8c8d04b --- /dev/null +++ b/tests/config/test_validation.py @@ -0,0 +1,40 @@ +import dynaconf +import pkg_resources +import pytest + +from mobilizon_bots.config.config import update_settings_files + + +@pytest.fixture +def invalid_settings_file(tmp_path, toml_content): + file = tmp_path / "tmp.toml" + file.write_text(toml_content) + return file + + +@pytest.mark.parametrize("toml_content", ["invalid toml["]) +def test_update_failure_invalid_toml(invalid_settings_file): + with pytest.raises(dynaconf.vendor.toml.decoder.TomlDecodeError): + update_settings_files([invalid_settings_file.absolute()]) + + +@pytest.mark.parametrize("toml_content", [""]) +def test_update_failure_invalid_preliminary_config(invalid_settings_file): + with pytest.raises(dynaconf.validator.ValidationError): + update_settings_files([invalid_settings_file.absolute()]) + + +@pytest.mark.parametrize( + "invalid_toml,pattern_in_exception", + [ + ["config_with_strategy.toml", "publisher.*.active"], + ["config_with_preliminary.toml", "publishing.window.begin"], + ["config_with_invalid_telegram.toml", "token"], + ], +) +def test_update_failure_config_without_publishers(invalid_toml, pattern_in_exception): + with pytest.raises(dynaconf.validator.ValidationError) as e: + update_settings_files( + [pkg_resources.resource_filename("tests.resources.config", invalid_toml)] + ) + assert e.match(pattern_in_exception) diff --git a/tests/event/test_strategies.py b/tests/event/test_strategies.py index ca050a8..8ce2289 100644 --- a/tests/event/test_strategies.py +++ b/tests/event/test_strategies.py @@ -34,7 +34,8 @@ def mock_publication_window(publication_window): ) -def test_window_no_event(): +@pytest.mark.parametrize("current_hour", [15]) +def test_window_no_event(mock_arrow_now): selected_event = SelectNextEventStrategy().select([], []) assert selected_event is None @@ -105,6 +106,55 @@ def test_window_simple_event_found( assert selected_event is unpublished_events[0] +@pytest.mark.parametrize("current_hour", [15]) +@pytest.mark.parametrize("strategy_name", ["next_event"]) +def test_window_simple_no_published_events( + event_generator, set_strategy, mock_arrow_now, +): + "Testing that if no event is published, the function takes the first available unpublished event" + unpublished_events = [ + event_generator( + published=False, + begin_date=arrow.Arrow(year=2021, month=1, day=5, hour=11, minute=30), + ), + event_generator( + published=False, + begin_date=arrow.Arrow(year=2021, month=1, day=5, hour=11, minute=50), + ), + ] + + selected_event = select_event_to_publish([], unpublished_events) + assert selected_event is unpublished_events[0] + + +@pytest.mark.parametrize("current_hour", [15]) +@pytest.mark.parametrize("strategy_name", ["next_event"]) +def test_window_simple_event_too_recent( + event_generator, set_strategy, mock_arrow_now, +): + "Testing that if an event has been published too recently, no event is selected for publication" + unpublished_events = [ + event_generator( + published=False, + begin_date=arrow.Arrow(year=2021, month=1, day=5, hour=11, minute=30), + ), + event_generator( + published=False, + begin_date=arrow.Arrow(year=2021, month=1, day=5, hour=11, minute=50), + ), + ] + + published_events = [ + event_generator( + published=True, + publication_time={"telegram": arrow.now().shift(minutes=-5)}, + ) + ] + + selected_event = select_event_to_publish(published_events, unpublished_events) + assert selected_event is None + + @pytest.mark.parametrize("current_hour", [15]) @pytest.mark.parametrize( "desired_break_window_days,days_passed_from_publication", [[1, 2], [2, 10], [4, 4]] diff --git a/tests/resources/config/__init__.py b/tests/resources/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/resources/config/config_with_invalid_telegram.toml b/tests/resources/config/config_with_invalid_telegram.toml new file mode 100644 index 0000000..bd3b73b --- /dev/null +++ b/tests/resources/config/config_with_invalid_telegram.toml @@ -0,0 +1,46 @@ + +[default.selection] +strategy = "next_event" + + +[default.source.mobilizon] +url="https://mobilizon.it/api" +group="tech_workers_coalition_italia" + + + +[default.publishing.window] +begin=12 +end=24 + +[default.selection.strategy_options] +break_between_events_in_minutes =1 + + +[default.publisher.telegram] +active=true +chat_id="xxx" +token="xxx" +username="xxx" + +[default.publisher.facebook] +active=false +[default.publisher.zulip] +active=false +[default.publisher.twitter] +active=false +[default.publisher.mastodon] +active=false + + +[default.notifier.telegram] +active=true +chat_id="xxx" +misspelled_token="xxx" +username="xxx" +[default.notifier.zulip] +active=false +[default.notifier.twitter] +active=false +[default.notifier.mastodon] +active=false diff --git a/tests/resources/config/config_with_preliminary.toml b/tests/resources/config/config_with_preliminary.toml new file mode 100644 index 0000000..ba43c9e --- /dev/null +++ b/tests/resources/config/config_with_preliminary.toml @@ -0,0 +1,27 @@ + +[default.selection] +strategy = "next_event" + + +[default.publisher.telegram] +active=true + + +[default.publisher.facebook] +active=false +[default.publisher.zulip] +active=false +[default.publisher.twitter] +active=false +[default.publisher.mastodon] +active=false + + +[default.notifier.telegram] +active=true +[default.notifier.zulip] +active=false +[default.notifier.twitter] +active=false +[default.notifier.mastodon] +active=false diff --git a/tests/resources/config/config_with_strategy.toml b/tests/resources/config/config_with_strategy.toml new file mode 100644 index 0000000..1c5a075 --- /dev/null +++ b/tests/resources/config/config_with_strategy.toml @@ -0,0 +1,3 @@ + +[default.selection] +strategy = "next_event"