home · contact · privacy
Overhaul as_dict generation to avoid endless nesting of objects.
authorChristian Heller <c.heller@plomlompom.de>
Thu, 20 Jun 2024 20:36:36 +0000 (22:36 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Thu, 20 Jun 2024 20:36:36 +0000 (22:36 +0200)
plomtask/days.py
plomtask/db.py
plomtask/http.py
plomtask/processes.py
tests/conditions.py
tests/days.py
tests/utils.py

index 0bd942cbd8804b8900a3271b9f4f0e4881f105ec..68cf989643924a42b1ef8b48cc04a63434efe6b9 100644 (file)
@@ -28,7 +28,9 @@ class Day(BaseModel[str]):
     def as_dict(self) -> dict[str, object]:
         """Return self as (json.dumps-coompatible) dict."""
         d = super().as_dict
-        d['todos'] = [t.as_dict for t in self.todos]
+        assert isinstance(d['_library'], dict)
+        d['todos'] = [t.as_dict_into_reference(d['_library'])
+                      for t in self.todos]
         return d
 
     @classmethod
index cce2630cd58bfb8bf7283c2eb0d2f45006d8ba26..b3f1db00986b1142f5f31be34060864840ab5bdc 100644 (file)
@@ -274,24 +274,58 @@ class BaseModel(Generic[BaseModelId]):
 
     @property
     def as_dict(self) -> dict[str, object]:
-        """Return self as (json.dumps-coompatible) dict."""
-        d: dict[str, object] = {'id': self.id_}
+        """Return self as (json.dumps-compatible) dict."""
+        library: dict[str, dict[str | int, object]] = {}
+        d: dict[str, object] = {'id': self.id_, '_library': library}
+        for to_save in self.to_save:
+            attr = getattr(self, to_save)
+            if hasattr(attr, 'as_dict_into_reference'):
+                d[to_save] = attr.as_dict_into_reference(library)
+            else:
+                d[to_save] = attr
         if len(self.to_save_versioned) > 0:
             d['_versioned'] = {}
-        for k in self.to_save:
-            attr = getattr(self, k)
-            if hasattr(attr, 'as_dict'):
-                d[k] = attr.as_dict
-            d[k] = attr
         for k in self.to_save_versioned:
             attr = getattr(self, k)
             assert isinstance(d['_versioned'], dict)
             d['_versioned'][k] = attr.history
         for r in self.to_save_relations:
             attr_name = r[2]
-            d[attr_name] = [x.as_dict for x in getattr(self, attr_name)]
+            l: list[int | str] = []
+            for rel in getattr(self, attr_name):
+                l += [rel.as_dict_into_reference(library)]
+            d[attr_name] = l
         return d
 
+    def as_dict_into_reference(self,
+                               library: dict[str, dict[str | int, object]]
+                               ) -> int | str:
+        """Return self.id_ while writing .as_dict into library."""
+        def into_library(library: dict[str, dict[str | int, object]],
+                         cls_name: str,
+                         id_: str | int,
+                         d: dict[str, object]
+                         ) -> None:
+            if cls_name not in library:
+                library[cls_name] = {}
+            if id_ in library[cls_name]:
+                if library[cls_name][id_] != d:
+                    msg = 'Unexpected inequality of entries for ' +\
+                            f'_library at: {cls_name}/{id_}'
+                    raise HandledException(msg)
+            else:
+                library[cls_name][id_] = d
+        as_dict = self.as_dict
+        assert isinstance(as_dict['_library'], dict)
+        for cls_name, dict_of_objs in as_dict['_library'].items():
+            for id_, obj in dict_of_objs.items():
+                into_library(library, cls_name, id_, obj)
+        del as_dict['_library']
+        assert self.id_ is not None
+        into_library(library, self.__class__.__name__, self.id_, as_dict)
+        assert isinstance(as_dict['id'], (int, str))
+        return as_dict['id']
+
     # cache management
     # (we primarily use the cache to ensure we work on the same object in
     # memory no matter where and how we retrieve it, e.g. we don't want
index 7c7fbd408edeb47dd48b9a4a04f93beca085f70a..3d1dd5036f909a553e6678f171fcace7c379cafc 100644 (file)
@@ -17,7 +17,6 @@ from plomtask.db import DatabaseConnection, DatabaseFile
 from plomtask.processes import Process, ProcessStep, ProcessStepsNode
 from plomtask.conditions import Condition
 from plomtask.todos import Todo
-from plomtask.db import BaseModel
 
 TEMPLATES_DIR = 'templates'
 
@@ -42,15 +41,20 @@ class TaskServer(HTTPServer):
     def ctx_to_json(ctx: dict[str, object]) -> str:
         """Render ctx into JSON string."""
         def walk_ctx(node: object) -> Any:
-            if isinstance(node, BaseModel):
+            if hasattr(node, 'as_dict_into_reference'):
+                if hasattr(node, 'id_') and node.id_ is not None:
+                    return node.as_dict_into_reference(library)
+            if hasattr(node, 'as_dict'):
                 return node.as_dict
             if isinstance(node, (list, tuple)):
                 return [walk_ctx(x) for x in node]
             if isinstance(node, HandledException):
                 return str(node)
             return node
+        library: dict[str, dict[str | int, object]] = {}
         for k, v in ctx.items():
             ctx[k] = walk_ctx(v)
+        ctx['_library'] = library
         return json_dumps(ctx)
 
     def render(self, ctx: dict[str, object], tmpl_name: str = '') -> str:
@@ -143,7 +147,7 @@ class TaskHandler(BaseHTTPRequestHandler):
                    tmpl_name: str,
                    code: int = 200
                    ) -> None:
