diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 746d8a1c43..ba3911b3d9 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3476,16 +3476,16 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): elif query: self.caller.msg(f"No prototype named '{query}' was found.") else: - self.caller.msg(f"No prototypes found.") + self.caller.msg("No prototypes found.") def _list_prototypes(self, key=None, tags=None): """Display prototypes as a list, optionally limited by key/tags. """ - table = protlib.list_prototypes(self.caller, key=key, tags=tags) - if not table: - return True - EvMore( - self.caller, str(table), exit_on_lastpage=True, justify_kwargs=False, - ) + protlib.list_prototypes(self.caller, key=key, tags=tags) + # if not table: + # return True + # EvMore( + # self.caller, str(table), exit_on_lastpage=True, justify_kwargs=False, + # ) @interactive def _update_existing_objects(self, caller, prototype_key, quiet=False): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 9e99a766fe..b82760c745 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -15,6 +15,7 @@ from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB from evennia.typeclasses.attributes import Attribute from evennia.utils.create import create_script +from evennia.utils.evmore import EvMore from evennia.utils.utils import ( all_from_module, make_iter, @@ -393,6 +394,7 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories) else: db_matches = DbPrototype.objects.all().order_by("id") + if key: # exact or partial match on key db_matches = ( @@ -425,8 +427,8 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators return matches elif return_iterators: # trying to get the entire set of prototypes - we must paginate - # we must paginate the result of trying to fetch the entire set - db_pages = Paginator(db_matches, 500) + # the result instead of trying to fetch the entire set at once + db_pages = Paginator(db_matches, 20) return module_prototypes, db_pages else: # full fetch, no pagination @@ -447,6 +449,74 @@ def search_objects_with_prototype(prototype_key): return ObjectDB.objects.get_by_tag(key=prototype_key, category=PROTOTYPE_TAG_CATEGORY) +class PrototypeEvMore(EvMore): + """ + Listing 1000+ prototypes can be very slow. So we customize EvMore to + display an EvTable per paginated page rather than to try creating an + EvTable for the entire dataset and then paginate it. + """ + + def __init__(self, *args, **kwargs): + """Store some extra properties on the EvMore class""" + self.show_non_use = kwargs.pop("show_non_use", False) + self.show_non_edit = kwargs.pop("show_non_edit", False) + super().__init__(*args, **kwargs) + + def page_formatter(self, page): + """Input is a queryset page from django.Paginator""" + caller = self._caller + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + + for prototype in page: + lock_use = caller.locks.check_lockstring( + caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True + ) + if not self.show_non_use and not lock_use: + continue + if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get("prototype_locks", ""), access_type="edit", default=True + ) + if not self.show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get("prototype_tags", []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + display_tuples.append( + ( + prototype.get("prototype_key", ""), + prototype.get("prototype_desc", ""), + "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), + ",".join(ptags), + ) + ) + + if not display_tuples: + return "" + + table = [] + width = 78 + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) + table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align="c") + table.reformat_column(3, width=16) + return str(table) + + def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -465,57 +535,66 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed # this allows us to pass lists of empty strings tags = [tag for tag in make_iter(tags) if tag] - # get prototypes for readonly and db-based prototypes - prototypes = search_prototype(key, tags) + if key is not None: + # get specific prototype (one value or exception) + return PrototypeEvMore(caller, [search_prototype(key, tags)], + show_non_use=show_non_use, + show_non_edit=show_non_edit) + else: + # list all + # get prototypes for readonly and db-based prototypes + module_prots, db_prots = search_prototype(key, tags, return_iterators=True) + return PrototypeEvMore(caller, db_prots, + show_non_use=show_non_use, show_non_edit=show_non_edit) - # get use-permissions of readonly attributes (edit is always False) - display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d.get("prototype_key", "")): - lock_use = caller.locks.check_lockstring( - caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True - ) - if not show_non_use and not lock_use: - continue - if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES: - lock_edit = False - else: - lock_edit = caller.locks.check_lockstring( - caller, prototype.get("prototype_locks", ""), access_type="edit", default=True - ) - if not show_non_edit and not lock_edit: - continue - ptags = [] - for ptag in prototype.get("prototype_tags", []): - if is_iter(ptag): - if len(ptag) > 1: - ptags.append("{} (category: {}".format(ptag[0], ptag[1])) - else: - ptags.append(ptag[0]) - else: - ptags.append(str(ptag)) - - display_tuples.append( - ( - prototype.get("prototype_key", ""), - prototype.get("prototype_desc", ""), - "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), - ",".join(ptags), - ) - ) - - if not display_tuples: - return "" - - table = [] - width = 78 - for i in range(len(display_tuples[0])): - table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) - table.reformat_column(0, width=22) - table.reformat_column(1, width=29) - table.reformat_column(2, width=11, align="c") - table.reformat_column(3, width=16) - return table +# # get use-permissions of readonly attributes (edit is always False) +# display_tuples = [] +# for prototype in sorted(prototypes, key=lambda d: d.get("prototype_key", "")): +# lock_use = caller.locks.check_lockstring( +# caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True +# ) +# if not show_non_use and not lock_use: +# continue +# if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES: +# lock_edit = False +# else: +# lock_edit = caller.locks.check_lockstring( +# caller, prototype.get("prototype_locks", ""), access_type="edit", default=True +# ) +# if not show_non_edit and not lock_edit: +# continue +# ptags = [] +# for ptag in prototype.get("prototype_tags", []): +# if is_iter(ptag): +# if len(ptag) > 1: +# ptags.append("{} (category: {}".format(ptag[0], ptag[1])) +# else: +# ptags.append(ptag[0]) +# else: +# ptags.append(str(ptag)) +# +# display_tuples.append( +# ( +# prototype.get("prototype_key", ""), +# prototype.get("prototype_desc", ""), +# "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), +# ",".join(ptags), +# ) +# ) +# +# if not display_tuples: +# return "" +# +# table = [] +# width = 78 +# for i in range(len(display_tuples[0])): +# table.append([str(display_tuple[i]) for display_tuple in display_tuples]) +# table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) +# table.reformat_column(0, width=22) +# table.reformat_column(1, width=29) +# table.reformat_column(2, width=11, align="c") +# table.reformat_column(3, width=16) +# return table def validate_prototype( diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index ae7e5bbfe2..755d7f2e13 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -1100,6 +1100,6 @@ class PrototypeCrashTest(EvenniaTest): for x in range(2): self.create(num_prototypes) # print("Attempting to list prototypes...") - start_time = time() + # start_time = time() self.char1.execute_cmd('spawn/list') # print(f"Prototypes listed in {time()-start_time} seconds.") diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 9814cc6331..37b64abca2 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -176,27 +176,30 @@ class EvMore(object): the caller when the more page exits. Note that this will be using whatever cmdset the user had *before* the evmore pager was activated (so none of the evmore commands will be available when this is run). - page_formatter (callable, optional): If given, this function will be passed the - contents of each extracted page. This is useful when paginating - data consisting something other than a string or a list of strings. Especially - queryset data is likely to always need this argument specified. Note however, - that all size calculations assume this function to return one single line - per element on the page! kwargs (any, optional): These will be passed on to the `caller.msg` method. Examples: + + Basic use: + ``` super_long_text = " ... " EvMore(caller, super_long_text) - + ``` + Paginator + ``` from django.core.paginator import Paginator query = ObjectDB.objects.all() pages = Paginator(query, 10) # 10 objs per page - EvMore(caller, pages) # will repr() each object per line, 10 to a page - - multi_page_table = [ [[..],[..]], ...] - EvMore(caller, multi_page_table, use_evtable=True, - evtable_args=("Header1", "Header2"), - evtable_kwargs={"align": "r", "border": "tablecols"}) + EvMore(caller, pages) + ``` + Every page an EvTable + ``` + from evennia import EvTable + def _to_evtable(page): + table = ... # convert page to a table + return EvTable(*headers, table=table, ...) + EvMore(caller, pages, page_formatter=_to_evtable) + ``` """ self._caller = caller @@ -216,15 +219,17 @@ class EvMore(object): self.exit_on_lastpage = exit_on_lastpage self.exit_cmd = exit_cmd self._exit_msg = "Exited |wmore|n pager." - self._page_formatter = page_formatter self._kwargs = kwargs self._data = None - self._paginator = None + self._pages = [] - self._npages = 1 self._npos = 0 + self._npages = 1 + self._paginator = self.paginator_index + self._page_formatter = str + # set up individual pages for different sessions height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4) self.width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0] @@ -232,155 +237,19 @@ class EvMore(object): self.height = min(10000 // max(1, self.width), height) # does initial parsing of input - self.parse_input(inp) + self.init_pages(inp) # kick things into gear self.start() - # Hooks for customizing input handling and formatting (use if overriding this class) - - def parse_input(self, inp): - """ - Parse the input to figure out the size of the data, how many pages it - consist of and pick the correct paginator mechanism. Override this if - you want to support a new type of input. - - Each initializer should set self._paginator and optionally self._page_formatter - for properly handling the input data. - - """ - if inherits_from(inp, "evennia.utils.evtable.EvTable"): - # an EvTable - self.init_evtable(inp) - elif isinstance(inp, QuerySet): - # a queryset - self.init_queryset(inp) - elif isinstance(inp, Paginator): - self.init_django_paginator(inp) - elif not isinstance(inp, str): - # anything else not a str - self.init_iterable(inp) - elif "\f" in inp: - # string with \f line-break markers in it - self.init_f_str(inp) - else: - # a string - self.init_str(inp) - - def format_page(self, page): - """ - Page formatter. Uses the page_formatter callable by default. - This allows to easier override the class if needed. - - Args: - page (any): A piece of data representing one page to display. This must - be poss - Returns: - """ - return self._page_formatter(page) - - # paginators - responsible for extracting a specific page number - - def paginator_index(self, pageno): - """Paginate to specific, known index""" - return self._data[pageno] - - def paginator_slice(self, pageno): - """ - Paginate by slice. This is done with an eye on memory efficiency (usually for - querysets); to avoid fetching all objects at the same time. - """ - return self._data[pageno * self.height: pageno * self.height + self.height] - - def paginator_django(self, pageno): - """ - Paginate using the django queryset Paginator API. Note that his is indexed from 1. - """ - return self._data.page(pageno + 1) - - # inits for different input types - - def init_evtable(self, table): - """The input is an EvTable.""" - if table.height: - # enforced height of each paged table, plus space for evmore extras - self.height = table.height - 4 - - # convert table to string - text = str(table) - self._justify = False - self._justify_kwargs = None # enforce - self.init_str(text) - - def init_queryset(self, qs): - """The input is a queryset""" - nsize = qs.count() # we assume each will be a line - self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) - self._data = qs - self._paginator = self.paginator_slice - - def init_django_paginator(self, pages): - """ - The input is a django Paginator object. - """ - self._npages = pages.num_pages - self._data = pages - self._paginator = self.paginator_django - - def init_iterable(self, inp): - """The input is something other than a string - convert to iterable of strings""" - inp = make_iter(inp) - nsize = len(inp) - self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) - self._data = inp - self._paginator = self.paginator_slice - - def init_f_str(self, text): - """ - The input contains \f markers. We use \f to indicate the user wants to - enforce their line breaks on their own. If so, we do no automatic - line-breaking/justification at all. - """ - self._data = text.split("\f") - self._npages = len(self._data) - self._paginator = self.paginator_index - - def init_str(self, text): - """The input is a string""" - - if self._justify: - # we must break very long lines into multiple ones. Note that this - # will also remove spurious whitespace. - justify_kwargs = self._justify_kwargs or {} - width = self._justify_kwargs.get("width", self.width) - justify_kwargs["width"] = width - justify_kwargs["align"] = self._justify_kwargs.get("align", "l") - justify_kwargs["indent"] = self._justify_kwargs.get("indent", 0) - - lines = [] - for line in text.split("\n"): - if len(line) > width: - lines.extend(justify(line, **justify_kwargs).split("\n")) - else: - lines.append(line) - else: - # no justification. Simple division by line - lines = text.split("\n") - - self._data = [ - "\n".join(lines[i : i + self.height]) for i in range(0, len(lines), self.height) - ] - self._npages = len(self._data) - self._paginator = self.paginator_index - - # display helpers and navigation + # EvMore functional methods def display(self, show_footer=True): """ Pretty-print the page. """ pos = self._npos - text = self.format_page(self._paginator(pos)) + text = self.page_formatter(self.paginator(pos)) if show_footer: page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages) else: @@ -460,6 +329,173 @@ class EvMore(object): self.page_top() + # default paginators - responsible for extracting a specific page number + + def paginator_index(self, pageno): + """Paginate to specific, known index""" + return self._data[pageno] + + def paginator_slice(self, pageno): + """ + Paginate by slice. This is done with an eye on memory efficiency (usually for + querysets); to avoid fetching all objects at the same time. + """ + return self._data[pageno * self.height: pageno * self.height + self.height] + + def paginator_django(self, pageno): + """ + Paginate using the django queryset Paginator API. Note that his is indexed from 1. + """ + return self._data.page(pageno + 1) + + # default helpers to set up particular input types + + def init_evtable(self, table): + """The input is an EvTable.""" + if table.height: + # enforced height of each paged table, plus space for evmore extras + self.height = table.height - 4 + + # convert table to string + text = str(table) + self._justify = False + self._justify_kwargs = None # enforce + self.init_str(text) + + def init_queryset(self, qs): + """The input is a queryset""" + nsize = qs.count() # we assume each will be a line + self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) + self._data = qs + + def init_django_paginator(self, pages): + """ + The input is a django Paginator object. + """ + self._npages = pages.num_pages + self._data = pages + + def init_iterable(self, inp): + """The input is something other than a string - convert to iterable of strings""" + inp = make_iter(inp) + nsize = len(inp) + self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) + self._data = inp + + def init_f_str(self, text): + """ + The input contains \f markers. We use \f to indicate the user wants to + enforce their line breaks on their own. If so, we do no automatic + line-breaking/justification at all. + """ + self._data = text.split("\f") + self._npages = len(self._data) + + def init_str(self, text): + """The input is a string""" + + if self._justify: + # we must break very long lines into multiple ones. Note that this + # will also remove spurious whitespace. + justify_kwargs = self._justify_kwargs or {} + width = self._justify_kwargs.get("width", self.width) + justify_kwargs["width"] = width + justify_kwargs["align"] = self._justify_kwargs.get("align", "l") + justify_kwargs["indent"] = self._justify_kwargs.get("indent", 0) + + lines = [] + for line in text.split("\n"): + if len(line) > width: + lines.extend(justify(line, **justify_kwargs).split("\n")) + else: + lines.append(line) + else: + # no justification. Simple division by line + lines = text.split("\n") + + self._data = [ + "\n".join(lines[i : i + self.height]) for i in range(0, len(lines), self.height) + ] + self._npages = len(self._data) + + # Hooks for customizing input handling and formatting (override in a child class) + + def init_pages(self, inp): + """ + Initialize the pagination. By default, will analyze input type to determine + how pagination automatically. + + Args: + inp (any): Incoming data to be paginated. By default, handles pagination of + strings, querysets, django.Paginator, EvTables and any iterables with strings. + + Notes: + If overridden, this method must perform the following actions: + - read and re-store `self._data` (the incoming data set) if needed for pagination to work. + - set `self._npages` to the total number of pages. Default is 1. + - set `self._paginator` to a callable that will take a page number 1...N and return + the data to display on that page (not any decorations or next/prev buttons). If only + wanting to change the paginator, override `self.paginator` instead. + - set `self._page_formatter` to a callable that will receive the page from `self._paginator` + and format it with one element per line. Default is `str`. Or override `self.page_formatter` + directly instead. + + By default, helper methods are called that perform these actions + depending on supported inputs. + + """ + if inherits_from(inp, "evennia.utils.evtable.EvTable"): + # an EvTable + self.init_evtable(inp) + elif isinstance(inp, QuerySet): + # a queryset + self.init_queryset(inp) + self._paginator = self.paginator_slice + elif isinstance(inp, Paginator): + self.init_django_paginator(inp) + self._paginator = self.paginator_django + elif not isinstance(inp, str): + # anything else not a str + self.init_iterable(inp) + self._paginator = self.paginator_django + elif "\f" in inp: + # string with \f line-break markers in it + self.init_f_str(inp) + else: + # a string + self.init_str(inp) + + def paginator(self, pageno): + """ + Paginator. The data operated upon is in `self._data`. + + Args: + pageno (int): The page number to view, from 1...N + Returns: + str: The page to display (without any decorations, those are added + by EvMore). + + """ + return self._paginator(pageno) + + def page_formatter(self, page): + """ + Page formatter. Every page passes through this method. Override + it to customize behvaior per-page. A common use is to generate a new + EvTable for every page (this is more efficient than to generate one huge + EvTable across many pages and feed it into EvMore all at once). + + Args: + page (any): A piece of data representing one page to display. This must + + Returns: + str: A ready-formatted page to display. Extra footer with help about + switching to the next/prev page will be added automatically + + """ + return self._page_formatter(page) + + # helper function