diff --git a/.github/workflows/pylms-build.yml b/.github/workflows/pylms-build.yml index 3841fea..49a22b2 100644 --- a/.github/workflows/pylms-build.yml +++ b/.github/workflows/pylms-build.yml @@ -26,13 +26,17 @@ jobs: run: | make check-format - name: tests - run: | - make test-ci + uses: coactions/setup-xvfb@v1.0.1 + with: + run: | + make test-ci - name: SonarCloud Scan uses: SonarSource/sonarcloud-github-action@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: build - run: | - make build + uses: coactions/setup-xvfb@v1.0.1 + with: + run: | + make build diff --git a/Makefile b/Makefile index 61f5358..7b6620b 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ build: format test python3 -m build check-format: - python3 -m black --check src/ + python3 -m black --check src/ tests/ format: python3 -m black src/ tests/ diff --git a/README.md b/README.md index 7e2fce0..e154a5b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Requirements * `Python3` * `pip` +* `Tkinter` and `Tk` + * on ubuntu, use `sudo apt-get install python3-tk` * `make` How to build diff --git a/requirements.txt b/requirements.txt index 4f5a5db..3a27f4d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ black >= 24.3.0 pytest >= 8.1.1 -coverage >= 7.4.4 \ No newline at end of file +coverage >= 7.4.4 +# required to run parametrized tests in unittest.TestCase subclasses +parameterized >= 0.9.0 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 1fa563d..3fd75f8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ exclude = tests [options.entry_points] console_scripts = pylms = pylms.__main__:main + pylmsgui = pylms.gui:main [options.extras_require] test = diff --git a/src/pylms/__main__.py b/src/pylms/__main__.py index ca975e8..001285d 100644 --- a/src/pylms/__main__.py +++ b/src/pylms/__main__.py @@ -1,6 +1,7 @@ #!/bin/env python3 import logging +import sys from sys import argv import pylms.pylms from pylms.pylms import list_persons, store_person, update_person, delete_person, link_persons, search_persons diff --git a/src/pylms/gui.py b/src/pylms/gui.py new file mode 100644 index 0000000..3666c71 --- /dev/null +++ b/src/pylms/gui.py @@ -0,0 +1,184 @@ +import io +import logging +import tkinter as tk +from tkinter.scrolledtext import ScrolledText +from typing import Callable + +from pylms.core import Person, Relationship, RelationshipAlias, RelationshipDefinition +import pylms.pylms +from pylms.pylms import IOs, EventListener +from pylms.pylms import list_persons, search_persons, store_person + + +class TkApp: + def __init__(self, window: tk.Tk): + super().__init__() + + self._window = window + self._entry_text: tk.StringVar = tk.StringVar() + self._entry = tk.Entry(self._window, textvariable=self._entry_text) + self._text_area: ScrolledText = ScrolledText(self._window) + self._configure_text_area() + + def init_ui(self): + self._entry.bind("", self._hit_enter) + self._entry.focus_set() + + self._entry.pack() + self._text_area.pack() + + def _configure_text_area(self) -> None: + self._text_area.configure(state="disabled") + + def _is_empty(self): + """source: https://stackoverflow.com/a/38541846""" + return self._text_area.compare("end-1c", "==", "1.0") + + def _execute_text_area_edit_action(self, action: Callable[[tk.Text], None]) -> None: + """ + Execute any "edit" action on self.textarea after putting it back to normal state, to allow the edit action + to have effect, and they restore it to "disabled" state + """ + self._text_area.configure(state="normal") + action(self._text_area) + self._text_area.configure(state="disabled") + + def write_line(self, *s: any) -> None: + prefix = "" if self._is_empty() else "\n" + self._execute_text_area_edit_action(lambda ta: ta.insert(tk.END, prefix + " ".join(map(str, s)))) + + def _clear(self): + self._execute_text_area_edit_action(lambda ta: ta.delete(1.0, tk.END)) + + def _hit_enter(self, *_: any) -> None: + self._clear() + text_input = self._entry_text.get().strip() + + if not text_input: + self.write_line("list_person()...") + list_persons() + return + + create_prefix = "create" + if text_input.startswith(create_prefix): + args = text_input[len(create_prefix) :].strip().split() + match len(args): + case 0: + self.write_line("Too few arguments (0)") + case 1: + self.write_line(f"store_person(firstname={args[0]})") + store_person(firstname=args[0]) + case 2: + self.write_line(f"store_person(firstname={args[0]}, lastname={args[1]})") + store_person(firstname=args[0], lastname=args[1]) + case _ as args_count: + self.write_line(f"Too many arguments ({args_count})") + else: + self.write_line(f"search_persons({text_input})...") + search_persons(text_input) + + +class GuiIOs(IOs): + def __init__(self, gui_manager: TkApp): + self.gui_manager: TkApp = gui_manager + + def _show_relationship(self, person: Person, relationship: Relationship) -> None: + other = relationship.right if relationship.left == person else relationship.left + self.gui_manager.write_line(f" -> {relationship.repr_for(person)} ({other.person_id}) {other}") + + def show_person(self, person: Person) -> None: + created = person.created + self.gui_manager.write_line( + f"({person.person_id})", + f" {person.sex.name}" if person.sex is not None else "", + person, + f"({created.year}-{created.month}-{created.day} {created.hour}-{created.minute}-{created.second})", + ) + + def list_persons(self, resolved_persons: list[(Person, list[Relationship])]) -> None: + for person, rls in sorted(resolved_persons, key=lambda t: t[0].person_id): + self.show_person(person) + for rl in rls: + self._show_relationship(person, rl) + + def select_person(self, persons: list[Person]) -> Person | None: + raise RuntimeError("select_person should not have been called") + + def update_person(self, person_to_update: Person) -> Person: + raise RuntimeError("update_person should not have been called") + + +class GuiEventListener(EventListener): + def __init__(self, tk_app: TkApp): + self.tk_app: TkApp = tk_app + + def creating_person(self, person: Person) -> None: + self.tk_app.write_line(f"Create Person {person}.") + + def deleting_person(self, person_to_delete: Person) -> None: + raise RuntimeError("deleting_person should not have been called") + + def creating_link(self, rl_definition: RelationshipDefinition, person_left: Person, person_right: Person) -> None: + raise RuntimeError("creating_link should not have been called") + + def configured_from_alias(self, person: Person, alias: RelationshipAlias) -> None: + raise RuntimeError("configured_from_alias should not have been called") + + def deleting_relationship(self, relationship, person: Person | None) -> None: + raise RuntimeError("deleting_relationship should not have been called") + + +class GuiLogger: + def __init__(self, gui_manager: TkApp): + self.gui_manager: TkApp = gui_manager + self._handler: logging.StreamHandler | None = None + + def configure(self): + class Foo(io.TextIOBase): + def __init__(self, tk_app: TkApp): + self.tk_app: TkApp = tk_app + + def write(self, __s): + self.tk_app.write_line(__s) + + self._handler = logging.StreamHandler(stream=Foo(self.gui_manager)) + self._handler.setLevel(logging.INFO) + + logger = logging.getLogger("pylms.pylms") + logger.setLevel(logging.INFO) + logger.addHandler(self._handler) + + def unconfigure(self): + logger = logging.getLogger("pylms.pylms") + if self._handler: + logger.removeHandler(self._handler) + del self._handler + + +def _main(window: tk.Tk): + logging.basicConfig(level=logging.WARN) + + tk_app = TkApp(window) + + pylms.pylms.ios = GuiIOs(tk_app) + pylms.pylms.events = GuiEventListener(tk_app) + gui_logger = GuiLogger(tk_app) + try: + gui_logger.configure() + + tk_app.init_ui() + + window.mainloop() + finally: + # nothing is happening after this line so the cleanup below is useless to the program + # however, it saves lots of noise in tests where logging with logger "pylms.pylms" fails because the textarea + # has been destroyed + gui_logger.unconfigure() + + +def main(): + _main(tk.Tk()) + + +if __name__ == "__main__": + main() diff --git a/tests/pylms/gui_test.py b/tests/pylms/gui_test.py new file mode 100644 index 0000000..e115baf --- /dev/null +++ b/tests/pylms/gui_test.py @@ -0,0 +1,325 @@ +""" +How to write unit tests for Tkinter was strongly inspired from this test in cpython's repository: +https://github.com/python/cpython/blob/055c739536ad63b55ad7cd0b91ccacc33064fe11/Lib/test/test_ttk/test_widgets.py +""" + +import logging +import unittest +import tkinter as tk +from tkinter.scrolledtext import ScrolledText +from unittest.mock import patch +from typing import TypeVar, Type +from datetime import datetime +from parameterized import parameterized + +import pytest + +import pylms.pylms +from pylms.pylms import IOs, EventListener +from pylms.gui import TkApp, GuiIOs, GuiEventListener +from pylms.gui import _main +from pylms.core import Person, Relationship, RelationshipDefinition + +# a single TK session is used for all tests in this module +# tests are expected to clean up after themselves with _tear_down() +window: tk.Tk + + +AnyWidget = TypeVar("AnyWidget", bound=tk.Misc) + + +def _find_widget(parent: tk.Misc, widget_type: Type[AnyWidget]) -> AnyWidget | None: + """ + Find the first widget of the specific type, crawling the tree of widgets in sibling-first order + """ + for c in parent.children.values(): + if isinstance(c, widget_type): + return c + if c.children: + return _find_widget(c, widget_type) + + return None + + +def _tk_initial_update(): + # force focus on the window (may not be automatically given by the window manager, eg. under xvfb) + window.focus_force() + window.update_idletasks() + window.update() + + +def _get_text(textarea: tk.Text) -> str: + # ensure any pending change to the textarea has been processed + textarea.update() + return textarea.get("1.0", "end-1c") + + +def _tear_down(): + """ + Run any leftover action and remove any children before running the next test. + Intended to be called from TestCases' tearDown() method + """ + window.update_idletasks() + for c in list(window.children.values()): + c.destroy() + + +def setUpModule(): + """Runs before any test and create the one TK that will be used by all tests""" + global window + window = tk.Tk() + + +def tearDownModule(): + """ + Clean up after all tests have run: run any leftover action and destroy the Tk. + Safety net before executing tests in other modules. + """ + global window + window.update_idletasks() + window.destroy() + del window + + +# mock mainloop to prevent showing the window and blocking until user has interactions with it +@patch("pylms.gui.tk.Tk.mainloop") +class TestMain(unittest.TestCase): + def tearDown(self): + _tear_down() + + def test_main_starts_tk_app(self, mock_tk_app_mainloop): + _main(window) + + mock_tk_app_mainloop.assert_called_once_with() + + def test_main_binds_ios_and_event(self, mock_tk_app_mainloop): + _main(window) + + assert isinstance(pylms.pylms.ios, GuiIOs) + assert isinstance(pylms.pylms.events, GuiEventListener) + + @patch("pylms.gui.TkApp.init_ui") + def test_main_inits_ui(self, mock_init_ui, mock_tk_app_mainloop): + _main(window) + + mock_init_ui.assert_called_once_with() + + +class TestEventListener(unittest.TestCase): + def setUp(self): + # mock mainloop to prevent showing the window and blocking until user has interactions with it + with patch("pylms.gui.tk.Tk.mainloop"): + _main(window) + _tk_initial_update() + + self._text_area: ScrolledText = _find_widget(window, ScrolledText) + self._under_test: EventListener = pylms.pylms.events + + def tearDown(self): + _tear_down() + + del self._under_test + del self._text_area + + def _get_textarea_text(self) -> str: + return _get_text(textarea=self._text_area) + + def test_creating_person_prints_to_textarea(self): + person = Person(person_id=1, firstname="John") + + self._under_test.creating_person(person) + self._text_area.update() + + assert self._get_textarea_text() == f"Create Person {person}." + + def test_deleting_person_not_supported(self): + with pytest.raises(RuntimeError, match="deleting_person should not have been called"): + self._under_test.deleting_person(None) + + def test_creating_link_not_supported(self): + with pytest.raises(RuntimeError, match="creating_link should not have been called"): + self._under_test.creating_link(None, None, None) + + def test_configured_from_alias_not_supported(self): + with pytest.raises(RuntimeError, match="configured_from_alias should not have been called"): + self._under_test.configured_from_alias(None, None) + + def test_deleting_relationship_not_supported(self): + with pytest.raises(RuntimeError, match="deleting_relationship should not have been called"): + self._under_test.deleting_relationship(None, None) + + +class TestIOs(unittest.TestCase): + person1 = Person(person_id=12, firstname="Boo", lastname="Bip", created=datetime(2024, 4, 5, 12, 41, 9)) + person2 = Person(person_id=5, firstname="Acme") + rl_definition = RelationshipDefinition(name="related") + + def setUp(self): + # mock mainloop to prevent showing the window and blocking until user has interactions with it + with patch("pylms.gui.tk.Tk.mainloop"): + _main(window) + _tk_initial_update() + + self._text_area: ScrolledText = _find_widget(window, ScrolledText) + self._under_test: IOs = pylms.pylms.ios + + def tearDown(self): + _tear_down() + + del self._under_test + del self._text_area + + def _get_textarea_text(self) -> str: + return _get_text(textarea=self._text_area) + + def test_show_person_prints_to_textarea(self): + self._under_test.show_person(self.person1) + + assert self._get_textarea_text() == "(12) Boo Bip (2024-4-5 12-41-9)" + + def test_list_persons_prints_to_textarea(self): + self._under_test.list_persons([(self.person1, [Relationship(self.person1, self.person2, self.rl_definition)])]) + + assert self._get_textarea_text() == "(12) Boo Bip (2024-4-5 12-41-9)\n -> related (5) Acme" + + def test_select_person_not_supported(self): + with pytest.raises(RuntimeError, match="select_person should not have been called"): + self._under_test.select_person(None) + + def test_update_person_not_supported(self): + with pytest.raises(RuntimeError, match="update_person should not have been called"): + self._under_test.update_person(None) + + +@patch("pylms.gui.tk.Tk.mainloop") +class TestLogging(unittest.TestCase): + + def setUp(self): + self._under_test: logging.Logger = logging.getLogger("pylms.pylms") + + def tearDown(self): + print("in tearDown") + _tear_down() + + del self._under_test + + @staticmethod + def _get_textarea_text() -> str: + return _get_text(textarea=_find_widget(window, ScrolledText)) + + @parameterized.expand([logging.INFO, logging.WARN, logging.ERROR, logging.CRITICAL]) + def test_prints_to_textarea_logs_above_info(self, mock_mainloop, level): + message = f"this is an {logging.getLevelName(level)} log" + + def do_some_log(): + self._under_test.log(level=level, msg=message) + + mock_mainloop.side_effect = do_some_log + + _main(window) + _tk_initial_update() + + assert TestLogging._get_textarea_text() == message + "\n" + + @parameterized.expand([logging.DEBUG]) + def test_does_not_print_to_textarea_logs_below_info(self, mock_mainloop, level): + message = f"this is an {logging.getLevelName(level)} log" + + def do_some_log(): + self._under_test.log(level=level, msg=message) + + mock_mainloop.side_effect = do_some_log + + _main(window) + _tk_initial_update() + + assert len(TestLogging._get_textarea_text()) == 0 + + +class TestGui(unittest.TestCase): + def setUp(self): + self._under_test: TkApp = TkApp(window) + self._under_test.init_ui() + _tk_initial_update() + + self._entry: tk.Entry = _find_widget(window, tk.Entry) + self._text_area: ScrolledText = _find_widget(window, ScrolledText) + + def tearDown(self): + _tear_down() + + del self._entry + del self._text_area + del self._under_test + + def _user_writes_to_entry(self, s: str) -> None: + self._entry.delete(0, tk.END) + self._entry.insert(0, s) + self._entry.update() + + def _user_hits_return_on_entry(self): + # force focus on the entry, otherwise events are not processed (source https://stackoverflow.com/a/27604905) + self._entry.focus_set() + self._entry.update() + + self._entry.event_generate("") + self._entry.update() + + def _get_textarea_text(self) -> str: + return _get_text(textarea=self._text_area) + + def test_creates_entry_and_scrolledtext_and_focus_on_entry(self): + assert self._entry is not None + assert self._text_area is not None + + @patch("pylms.gui.list_persons") + def test_runs_list_when_users_hits_return_on_empty_entry(self, mock_list_persons): + self._user_hits_return_on_entry() + + assert self._get_textarea_text() == "list_person()..." + mock_list_persons.assert_called_once_with() + + @patch("pylms.gui.search_persons") + def test_runs_search_person_when_users_hits_return_with_non_empty_entry(self, mock_search_persons): + some_text: str = "foo bar 42" + self._user_writes_to_entry(some_text) + self._user_hits_return_on_entry() + + assert self._get_textarea_text() == f"search_persons({some_text})..." + mock_search_persons.assert_called_once_with(some_text) + + @patch("pylms.gui.store_person") + def test_report_error_for_create_text_alone(self, mock_store_person): + some_text: str = "create" + self._user_writes_to_entry(some_text) + self._user_hits_return_on_entry() + + assert self._get_textarea_text() == f"Too few arguments (0)" + assert mock_store_person.call_count == 0 + + @patch("pylms.gui.store_person") + def test_report_error_for_create_text_and_3_words(self, mock_store_person): + some_text: str = "create foo bar 2000" + self._user_writes_to_entry(some_text) + self._user_hits_return_on_entry() + + assert self._get_textarea_text() == f"Too many arguments (3)" + assert mock_store_person.call_count == 0 + + @patch("pylms.gui.store_person") + def test_runs_store_person_for_create_text_and_1_word(self, mock_store_person): + some_text: str = "create bar" + self._user_writes_to_entry(some_text) + self._user_hits_return_on_entry() + + assert self._get_textarea_text() == f"store_person(firstname=bar)" + mock_store_person.assert_called_once_with(firstname="bar") + + @patch("pylms.gui.store_person") + def test_runs_store_person_for_create_text_and_2_words(self, mock_store_person): + some_text: str = "create bar donut" + self._user_writes_to_entry(some_text) + self._user_hits_return_on_entry() + + assert self._get_textarea_text() == f"store_person(firstname=bar, lastname=donut)" + mock_store_person.assert_called_once_with(firstname="bar", lastname="donut") diff --git a/tests/pylms/pymls_test.py b/tests/pylms/pymls_test.py index 9be20cb..2bfaa4b 100644 --- a/tests/pylms/pymls_test.py +++ b/tests/pylms/pymls_test.py @@ -376,7 +376,7 @@ class TestSearchPersonByRelationship: @mark.parametrize( ("search_request", "expected"), [ - ("parent de emma", [(john, [relationships[0], relationships[1],relationships[3]])]), + ("parent de emma", [(john, [relationships[0], relationships[1], relationships[3]])]), ( "enfant de John", [(peter, [relationships[0]]), (emma, [relationships[1], relationships[2]]), (tom, [relationships[3]])], @@ -385,7 +385,10 @@ class TestSearchPersonByRelationship: ("fils de John", [(tom, [relationships[3]])]), ("mère de bill", [(dona, [relationships[4]]), (princess, [relationships[6]])]), ("père de bill", [(elmer, [relationships[5]])]), - ("parent de bill", [(dona, [relationships[4]]), (elmer, [relationships[5]]), (princess, [relationships[6]])]), + ( + "parent de bill", + [(dona, [relationships[4]]), (elmer, [relationships[5]]), (princess, [relationships[6]])], + ), ], ) def test_search_successful(self, mock_storage, mock_ios, search_request, expected): @@ -405,14 +408,7 @@ def test_search_successful(self, mock_storage, mock_ios, search_request, expecte @patch("pylms.pylms.storage") @mark.parametrize( "search_request", - [ - "père de emma", - "mère de peter", - "parent de carine", - "fils de Elmer", - "fils de Dona", - "fils de princess" - ], + ["père de emma", "mère de peter", "parent de carine", "fils de Elmer", "fils de Dona", "fils de princess"], ) def test_no_matching_relationship(self, mock_storage, mock_logger, mock_ios, search_request): mock_storage.read_persons.return_value = self.persons