-        """Send HTML as proper HTTP response."""
+        """Send ctx as proper HTTP response."""
         body = self.server.render(ctx, tmpl_name)
         self.send_response(code)
         for header_tuple in self.server.headers:
index d007d0f0ff14303e7668146323f0b2384a35cf55..ebe781e8fb83e09699e38532c0c2c7b3a9a2e1f2 100644 (file)
@@ -51,8 +51,9 @@ class Process(BaseModel[int], ConditionsRelations):
     def as_dict(self) -> dict[str, object]:
         """Return self as (json.dumps-coompatible) dict."""
         d = super().as_dict
-        d['explicit_steps'] = [s.as_dict for s in self.explicit_steps]
-        d['suppressed_steps'] = [s.as_dict for s in self.suppressed_steps]
+        assert isinstance(d['_library'], dict)
+        d['explicit_steps'] = [s.as_dict_into_reference(d['_library'])
+                               for s in self.explicit_steps]
         return d
 
     @classmethod
index af5f661d809995e26bdcc681c4dcff73f0fdb7a6..7fdd4d44823d5b80b5a788267f82d26a8c53fa12 100644 (file)
@@ -53,9 +53,7 @@ class TestsWithServer(TestCaseWithServer):
              'is_active': is_active,
              '_versioned': {
                  'title': {},
-                 'description': {}
-                 }
-             }
+                 'description': {}}}
         titles = titles if titles else []
         descriptions = descriptions if descriptions else []
         assert isinstance(d['_versioned'], dict)
@@ -80,12 +78,14 @@ class TestsWithServer(TestCaseWithServer):
                            'disabled_processes': [],
                            'enabling_processes': [],
                            'disabling_processes': [],
-                           'condition': cond}
+                           'condition': cond['id'],
+                           '_library': {'Condition': self.as_refs([cond])}}
         self.check_json_get('/condition?id=1', expected_single)
         # … full /conditions
         expected_all: dict[str, object]
-        expected_all = {'conditions': [cond],
-                        'sort_by': 'title', 'pattern': ''}
+        expected_all = {'conditions': self.as_id_list([cond]),
+                        'sort_by': 'title', 'pattern': '',
+                        '_library': {'Condition': self.as_refs([cond])}}
         self.check_json_get('/conditions', expected_all)
         # test effect of invalid POST to existing Condition on /condition
         self.check_post({}, '/condition?id=1', 400)
@@ -93,17 +93,20 @@ class TestsWithServer(TestCaseWithServer):
         # test effect of POST changing title and activeness
         post = {'title': 'bar', 'description': 'oof', 'is_active': True}
         self.check_post(post, '/condition?id=1', 302)
-        assert isinstance(expected_single['condition'], dict)
-        expected_single['condition']['_versioned']['title'][1] = 'bar'
-        expected_single['condition']['is_active'] = True
+        assert isinstance(cond['_versioned'], dict)
+        cond['_versioned']['title'][1] = 'bar'
+        cond['is_active'] = True
         self.check_json_get('/condition?id=1', expected_single)
         # test deletion POST's effect on …
         self.check_post({'delete': ''}, '/condition?id=1', 302, '/conditions')
         cond = self.cond_as_dict()
-        expected_single['condition'] = cond
+        assert isinstance(expected_single['_library'], dict)
+        assert isinstance(expected_single['_library']['Condition'], dict)
+        expected_single['_library']['Condition'] = self.as_refs([cond])
         self.check_json_get('/condition?id=1', expected_single)
         # … full /conditions
         expected_all['conditions'] = []
