From c53a9b5770bbe7894210f3199a163f0cd8f0fe0e Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 17 Sep 2012 15:31:50 +0200 Subject: [PATCH] Changed how Objects are searched, using proper Django Q objects instead of hack-y evals to build queries. This has lead to a number of changes to the ObjectDB manager search. Notably there is now no way to supply a "location" to either of the manager search methods anymore. Instead you can now supply the keyword "candidates", a list of objects which should be used to limit the search. This is much more generic than giving location. The higher-level search (like caller.search, reached from commands) have not changed its API, so commands should work the same unless you are using the manager backbone directly. This search function is now using location to create the "candidates" list. Some other things, like matching for "me" and "here" have also been moved up to a level were it can be easily overloaded. "me" and "here" etc were also moved under i18n. As part of this overhaul I implemented the partial_matching algorithm originally asked for by user "Adam_ASE" over IRC. This will allow for (local-only) partial matching of objects. So "big black sword" will now be matched by "bi", "sword", "bi bla" and so on. The partial matcher sits in src.utils.utils.py if one wants to use it for something else. --- src/objects/manager.py | 270 +++++++++---------- src/objects/models.py | 65 +++-- src/objects/objects.py | 30 +-- src/scripts/manager.py | 1 - src/typeclasses/managers.py | 12 +- src/utils/dummyrunner/dummyrunner_actions.py | 2 +- src/utils/utils.py | 49 +++- 7 files changed, 236 insertions(+), 193 deletions(-) diff --git a/src/objects/manager.py b/src/objects/manager.py index a8b45902f6..fc3d1a08cc 100644 --- a/src/objects/manager.py +++ b/src/objects/manager.py @@ -1,17 +1,17 @@ """ Custom manager for Objects. """ +from django.db.models import Q from django.conf import settings #from django.contrib.auth.models import User from django.db.models.fields import exceptions from src.typeclasses.managers import TypedObjectManager from src.typeclasses.managers import returns_typeclass, returns_typeclass_list from src.utils import utils -from src.utils.utils import to_unicode, make_iter - -_ObjAttribute = None +from src.utils.utils import to_unicode, make_iter, string_partial_matching __all__ = ("ObjectManager",) +_GA = object.__getattribute__ # Try to use a custom way to parse id-tagged multimatches. @@ -73,7 +73,7 @@ class ObjectManager(TypedObjectManager): # This returns typeclass since get_object_with_user and get_dbref does. @returns_typeclass - def get_object_with_player(self, search_string): + def get_object_with_player(self, ostring, exact=True, candidates=None): """ Search for an object based on its player's name or dbref. This search @@ -81,127 +81,138 @@ class ObjectManager(TypedObjectManager): the search criterion (e.g. in local_and_global_search). search_string: (string) The name or dbref to search for. """ - dbref = self.dbref(search_string) - if not dbref: - # not a dbref. Search by name. - search_string = to_unicode(search_string).lstrip('*') - return self.filter(db_player__user__username__iexact=search_string) - return self.get_id(dbref) + ostring = to_unicode(ostring).lstrip('*') + # simplest case - search by dbref + dbref = self.dbref(ostring) + if dbref: + return dbref + # not a dbref. Search by name. + cand_restriction = candidates and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) or Q() + if exact: + return self.filter(cand_restriction & Q(db_player__user__username__iexact=ostring)) + else: # fuzzy matching + ply_cands = self.filter(cand_restriction & Q(playerdb__user__username__istartswith=ostring)).values_list("db_key", flat=True) + if candidates: + index_matches = string_partial_matching(ply_cands, ostring, ret_index=True) + return [obj for ind, obj in enumerate(make_iter(candidates)) if ind in index_matches] + else: + return string_partial_matching(ply_cands, ostring, ret_index=False) @returns_typeclass_list - def get_objs_with_key_and_typeclass(self, oname, otypeclass_path): + def get_objs_with_key_and_typeclass(self, oname, otypeclass_path, candidates=None): """ Returns objects based on simultaneous key and typeclass match. """ - return self.filter(db_key__iexact=oname, db_typeclass_path__exact=otypeclass_path) + cand_restriction = candidates and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) or Q() + return self.filter(cand_restriction & Q(db_key__iexact=oname, db_typeclass_path__exact=otypeclass_path)) # attr/property related @returns_typeclass_list - def get_objs_with_attr(self, attribute_name, location=None): + def get_objs_with_attr(self, attribute_name, candidates=None): """ Returns all objects having the given attribute_name defined at all. Location should be a valid location object. """ - global _ObjAttribute - if not _ObjAttribute: - from src.objects.models import ObjAttribute as _ObjAttribute - if location: - attrs = _ObjAttribute.objects.select_related("db_obj").filter(db_key=attribute_name, db_obj__db_location=location) - else: - attrs = _ObjAttribute.objects.select_related("db_obj").filter(db_key=attribute_name) - return [attr.db_obj for attr in attrs] + cand_restriction = candidates and Q(objattribute__db_obj__pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) or Q() + return self.filter(cand_restriction & Q(objattribute__db_key=attribute_name)) @returns_typeclass_list - def get_objs_with_attr_match(self, attribute_name, attribute_value, location=None, exact=False): + def get_objs_with_attr_value(self, attribute_name, attribute_value, candidates=None): """ Returns all objects having the valid attrname set to the given value. Note that no conversion is made - to attribute_value, and so it can accept also non-strings. + to attribute_value, and so it can accept also non-strings. For this reason it does + not make sense to offer an "exact" type matching for this. """ - global _ObjAttribute - if not _ObjAttribute: - from src.objects.models import ObjAttribute as _ObjAttribute - if location: - attrs = _ObjAttribute.objects.select_related("db_value").filter(db_key=attribute_name, db_obj__db_location=location) - else: - attrs = _ObjAttribute.objects.select_related("db_value").filter(db_key=attribute_name) - if exact: - return [attr.obj for attr in attrs if attribute_value == attr.value] - else: - return [attr.obj for attr in attrs if to_unicode(attribute_value) in str(attr.value)] + cand_restriction = candidates and Q(db_obj__pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) or Q() + attrs= self.model.objattribute_set.related.model.objects.select_related("db_obj").filter(cand_restriction & Q(db_key=attribute_name)) + return [attr.db_obj for attr in attrs if attribute_value == attr.value] @returns_typeclass_list - def get_objs_with_db_property(self, property_name, location=None): + def get_objs_with_db_property(self, property_name, candidates=None): """ Returns all objects having a given db field property. property_name = search string - location - actual location object to restrict to - + candidates - list of candidate objects to search """ - lstring = "" - if location: - lstring = ".filter(db_location=location)" + property_name = "db_%s" % property_name.lstrip('db_') + cand_restriction = candidates and Q(pk__in=[_GA(obj, "in") for obj in make_iter(candidates) if obj]) or Q() try: - return eval("self.exclude(db_%s=None)%s" % (property_name, lstring)) + return self.filter(cand_restriction).exclude(Q(property_name=None)) except exceptions.FieldError: return [] @returns_typeclass_list - def get_objs_with_db_property_match(self, property_name, property_value, location, exact=False): + def get_objs_with_db_property_value(self, property_name, property_value, candidates=None): """ Returns all objects having a given db field property """ - lstring = "" - if location: - lstring = ", db_location=location" - + if isinstance(property_value, basestring): + property_value = to_unicode(property_value) + property_name = "db_%s" % property_name.lstrip('db_') + cand_restriction = candidates and Q(pk__in=[_GA(obj, "in") for obj in make_iter(candidates) if obj]) or Q() try: - if exact: - return eval("self.filter(db_%s__iexact=property_value%s)" % (property_name, lstring)) - else: - return eval("self.filter(db_%s__icontains=property_value%s)" % (property_name, lstring)) + return self.filter(cand_restriction & Q(property_name=property_value)) except exceptions.FieldError: return [] - @returns_typeclass_list - def get_objs_with_key_or_alias(self, ostring, location=None, exact=False): - """ - Returns objects based on key or alias match - """ - lstring_key, lstring_alias, estring = "", "", "icontains" - if location: - lstring_key = ", db_location=location" - lstring_alias = ", db_obj__db_location=location" - if exact: - estring = "__iexact" - else: - estring = "__istartswith" - matches = eval("self.filter(db_key%s=ostring%s)" % (estring, lstring_key)) - if not matches: - alias_matches = eval("self.model.alias_set.related.model.objects.filter(db_key%s=ostring%s)" % (estring, lstring_alias)) - matches = [alias.db_obj for alias in alias_matches] - return matches - - # main search methods and helper functions - @returns_typeclass_list def get_contents(self, location, excludeobj=None): """ Get all objects that has a location set to this one. - excludeobjs - one or more object keys to exclude from the match + excludeobj - one or more object keys to exclude from the match """ - query = self.filter(db_location__id=location.id) - for objkey in make_iter(excludeobj): - query = query.exclude(db_key=objkey) - return query + exclude_restriction = excludeobj and Q(pk__in=[_GA(obj, "in") for obj in make_iter(excludeobj)]) or Q() + return self.filter(db_location=location).exclude(exclude_restriction) + + @returns_typeclass_list + def get_objs_with_key_or_alias(self, ostring, exact=True, candidates=None): + """ + Returns objects based on key or alias match. Will also do fuzzy matching based on + the utils.string_partial_matching function. + """ + # build query objects + candidates_id = [_GA(obj, "id") for obj in make_iter(candidates) if obj] + cand_restriction = candidates and Q(pk__in=candidates_id) or Q() + if exact: + return self.filter(cand_restriction & (Q(db_key__iexact=ostring) | Q(alias__db_key__iexact=ostring))) + else: + if candidates: + # fuzzy matching - only check the candidates + key_candidates = self.filter(cand_restriction) + key_strings = key_candidates.values_list("db_key", flat=True) + index_matches = string_partial_matching(key_strings, ostring, ret_index=True) + if index_matches: + return [obj for ind, obj in enumerate(key_candidates) if ind in index_matches] + else: + alias_candidates = self.model.alias_set.related.model.objects.filter(db_obj__pk__in=candidates_id) + alias_strings = alias_candidates.values_list("db_key", flat=True) + index_matches = string_partial_matching(alias_strings, ostring, ret_index=True) + if index_matches: + return [alias.db_obj for ind, alias in enumerate(alias_candidates) if ind in index_matches] + return [] + else: + # fuzzy matching - first check with keys, then with aliases + key_candidates = self.filter(Q(db_key__istartswith=ostring) | Q(alias__db_key__istartswith=ostring)) + key_strings = key_candidates.values_list("db_key", flat=True) + matches = string_partial_matching(key_candidates, ostring, reg_index=False) + if matches: + return matches + alias_candidates = self.model.alias_set.related.model.objects.filter(db_obj__pk__in=candidates_id).values_list("db_key", flat=True) + return string_partial_matching(alias_candidates, ostring, ret_index=False) + + + # main search methods and helper functions @returns_typeclass_list def object_search(self, ostring, caller=None, global_search=False, - attribute_name=None, location=None, single_result=False): + attribute_name=None, + candidates=None, + exact=True): """ Search as an object and return results. The result is always an Object. If * is appended (player search, a Character controlled by this Player @@ -212,19 +223,40 @@ class ObjectManager(TypedObjectManager): ostring: (string) The string to compare names against. Can be a dbref. If name is appended by *, a player is searched for. caller: (Object) The optional object performing the search. - global_search (bool). Defaults to False. If a caller is defined, search will - be restricted to the contents of caller.location unless global_search - is True. If no caller is given (or the caller has no location), a - global search is assumed automatically. attribute_name: (string) Which object attribute to match ostring against. If not set, the "key" and "aliases" properties are searched in order. - location (Object): If set, this location's contents will be used to limit the search instead - of the callers. global_search will override this argument + candidates (list obj ObjectDBs): If objlist is supplied, global_search keyword is ignored + and search will only be performed among the candidates in this list. A common list + of candidates is the contents of the current location searched. + exact (bool): Match names/aliases exactly or partially. Partial matching matches the + beginning of words in the names/aliases, using a matching routine to separate + multiple matches in names with multiple components (so "bi sw" will match + "Big sword"). Since this is more expensive than exact matching, it is + recommended to be used together with the objlist keyword to limit the number + of possibilities. This value has no meaning if searching for attributes/properties. Returns: A list of matching objects (or a list with one unique match) """ + def _searcher(ostring, exact=False): + "Helper method for searching objects" + # Handle player + if ostring.startswith("*"): + # Player search - try to find obj by its player's name + player_match = self.get_object_with_player(ostring, candidates=candidates) + if player_match is not None: + return [player_match] + if attribute_name: + # attribute/property search (always exact). + matches = self.get_objs_with_db_property_value(attribute_name, ostring, candidates=candidates) + if not matches: + return self.get_objs_with_attr_value(attribute_name, ostring, candidates=candidates) + else: + # normal key/alias search + return self.get_objs_with_key_or_alias(ostring, exact=exact, candidates=candidates) + + ostring = to_unicode(ostring, force_string=True) if not ostring and ostring != 0: @@ -237,81 +269,25 @@ class ObjectManager(TypedObjectManager): if dbref_match: return [dbref_match] - if not location and caller and hasattr(caller, "location"): - location = caller.location - - # Test some common self-references - - if location and ostring == 'here': - return [location] - if caller and ostring in ('me', 'self'): - return [caller] - if caller and ostring in ('*me', '*self'): - return [caller] - - # Test if we are looking for an object controlled by a - # specific player - - #logger.log_infomsg(str(type(ostring))) - if ostring.startswith("*"): - # Player search - try to find obj by its player's name - player_match = self.get_object_with_player(ostring) - if player_match is not None: - return [player_match] - - # Search for keys, aliases or other attributes - - search_locations = [None] # this means a global search - if not global_search and location: - # Test if we are referring to the current room - if location and (ostring.lower() == location.key.lower() - or ostring.lower() in [alias.lower() for alias in location.aliases]): - return [location] - # otherwise, setup the locations to search in - search_locations = [location] - if caller: - search_locations.append(caller) - - def local_and_global_search(ostring, exact=False): - "Helper method for searching objects" - matches = [] - for location in search_locations: - if attribute_name: - # Attribute/property search. First, search for db_ matches on the model - matches.extend(self.get_objs_with_db_property_match(attribute_name, ostring, location, exact)) - if not matches: - # Next, try Attribute matches - matches.extend(self.get_objs_with_attr_match(attribute_name, ostring, location, exact)) - else: - # No attribute/property named. Do a normal key/alias-search - matches.extend(self.get_objs_with_key_or_alias(ostring, location, exact)) - return matches - # Search through all possibilities. match_number = None - matches = local_and_global_search(ostring, exact=True) + # always run first check exact - we don't want partial matches if on the form of 1-keyword etc. + matches = _searcher(ostring, exact=True) if not matches: - # if we have no match, check if we are dealing with an "N-keyword" query - if so, strip it. + # no matches found - check if we are dealing with N-keyword query - if so, strip it. match_number, ostring = _AT_MULTIMATCH_INPUT(ostring) - if match_number != None and ostring: - # Run search again, without match number: - matches = local_and_global_search(ostring, exact=True) - if ostring and (len(matches) > 1 or not matches): - # Already multimatch or no matches. Run a fuzzy matching. - matches = local_and_global_search(ostring, exact=False) - elif len(matches) > 1: - # multiple matches already. Run a fuzzy search. This catches partial matches (suggestions) - matches = local_and_global_search(ostring, exact=False) + # run search again, with the exactness set by caller + matches = _searcher(ostring, exact=exact) - # deal with the result + # deal with result if len(matches) > 1 and match_number != None: - # We have multiple matches, but a N-type match number is available to separate them. + # multiple matches, but a number was given to separate them try: matches = [matches[match_number]] except IndexError: pass - # We always have a (possibly empty) list at this point. + # return a list (possibly empty) return matches # diff --git a/src/objects/models.py b/src/objects/models.py index 312e348dc8..9b0058856a 100644 --- a/src/objects/models.py +++ b/src/objects/models.py @@ -23,7 +23,6 @@ from src.typeclasses.models import Attribute, TypedObject, TypeNick, TypeNickHan from src.typeclasses.models import _get_cache, _set_cache, _del_cache from src.typeclasses.typeclass import TypeClass from src.objects.manager import ObjectManager -from src.players.models import PlayerDB from src.commands.cmdsethandler import CmdSetHandler from src.commands import cmdhandler from src.scripts.scripthandler import ScriptHandler @@ -41,6 +40,11 @@ _GA = object.__getattribute__ _SA = object.__setattr__ _DA = object.__delattr__ +_ME = _("me") +_SELF = _("self") +_HERE = _("here") + + def clean_content_cache(obj): "Clean obj's content cache" _SA(obj, "_contents_cache", None) @@ -500,7 +504,8 @@ class ObjectDB(TypedObject): global_search=False, attribute_name=None, use_nicks=False, location=None, - ignore_errors=False, player=False): + player=False, + ignore_errors=False, exact=False): """ Perform a standard object search in the database, handling multiple results and lack thereof gracefully. @@ -509,17 +514,18 @@ class ObjectDB(TypedObject): Obs - To find a player, append * to the start of ostring. global_search: Search all objects, not just the current - location/inventory - attribute_name: (string) Which attribute to match - (if None, uses default 'name') + location/inventory. This is overruled if location keyword is given. + attribute_name: (string) Which attribute to match (if None, uses default 'name') use_nicks : Use nickname replace (off by default) location : If None, use caller's current location + player: return the Objects' controlling Player, instead, if available ignore_errors : Don't display any error messages even if there are none/multiple matches - just return the result as a list. - player : Don't search for an Object but a Player. - This will also find players that don't - currently have a character. + exact: Determines if the search must exactly match the key/alias of the + given object or if partial matches the beginnings of one or more + words in the name is enough. Exact matching is faster if using + global search. Also, if attribute_name is set, matching is always exact. Returns - a unique Object/Player match or None. All error messages are handled by system-commands and the parser-handlers @@ -539,6 +545,12 @@ class ObjectDB(TypedObject): address the individual ball as '1-ball', '2-ball', '3-ball' etc. """ + # handle some common self-references: + if ostring == _HERE: + return self.location + if ostring in (_ME, _SELF, '*' + _ME, '*' + _SELF): + return self + if use_nicks: if ostring.startswith('*') or player: # player nick replace @@ -549,21 +561,32 @@ class ObjectDB(TypedObject): # object nick replace ostring = self.nicks.get(ostring, nick_type="object") - if player: - if ostring in ("me", "self", "*me", "*self"): - results = [self.player] - else: - results = PlayerDB.objects.player_search(ostring.lstrip('*')) + candidates=None + if global_search: + # only allow exact matching if searching the entire database + exact = True else: - results = ObjectDB.objects.object_search(ostring, caller=self, - global_search=global_search, - attribute_name=attribute_name, - location=location) + # local search. Candidates are self.contents, self.location and self.location.contents + if not location: + location = self.location + candidates = self.contents + if location: + candidates = candidates + [location] + location.contents + else: + candidates.append(self) # normally we are included in location.contents + # db manager expects database objects + candidates = [obj.dbobj for obj in candidates] + + results = ObjectDB.objects.object_search(ostring, caller=self, + attribute_name=attribute_name, + candidates=candidates, + exact=exact) + if not ignore_errors: + result = _AT_SEARCH_RESULT(self, ostring, results, global_search) + if player and result: + return result.player + return result - if ignore_errors: - return results - # this import is cache after the first call. - return _AT_SEARCH_RESULT(self, ostring, results, global_search) # # Execution/action methods diff --git a/src/objects/objects.py b/src/objects/objects.py index 51539d86e6..57903c2e0e 100644 --- a/src/objects/objects.py +++ b/src/objects/objects.py @@ -389,14 +389,14 @@ class Object(TypeClass): # controller, for example) dbref = self.dbobj.dbref - self.locks.add(";".join("control:perm(Immortals)", # edit locks/permissions, delete - "examine:perm(Builders)", # examine properties - "view:all()", # look at object (visibility) - "edit:perm(Wizards)", # edit properties/attributes - "delete:perm(Wizards)", # delete object - "get:all()", # pick up object - "call:true()", # allow to call commands on this object - "puppet:id(%s) or perm(Immortals) or pperm(Immortals)" % dbref)) # restricts puppeting of this object + self.locks.add(";".join(["control:perm(Immortals)", # edit locks/permissions, delete + "examine:perm(Builders)", # examine properties + "view:all()", # look at object (visibility) + "edit:perm(Wizards)", # edit properties/attributes + "delete:perm(Wizards)", # delete object + "get:all()", # pick up object + "call:true()", # allow to call commands on this object + "puppet:id(%s) or perm(Immortals) or pperm(Immortals)" % dbref])) # restricts puppeting of this object def basetype_posthook_setup(self): """ @@ -720,8 +720,8 @@ class Character(Object): overload the defaults (it is called after this one). """ super(Character, self).basetype_setup() - self.locks.add(";".join("get:false()", # noone can pick up the character - "call:false()")) # no commands can be called on character from outside + self.locks.add(";".join(["get:false()", # noone can pick up the character + "call:false()"])) # no commands can be called on character from outside # add the default cmdset from settings import CMDSET_DEFAULT self.cmdset.add_default(CMDSET_DEFAULT, permanent=True) @@ -788,8 +788,8 @@ class Room(Object): """ super(Room, self).basetype_setup() - self.locks.add(";".join("get:false()", - "puppet:false()")) # would be weird to puppet a room ... + self.locks.add(";".join(["get:false()", + "puppet:false()"])) # would be weird to puppet a room ... self.location = None # @@ -884,9 +884,9 @@ class Exit(Object): super(Exit, self).basetype_setup() # setting default locks (overload these in at_object_creation() - self.locks.add(";".join("puppet:false()", # would be weird to puppet an exit ... - "traverse:all()", # who can pass through exit by default - "get:false()")) # noone can pick up the exit + self.locks.add(";".join(["puppet:false()", # would be weird to puppet an exit ... + "traverse:all()", # who can pass through exit by default + "get:false()"])) # noone can pick up the exit # an exit should have a destination (this is replaced at creation time) if self.dbobj.location: diff --git a/src/scripts/manager.py b/src/scripts/manager.py index e22a2307aa..4625c09851 100644 --- a/src/scripts/manager.py +++ b/src/scripts/manager.py @@ -42,7 +42,6 @@ class ScriptManager(TypedObjectManager): if not obj: return [] if key: - script = [] dbref = self.dbref(key) if dbref: script = self.filter(db_obj=obj, id=dbref) diff --git a/src/typeclasses/managers.py b/src/typeclasses/managers.py index 2d9a2e6655..c478893e39 100644 --- a/src/typeclasses/managers.py +++ b/src/typeclasses/managers.py @@ -34,7 +34,7 @@ class AttributeManager(models.Manager): def returns_typeclass_list(method): """ - Decorator: Chantes return of the decorated method (which are + Decorator: Changes return of the decorated method (which are TypeClassed objects) into object_classes(s) instead. Will always return a list (may be empty). """ @@ -52,11 +52,11 @@ def returns_typeclass(method): def func(self, *args, **kwargs): "decorator. Returns result or None." self.__doc__ = method.__doc__ - rfunc = returns_typeclass_list(method) - try: - return rfunc(self, *args, **kwargs)[0] - except IndexError: - return None + matches = method(self, *args, **kwargs) + dbobj = matches and make_iter(matches)[0] or None + if dbobj: + return (hasattr(dbobj, "typeclass") and dbobj.typeclass) or dbobj + return None return update_wrapper(func, method) diff --git a/src/utils/dummyrunner/dummyrunner_actions.py b/src/utils/dummyrunner/dummyrunner_actions.py index cd7521d9c2..f8416c5d77 100644 --- a/src/utils/dummyrunner/dummyrunner_actions.py +++ b/src/utils/dummyrunner/dummyrunner_actions.py @@ -66,7 +66,7 @@ def c_login(client): exitname1 = EXIT_TEMPLATE % client.counter() exitname2 = EXIT_TEMPLATE % client.counter() client.exits.extend([exitname1, exitname2]) - cmd = '@dig %s = %s, %s' % (roomname, exitname1, exitname2) + #cmd = '@dig %s = %s, %s' % (roomname, exitname1, exitname2) cmd = ('create %s %s' % (cname, cpwd), 'connect %s %s' % (cname, cpwd), '@dig %s' % START_ROOM % client.cid, diff --git a/src/utils/utils.py b/src/utils/utils.py index 75cae91f35..7e4b10f06c 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -38,8 +38,7 @@ def is_iter(iterable): def make_iter(obj): "Makes sure that the object is always iterable." - if not hasattr(obj, '__iter__'): return [obj] - return obj + return not hasattr(obj, '__iter__') and [obj] or obj def fill(text, width=78, indent=0): """ @@ -976,3 +975,49 @@ def string_suggestions(string, vocabulary, cutoff=0.6, maxnum=3): """ return [tup[1] for tup in sorted([(string_similarity(string, sugg), sugg) for sugg in vocabulary], key=lambda tup: tup[0], reverse=True) if tup[0] >= cutoff][:maxnum] + +def string_partial_matching(alternatives, inp, ret_index=True): + """ + Partially matches a string based on a list of alternatives. Matching + is made from the start of each subword in each alternative. Case is not + important. So e.g. "bi sh sw" or just "big" or "shiny" or "sw" will match + "Big shiny sword". Scoring is done to allow to separate by most common + demoninator. You will get multiple matches returned if appropriate. + + Input: + alternatives (list of str) - list of possible strings to match + inp (str) - search criterion + ret_index (bool) - return list of indices (from alternatives array) or strings + Returns: + list of matching indices or strings, or an empty list + + """ + if not alternatives or not inp: + return [] + + matches = defaultdict(list) + inp_words = inp.lower().split() + for altindex, alt in enumerate(alternatives): + alt_words = alt.lower().split() + last_index = 0 + score = 0 + for inp_word in inp_words: + # loop over parts, making sure only to visit each part once + # (this will invalidate input in the wrong word order) + submatch = [last_index + alt_num for alt_num, alt_word + in enumerate(alt_words[last_index:]) if alt_word.startswith(inp_word)] + if submatch: + last_index = min(submatch) + 1 + score += 1 + else: + score = 0 + break + if score: + if ret_index: + matches[score].append(altindex) + else: + matches[score].append(alt) + if matches: + return matches[max(matches)] + return [] +