From 21df71ef1fde304b158da5989692c01f463515b5 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Thu, 20 Jun 2024 22:36:36 +0200 Subject: [PATCH] Overhaul as_dict generation to avoid endless nesting of objects. --- plomtask/days.py | 4 ++- plomtask/db.py | 50 ++++++++++++++++++++++++++------ plomtask/http.py | 10 +++++-- plomtask/processes.py | 5 ++-- tests/conditions.py | 66 ++++++++++++++++++++++++++----------------- tests/days.py | 38 ++++++++++++++----------- tests/utils.py | 26 ++++++++++++++--- 7 files changed, 139 insertions(+), 60 deletions(-) diff --git a/plomtask/days.py b/plomtask/days.py index 0bd942c..68cf989 100644 --- a/plomtask/days.py +++ b/plomtask/days.py @@ -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 diff --git a/plomtask/db.py b/plomtask/db.py index cce2630..b3f1db0 100644 --- a/plomtask/db.py +++ b/plomtask/db.py @@ -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 diff --git a/plomtask/http.py b/plomtask/http.py index 7c7fbd4..3d1dd50 100644 --- a/plomtask/http.py +++ b/plomtask/http.py @@ -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: diff --git a/plomtask/processes.py b/plomtask/processes.py index d007d0f..ebe781e 100644 --- a/plomtask/processes.py +++ b/plomtask/processes.py @@ -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 diff --git a/tests/conditions.py b/tests/conditions.py index af5f661..7fdd4d4 100644 --- a/tests/conditions.py +++ b/tests/conditions.py @@ -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) diff --git a/tests/days.py b/tests/days.py index d14e0af..079a0eb 100644 --- a/tests/days.py +++ b/tests/days.py @@ -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' diff --git a/tests/utils.py b/tests/utils.py index ed4101a..3b259b2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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: -- 2.30.2