+        expected_all['_library'] = {}
         self.check_json_get('/conditions', expected_all)
 
     def test_do_GET_condition(self) -> None:
@@ -122,25 +125,29 @@ class TestsWithServer(TestCaseWithServer):
         cond = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
         proc_1 = self.proc_as_dict(conditions=[cond], disables=[cond])
         proc_2 = self.proc_as_dict(2, 'B', blockers=[cond], enables=[cond])
-        expected_single = {'is_new': False,
-                           'enabled_processes': [proc_1],
-                           'disabled_processes': [proc_2],
-                           'enabling_processes': [proc_2],
-                           'disabling_processes': [proc_1],
-                           'condition': cond}
-        self.check_json_get('/condition?id=1', expected_single)
+        expected = {'is_new': False,
+                    'enabled_processes': self.as_id_list([proc_1]),
+                    'disabled_processes': self.as_id_list([proc_2]),
+                    'enabling_processes': self.as_id_list([proc_2]),
+                    'disabling_processes': self.as_id_list([proc_1]),
+                    'condition': cond['id'],
+                    '_library': {'Condition': self.as_refs([cond]),
+                                 'Process': self.as_refs([proc_1, proc_2])}}
+        self.check_json_get('/condition?id=1', expected)
 
     def test_do_GET_conditions(self) -> None:
         """Test GET /conditions."""
         # test empty result on empty DB, default-settings on empty params
         expected_json: dict[str, object] = {'conditions': [],
                                             'sort_by': 'title',
-                                            'pattern': ''}
+                                            'pattern': '',
+                                            '_library': {}}
         self.check_json_get('/conditions', expected_json)
         # test on meaningless non-empty params (incl. entirely un-used key)
         expected_json = {'conditions': [],
                          'sort_by': 'title',  # nonsense "foo" defaulting
-                         'pattern': 'bar'}  # preserved despite zero effect
+                         'pattern': 'bar',  # preserved despite zero effect
+                         '_library': {}}
         self.check_json_get('/conditions?sort_by=foo&pattern=bar&foo=x',
                             expected_json)
         # test non-empty result, automatic (positive) sorting by title
@@ -154,25 +161,32 @@ class TestsWithServer(TestCaseWithServer):
         cond_2 = self.cond_as_dict(2, titles=['bar'], descriptions=['rab'])
         cond_3 = self.cond_as_dict(3, True, ['baz'], ['zab'])
         cons = [cond_2, cond_3, cond_1]
-        expected_json = {'conditions': cons, 'sort_by': 'title', 'pattern': ''}
+        expected_json = {'conditions': self.as_id_list(cons),
+                         'sort_by': 'title',
+                         'pattern': '',
+                         '_library': {'Condition': self.as_refs(cons)}}
         self.check_json_get('/conditions', expected_json)
         # test other sortings
         # (NB: by .is_active has two items of =False, their order currently
         # is not explicitly made predictable, so mail fail until we do)
-        expected_json['conditions'] = [cond_1, cond_3, cond_2]
+        expected_json['conditions'] = self.as_id_list([cond_1, cond_3, cond_2])
         expected_json['sort_by'] = '-title'
         self.check_json_get('/conditions?sort_by=-title', expected_json)
-        expected_json['conditions'] = [cond_1, cond_2, cond_3]
+        expected_json['conditions'] = self.as_id_list([cond_1, cond_2, cond_3])
         expected_json['sort_by'] = 'is_active'
         self.check_json_get('/conditions?sort_by=is_active', expected_json)
-        expected_json['conditions'] = [cond_3, cond_1, cond_2]
+        expected_json['conditions'] = self.as_id_list([cond_3, cond_1, cond_2])
         expected_json['sort_by'] = '-is_active'
         self.check_json_get('/conditions?sort_by=-is_active', expected_json)
         # test pattern matching on title
-        expected_json = {'conditions': [cond_2, cond_3],
-                         'sort_by': 'title', 'pattern': 'ba'}
+        cons = [cond_2, cond_3]
+        expected_json = {'conditions': self.as_id_list(cons),
+                         'sort_by': 'title', 'pattern': 'ba',
+                         '_library': {'Condition': self.as_refs(cons)}}
         self.check_json_get('/conditions?pattern=ba', expected_json)
         # test pattern matching on description
-        expected_json['conditions'] = [cond_1]
+        expected_json['conditions'] = self.as_id_list([cond_1])
+        assert isinstance(expected_json['_library'], dict)
+        expected_json['_library']['Condition'] = self.as_refs([cond_1])
         expected_json['pattern'] = 'oo'
         self.check_json_get('/conditions?pattern=oo', expected_json)
index d14e0afc58bd358ff92c1a032ea6f0367081acd2..079a0ebee54eb8c26558f2b770152f0236d432e5 100644 (file)
@@ -81,42 +81,48 @@ class TestsWithServer(TestCaseWithServer):
     @staticmethod
     def day_dict(date: str) -> dict[str, object]:
         """Return JSON of Process to expect."""
-        d: dict[str, object] = {'day': {'id': date,
-                                        'comment': '',
-                                        'todos': []},
-                                'top_nodes': [],
-                                'make_type': '',
-                                'enablers_for': {},
-                                'disablers_for': {},
-                                'conditions_present': [],
-                                'processes': []}
-        return d
+        return {'id': date, 'comment': '', 'todos': []}
 
     def test_do_GET_day(self) -> None:
         """Test GET /day basics."""
         # check undefined day
         date = date_in_n_days(0)
-        expected = self.day_dict(date)
+        day = self.day_dict(date)
+        expected: dict[str, object]
+        expected = {'day': date,
+                    'top_nodes': [],
+                    'make_type': '',
+                    'enablers_for': {},
+                    'disablers_for': {},
+                    'conditions_present': [],
+                    'processes': [],
+                    '_library': {'Day': self.as_refs([day])}}
         self.check_json_get('/day', expected)
         # check "today", "yesterday", "tomorrow" days
         self.check_json_get('/day?date=today', expected)
-        expected = self.day_dict(date_in_n_days(1))
+        day = self.day_dict(date_in_n_days(1))
+        expected['day'] = day['id']
+        assert isinstance(expected['_library'], dict)
+        expected['_library']['Day'] = self.as_refs([day])
         self.check_json_get('/day?date=tomorrow', expected)
-        expected = self.day_dict(date_in_n_days(-1))
+        day = self.day_dict(date_in_n_days(-1))
+        expected['day'] = day['id']
+        expected['_library']['Day'] = self.as_refs([day])
         self.check_json_get('/day?date=yesterday', expected)
         # check wrong day strings
         self.check_get('/day?date=foo', 400)
         self.check_get('/day?date=2024-02-30', 400)
         # check defined day
         date = '2024-01-01'
-        expected = self.day_dict(date)
+        day = self.day_dict(date)
+        expected['day'] = day['id']
+        expected['_library']['Day'] = self.as_refs([day])
         self.check_json_get(f'/day?date={date}', expected)
         # check saved day
         post_day = {'day_comment': 'foo', 'make_type': ''}
         self.check_post(post_day, f'/day?date={date}', 302,
                         f'/day?date={date}&make_type=')
-        assert isinstance(expected['day'], dict)
-        expected['day']['comment'] = 'foo'
+        day['comment'] = post_day['day_comment']
         self.check_json_get(f'/day?date={date}', expected)
         # check GET parameter POST not affecting reply to non-parameter GET
         post_day['make_type'] = 'foo'
index ed4101a6c32a52d1e26fadb99c35fd8c44d2178a..3b259b2e3aaa7c202f8b581b6b9167d1e04f1128 100644 (file)
@@ -327,6 +327,24 @@ class TestCaseWithServer(TestCaseWithDB):
         self.server_thread.join()
         super().tearDown()
 
+    @staticmethod
+    def as_id_list(items: list[dict[str, object]]) -> list[int | str]:
+        """Return list of only 'id' fields of items."""
+        id_list = []
+        for item in items:
+            assert isinstance(item['id'], (int, str))
+            id_list += [item['id']]
+        return id_list
+
+    @staticmethod
+    def as_refs(items: list[dict[str, object]]
+                ) -> dict[str, dict[str, object]]:
+        """Return dictionary of items by their 'id' fields."""
+        refs = {}
+        for item in items:
+            refs[str(item['id'])] = item
+        return refs
+
     @staticmethod
     def proc_as_dict(id_: int = 1,
                      title: str = 'A',
@@ -348,10 +366,10 @@ class TestCaseWithServer(TestCaseWithDB):
                  'description': {0: description},
                  'effort': {0: effort}
                  },
-             'conditions': conditions if conditions else [],
-             'disables': disables if disables else [],
-             'enables': enables if enables else [],
-             'blockers': blockers if blockers else []}
+             'conditions': [c['id'] for c in conditions] if conditions else [],
+             'disables': [c['id'] for c in disables] if disables else [],
+             'enables': [c['id'] for c in enables] if enables else [],
+             'blockers': [c['id'] for c in blockers] if blockers else []}
         return d
 
     def check_redirect(self, target: str) -> None: