diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..30ef101a02 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: griatch +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: https://www.paypal.me/GriatchEvennia diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a75228c74..ad5a889693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ - Added `content_types` indexing to DefaultObject's ContentsHandler. (volund) - Made most of the networking classes such as Protocols and the SessionHandlers replaceable via `settings.py` for modding enthusiasts. (volund) +- The `initial_setup.py` file can now be substituted in `settings.py` to customize + initial game database state. (volund) - Added new Traits contrib, converted and expanded from Ainneve project. - ### Already in master - `is_typeclass(obj (Object), exact (bool))` now defaults to exact=False - `py` command now reroutes stdout to output results in-game client. `py` diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 397ea287b6..305b841956 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -32,7 +32,7 @@ from evennia.server.signals import ( SIGNAL_OBJECT_POST_PUPPET, SIGNAL_OBJECT_POST_UNPUPPET, ) -from evennia.typeclasses.attributes import NickHandler +from evennia.typeclasses.attributes import NickHandler, ModelAttributeBackend from evennia.scripts.scripthandler import ScriptHandler from evennia.commands.cmdsethandler import CmdSetHandler from evennia.utils.optionhandler import OptionHandler @@ -199,7 +199,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): @lazy_property def nicks(self): - return NickHandler(self) + return NickHandler(self, ModelAttributeBackend) @lazy_property def sessions(self): @@ -275,11 +275,11 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): raise RuntimeError("Session not found") if self.get_puppet(session) == obj: # already puppeting this object - self.msg("You are already puppeting this object.") + self.msg(_("You are already puppeting this object.")) return if not obj.access(self, "puppet"): # no access - self.msg(f"You don't have permission to puppet '{obj.key}'.") + self.msg(_("You don't have permission to puppet '{key}'.").format(key=obj.key)) return if obj.account: # object already puppeted @@ -295,12 +295,12 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): else: txt1 = f"Taking over |c{obj.name}|n from another of your sessions." txt2 = f"|c{obj.name}|n|R is now acted from another of your sessions.|n" - self.msg(txt1, session=session) - self.msg(txt2, session=obj.sessions.all()) + self.msg(_(txt1), session=session) + self.msg(_(txt2), session=obj.sessions.all()) self.unpuppet_object(obj.sessions.get()) elif obj.account.is_connected: # controlled by another account - self.msg(f"|c{obj.key}|R is already puppeted by another Account.") + self.msg(_("|c{key}|R is already puppeted by another Account.").format(key=obj.key)) return # do the puppeting @@ -496,7 +496,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): # See if authentication is currently being throttled if ip and LOGIN_THROTTLE.check(ip): - errors.append("Too many login failures; please try again in a few minutes.") + errors.append(_("Too many login failures; please try again in a few minutes.")) # With throttle active, do not log continued hits-- it is a # waste of storage and can be abused to make your logs harder to @@ -508,8 +508,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): if banned: # this is a banned IP or name! errors.append( - "|rYou have been banned and cannot continue from here." - "\nIf you feel this ban is in error, please email an admin.|x" + _( + "|rYou have been banned and cannot continue from here." + "\nIf you feel this ban is in error, please email an admin.|x" + ) ) logger.log_sec(f"Authentication Denied (Banned): {username} (IP: {ip}).") LOGIN_THROTTLE.update(ip, "Too many sightings of banned artifact.") @@ -519,7 +521,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): account = authenticate(username=username, password=password) if not account: # User-facing message - errors.append("Username and/or password is incorrect.") + errors.append(_("Username and/or password is incorrect.")) # Log auth failures while throttle is inactive logger.log_sec(f"Authentication Failure: {username} (IP: {ip}).") @@ -688,7 +690,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): ip = kwargs.get("ip", "") if ip and CREATION_THROTTLE.check(ip): errors.append( - "You are creating too many accounts. Please log into an existing account." + _("You are creating too many accounts. Please log into an existing account.") ) return None, errors @@ -716,7 +718,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): banned = cls.is_banned(username=username, ip=ip) if banned: # this is a banned IP or name! - string = ( + string = _( "|rYou have been banned and cannot continue from here." "\nIf you feel this ban is in error, please email an admin.|x" ) @@ -733,7 +735,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): except Exception as e: errors.append( - "There was an error creating the Account. If this problem persists, contact an admin." + _( + "There was an error creating the Account. If this problem persists, contact an admin." + ) ) logger.log_trace() return None, errors @@ -785,7 +789,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): # We are in the middle between logged in and -not, so we have # to handle tracebacks ourselves at this point. If we don't, # we won't see any errors at all. - errors.append("An error occurred. Please e-mail an admin if the problem persists.") + errors.append(_("An error occurred. Please e-mail an admin if the problem persists.")) logger.log_trace() # Update the throttle to indicate a new account was created from this IP @@ -1253,21 +1257,21 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): if session: session.msg(logged_in={}) - self._send_to_connect_channel(f"|G{self.key} connected|n") + self._send_to_connect_channel(_("|G{key} connected|n").format(key=self.key)) if _MULTISESSION_MODE == 0: # in this mode we should have only one character available. We # try to auto-connect to our last conneted object, if any try: self.puppet_object(session, self.db._last_puppet) except RuntimeError: - self.msg("The Character does not exist.") + self.msg(_("The Character does not exist.")) return elif _MULTISESSION_MODE == 1: # in this mode all sessions connect to the same puppet. try: self.puppet_object(session, self.db._last_puppet) except RuntimeError: - self.msg("The Character does not exist.") + self.msg(_("The Character does not exist.")) return elif _MULTISESSION_MODE in (2, 3): # In this mode we by default end up at a character selection @@ -1305,7 +1309,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): """ reason = f" ({reason if reason else ''})" - self._send_to_connect_channel(f"|R{self.key} disconnected{reason}|n") + self._send_to_connect_channel( + _("|R{key} disconnected{reason}|n").format(key=self.key, reason=reason) + ) def at_post_disconnect(self, **kwargs): """ @@ -1411,7 +1417,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): if hasattr(target, "return_appearance"): return target.return_appearance(self) else: - return "{} has no in-game appearance.".format(target) + return _("{target} has no in-game appearance.").format(target=target) else: # list of targets - make list to disconnect from db characters = list(tar for tar in target if tar) if target else [] @@ -1454,7 +1460,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): if is_su or len(characters) < charmax: if not characters: result.append( - "\n\n You don't have any characters yet. See |whelp @charcreate|n for creating one." + _( + "\n\n You don't have any characters yet. See |whelp @charcreate|n for creating one." + ) ) else: result.append("\n |w@charcreate [=description]|n - create new character") @@ -1534,7 +1542,7 @@ class DefaultGuest(DefaultAccount): # check if guests are enabled. if not settings.GUEST_ENABLED: - errors.append("Guest accounts are not enabled on this server.") + errors.append(_("Guest accounts are not enabled on this server.")) return None, errors try: @@ -1544,7 +1552,7 @@ class DefaultGuest(DefaultAccount): username = name break if not username: - errors.append("All guest accounts are in use. Please try again later.") + errors.append(_("All guest accounts are in use. Please try again later.")) if ip: LOGIN_THROTTLE.update(ip, "Too many requests for Guest access.") return None, errors @@ -1572,7 +1580,7 @@ class DefaultGuest(DefaultAccount): # We are in the middle between logged in and -not, so we have # to handle tracebacks ourselves at this point. If we don't, # we won't see any errors at all. - errors.append("An error occurred. Please e-mail an admin if the problem persists.") + errors.append(_("An error occurred. Please e-mail an admin if the problem persists.")) logger.log_trace() return None, errors @@ -1589,7 +1597,7 @@ class DefaultGuest(DefaultAccount): overriding the call (unused by default). """ - self._send_to_connect_channel(f"|G{self.key} connected|n") + self._send_to_connect_channel(_("|G{key} connected|n").format(key=self.key)) self.puppet_object(session, self.db._last_puppet) def at_server_shutdown(self): diff --git a/evennia/accounts/bots.py b/evennia/accounts/bots.py index 455e10b629..420d96053d 100644 --- a/evennia/accounts/bots.py +++ b/evennia/accounts/bots.py @@ -10,6 +10,7 @@ from evennia.accounts.accounts import DefaultAccount from evennia.scripts.scripts import DefaultScript from evennia.utils import search from evennia.utils import utils +from django.utils.translation import gettext as _ _IDLE_TIMEOUT = settings.IDLE_TIMEOUT @@ -328,7 +329,9 @@ class IRCBot(Bot): chstr = f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})" nicklist = ", ".join(sorted(kwargs["nicklist"], key=lambda n: n.lower())) for obj in self._nicklist_callers: - obj.msg(f"Nicks at {chstr}:\n {nicklist}") + obj.msg( + _("Nicks at {chstr}:\n {nicklist}").format(chstr=chstr, nicklist=nicklist) + ) self._nicklist_callers = [] return @@ -337,7 +340,11 @@ class IRCBot(Bot): if hasattr(self, "_ping_callers") and self._ping_callers: chstr = f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})" for obj in self._ping_callers: - obj.msg(f"IRC ping return from {chstr} took {kwargs['timing']}s.") + obj.msg( + _("IRC ping return from {chstr} took {time}s.").format( + chstr=chstr, time=kwargs["timing"] + ) + ) self._ping_callers = [] return diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 6f8f247a72..8ae1a7f285 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -743,7 +743,9 @@ def cmdhandler( sysarg = raw_string else: # fallback to default error text - sysarg = _("Command '%s' is not available.") % raw_string + sysarg = _("Command '{command}' is not available.").format( + command=raw_string + ) suggestions = string_suggestions( raw_string, cmdset.get_all_cmd_keys_and_aliases(caller), @@ -751,8 +753,8 @@ def cmdhandler( maxnum=3, ) if suggestions: - sysarg += _(" Maybe you meant %s?") % utils.list_to_string( - suggestions, _("or"), addquote=True + sysarg += _(" Maybe you meant {command}?").format( + command=utils.list_to_string(suggestions, _("or"), addquote=True) ) else: sysarg += _(' Type "help" for help.') diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index 395c9c2ba3..b2eb95c886 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -184,7 +184,9 @@ def import_cmdset(path, cmdsetobj, emit_to_obj=None, no_logging=False): raise exc.with_traceback(tb) else: # try next suggested path - errstring += _("\n(Unsuccessfully tried '%s')." % python_path) + errstring += _("\n(Unsuccessfully tried '{path}').").format( + path=python_path + ) continue try: cmdsetclass = getattr(module, classname) @@ -194,7 +196,9 @@ def import_cmdset(path, cmdsetobj, emit_to_obj=None, no_logging=False): dum, dum, tb = sys.exc_info() raise exc.with_traceback(tb) else: - errstring += _("\n(Unsuccessfully tried '%s')." % python_path) + errstring += _("\n(Unsuccessfully tried '{path}').").format( + path=python_path + ) continue _CACHED_CMDSETS[python_path] = cmdsetclass diff --git a/evennia/comms/channelhandler.py b/evennia/comms/channelhandler.py index 98e28786a0..c99288c337 100644 --- a/evennia/comms/channelhandler.py +++ b/evennia/comms/channelhandler.py @@ -119,17 +119,17 @@ class ChannelCommand(command.Command): caller = caller if not hasattr(caller, "account") else caller.account unmuted = channel.unmute(caller) if unmuted: - self.msg("You start listening to %s." % channel) + self.msg(_("You start listening to %s.") % channel) return - self.msg("You were already listening to %s." % channel) + self.msg(_("You were already listening to %s.") % channel) return if msg == "off": caller = caller if not hasattr(caller, "account") else caller.account muted = channel.mute(caller) if muted: - self.msg("You stop listening to %s." % channel) + self.msg(_("You stop listening to %s.") % channel) return - self.msg("You were already not listening to %s." % channel) + self.msg(_("You were already not listening to %s.") % channel) return if self.history_start is not None: # Try to view history @@ -144,7 +144,7 @@ class ChannelCommand(command.Command): else: caller = caller if not hasattr(caller, "account") else caller.account if caller in channel.mutelist: - self.msg("You currently have %s muted." % channel) + self.msg(_("You currently have %s muted.") % channel) return channel.msg(msg, senders=self.caller, online=True) diff --git a/evennia/contrib/puzzles.py b/evennia/contrib/puzzles.py index 1ca661cd5d..5eef1f77e2 100644 --- a/evennia/contrib/puzzles.py +++ b/evennia/contrib/puzzles.py @@ -289,7 +289,7 @@ class CmdCreatePuzzleRecipe(MuxCommand): proto_parts = [proto_def(obj) for obj in parts] proto_results = [proto_def(obj) for obj in results] - puzzle = create_script(PuzzleRecipe, key=puzzle_name) + puzzle = create_script(PuzzleRecipe, key=puzzle_name, persistent=True) puzzle.save_recipe(puzzle_name, proto_parts, proto_results) puzzle.locks.add("control:id(%s) or perm(Builder)" % caller.dbref[1:]) @@ -488,7 +488,7 @@ class CmdArmPuzzle(MuxCommand): Notes: Create puzzles with `@puzzle`; get list of - defined puzzles using `@lspuzlerecipies`. + defined puzzles using `@lspuzzlerecipes`. """ diff --git a/evennia/help/manager.py b/evennia/help/manager.py index 3459efe951..398312a203 100644 --- a/evennia/help/manager.py +++ b/evennia/help/manager.py @@ -131,7 +131,9 @@ class HelpEntryManager(TypedObjectManager): for topic in topics: topic.help_category = default_category topic.save() - string = "Help database moved to category %s" % default_category + string = _("Help database moved to category {default_category}").format( + default_category=default_category + ) logger.log_info(string) def search_help(self, ostring, help_category=None): diff --git a/evennia/locale/ru/LC_MESSAGES/django.mo b/evennia/locale/ru/LC_MESSAGES/django.mo new file mode 100644 index 0000000000..8a3f6b3aba Binary files /dev/null and b/evennia/locale/ru/LC_MESSAGES/django.mo differ diff --git a/evennia/locale/ru/LC_MESSAGES/django.po b/evennia/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000000..e42efc8362 --- /dev/null +++ b/evennia/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,299 @@ +msgid "" +msgstr "" +"Project-Id-Version: Evennia Russian Translation v0.1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-02-20 12:13+0000\n" +"PO-Revision-Date: 2020-04-19 18:32+0000\n" +"Last-Translator: 3eluk\n" +"Language-Team: Russian (Russia)\n" +"Language: ru-RU\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10 >= 2 && " +"n%10<=4 &&(n%100<10||n%100 >= 20)? 1 : 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Loco-Source-Locale: ru_RU\n" +"X-Generator: Loco https://localise.biz/\n" +"X-Loco-Parser: loco_parse_po" + +#: accounts/accounts.py:440 +msgid "Account being deleted." +msgstr "Аккаунт удаляется." + +#: commands/cmdhandler.py:681 +msgid "There were multiple matches." +msgstr "Здесь было несколько совпадений." + +#: commands/cmdhandler.py:704 +#, python-format +msgid "Command '%s' is not available." +msgstr "Команда '%s' недоступна." + +#: commands/cmdhandler.py:709 +#, python-format +msgid " Maybe you meant %s?" +msgstr "Возможно, вы имели ввиду %s?" + +#: commands/cmdhandler.py:709 +msgid "or" +msgstr "или" + +#: commands/cmdhandler.py:711 +msgid " Type \"help\" for help." +msgstr " Введи \"справка\" для получения помощи." + +#: commands/cmdsethandler.py:89 +msgid "" +"{traceback}\n" +"Error loading cmdset '{path}'\n" +"(Traceback was logged {timestamp})" +msgstr "" + +#: commands/cmdsethandler.py:94 +msgid "" +"Error loading cmdset: No cmdset class '{classname}' in '{path}'.\n" +"(Traceback was logged {timestamp})" +msgstr "" + +#: commands/cmdsethandler.py:98 +msgid "" +"{traceback}\n" +"SyntaxError encountered when loading cmdset '{path}'.\n" +"(Traceback was logged {timestamp})" +msgstr "" + +#: commands/cmdsethandler.py:103 +msgid "" +"{traceback}\n" +"Compile/Run error when loading cmdset '{path}'.\",\n" +"(Traceback was logged {timestamp})" +msgstr "" + +#: commands/cmdsethandler.py:108 +msgid "" +"\n" +"Error encountered for cmdset at path '{path}'.\n" +"Replacing with fallback '{fallback_path}'.\n" +msgstr "" + +#: commands/cmdsethandler.py:114 +msgid "Fallback path '{fallback_path}' failed to generate a cmdset." +msgstr "" + +#: commands/cmdsethandler.py:182 commands/cmdsethandler.py:192 +#, python-format +msgid "" +"\n" +"(Unsuccessfully tried '%s')." +msgstr "" +"\n" +"(Безуспешно пробую '%s')." + +#: commands/cmdsethandler.py:311 +msgid "custom {mergetype} on cmdset '{cmdset}'" +msgstr "" + +#: commands/cmdsethandler.py:314 +msgid " : {current}" +msgstr "" + +#: commands/cmdsethandler.py:322 +msgid "" +" <{key} ({mergetype}, prio {prio}, {permstring})>:\n" +" {keylist}" +msgstr "" + +#: commands/cmdsethandler.py:426 +msgid "Only CmdSets can be added to the cmdsethandler!" +msgstr "" + +#: comms/channelhandler.py:100 +msgid "Say what?" +msgstr "Сказать что?" + +#: comms/channelhandler.py:105 +#, python-format +msgid "Channel '%s' not found." +msgstr "Канал '%s' не обнаружен." + +#: comms/channelhandler.py:108 +#, python-format +msgid "You are not connected to channel '%s'." +msgstr "Ты не соединён с каналом '%s'." + +#: comms/channelhandler.py:112 +#, python-format +msgid "You are not permitted to send to channel '%s'." +msgstr "У тебя нет разрешения слать в канал '%s'." + +#: comms/channelhandler.py:155 +msgid " (channel)" +msgstr " (канал)" + +#: locks/lockhandler.py:236 +#, python-format +msgid "Lock: lock-function '%s' is not available." +msgstr "" + +#: locks/lockhandler.py:249 +#, python-format +msgid "Lock: definition '%s' has syntax errors." +msgstr "" + +#: locks/lockhandler.py:253 +#, python-format +msgid "" +"LockHandler on %(obj)s: access type '%(access_type)s' changed from '%(source)" +"s' to '%(goal)s' " +msgstr "" + +#: locks/lockhandler.py:320 +msgid "Lock: '{lockdef}' contains no colon (:)." +msgstr "" + +#: locks/lockhandler.py:328 +msgid "Lock: '{lockdef}' has no access_type (left-side of colon is empty)." +msgstr "" + +#: locks/lockhandler.py:336 +msgid "Lock: '{lockdef}' has mismatched parentheses." +msgstr "" + +#: locks/lockhandler.py:343 +msgid "Lock: '{lockdef}' has no valid lock functions." +msgstr "" + +#: objects/objects.py:732 +#, python-format +msgid "Couldn't perform move ('%s'). Contact an admin." +msgstr "Не удалось выполнить действие ('%s'). Свяжитесь с администратором." + +#: objects/objects.py:742 +msgid "The destination doesn't exist." +msgstr "Такой точки назначения нету." + +#: objects/objects.py:833 +#, python-format +msgid "Could not find default home '(#%d)'." +msgstr "Не обнаружен дом по умолчанию '(#%d)'." + +#: objects/objects.py:849 +msgid "Something went wrong! You are dumped into nowhere. Contact an admin." +msgstr "" +"Что-то пошло не так! Тебя выбрасывает в пустоту. Свяжитесь с администратором." + +#: objects/objects.py:915 +#, python-format +msgid "Your character %s has been destroyed." +msgstr "Ваш персонаж %s был уничтожен." + +#: scripts/scripthandler.py:53 +#, python-format +msgid "" +"\n" +" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s" +msgstr "" + +#: scripts/scripts.py:205 +#, python-format +msgid "" +"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'." +msgstr "" + +#: server/initial_setup.py:28 +msgid "" +"\n" +"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if " +"you need\n" +"help, want to contribute, report issues or just join the community.\n" +"As Account #1 you can create a demo/tutorial area with |w@batchcommand " +"tutorial_world.build|n.\n" +" " +msgstr "" +"\n" +"Добро пожаловать в твою новую игру, основанную на |wEvennia|n! Посети http:" +"//www.evennia.com\n" +"если тебе нужна помощь, хочешь помочь, сообщить об ошибках, lили просто " +"присоединиться к сообществу.\n" +"Как Аккаунт №1, ты можешь создать зону для демонстрации/обучения командой " +"|w@batchcommand tutorial_world.build|n.\n" +" " + +#: server/initial_setup.py:92 +msgid "This is User #1." +msgstr "Это Пользователь №1." + +#: server/initial_setup.py:105 +msgid "Limbo" +msgstr "Лимб" + +#: server/server.py:139 +msgid "idle timeout exceeded" +msgstr "время бездействия превышено" + +#: server/sessionhandler.py:386 +msgid " ... Server restarted." +msgstr " ... Сервер перезапущен." + +#: server/sessionhandler.py:606 +msgid "Logged in from elsewhere. Disconnecting." +msgstr "Выполнено соединение в другом месте. Отключение." + +#: server/sessionhandler.py:634 +msgid "Idle timeout exceeded, disconnecting." +msgstr "Время бездействия превышено, отключение." + +#: server/validators.py:50 +#, python-format +msgid "" +"%s From a terminal client, you can also use a phrase of multiple words if " +"you enclose the password in double quotes." +msgstr "" +"%s Если вы используете терминал, вы можете использовать фразу из нескольких " +"слов если возьмёте пароль в двойные скобки." + +#: utils/evmenu.py:192 +msgid "" +"Menu node '{nodename}' is either not implemented or caused an error. Make " +"another choice." +msgstr "" + +#: utils/evmenu.py:194 +msgid "Error in menu node '{nodename}'." +msgstr "" + +#: utils/evmenu.py:195 +msgid "No description." +msgstr "Нет описания." + +#: utils/evmenu.py:196 +msgid "Commands: , help, quit" +msgstr "Команды: , справка, выход" + +#: utils/evmenu.py:197 +msgid "Commands: , help" +msgstr "Команды: , справка" + +#: utils/evmenu.py:198 +msgid "Commands: help, quit" +msgstr "" + +#: utils/evmenu.py:199 +msgid "Commands: help" +msgstr "Команды: справка" + +#: utils/evmenu.py:200 +msgid "Choose an option or try 'help'." +msgstr "Выберите опцию или введите \"справка\"." + +#: utils/utils.py:1866 +#, python-format +msgid "Could not find '%s'." +msgstr "Не обнаружено '%s'." + +#: utils/utils.py:1873 +#, python-format +msgid "" +"More than one match for '%s' (please narrow target):\n" +msgstr "" +"Больше одного подходящего варианта для '%s' (уточните цель):\n" diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index ac8c85abc8..d2385a0aa7 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -246,7 +246,11 @@ class LockHandler(object): evalstring = " ".join(_RE_OK.findall(evalstring)) eval(evalstring % tuple(True for func in funclist), {}, {}) except Exception: - elist.append(_("Lock: definition '%s' has syntax errors.") % raw_lockstring) + elist.append( + _("Lock: definition '{lock_string}' has syntax errors.").format( + lock_string=raw_lockstring + ) + ) continue if access_type in locks: duplicates += 1 diff --git a/evennia/objects/admin.py b/evennia/objects/admin.py index 49bec928c3..59a1d85c68 100644 --- a/evennia/objects/admin.py +++ b/evennia/objects/admin.py @@ -8,6 +8,7 @@ from django.contrib import admin from evennia.typeclasses.admin import AttributeInline, TagInline from evennia.objects.models import ObjectDB from django.contrib.admin.utils import flatten_fieldsets +from django.utils.translation import gettext as _ class ObjectAttributeInline(AttributeInline): diff --git a/evennia/objects/manager.py b/evennia/objects/manager.py index 1217d0a91d..3e609991ff 100644 --- a/evennia/objects/manager.py +++ b/evennia/objects/manager.py @@ -159,7 +159,7 @@ class ObjectDBManager(TypedObjectManager): typeclasses (list, optional): Python pats to restrict matches with. Returns: - matches (list): Objects fullfilling both the `attribute_name` and + matches (query): Objects fullfilling both the `attribute_name` and `attribute_value` criterions. Notes: @@ -273,7 +273,7 @@ class ObjectDBManager(TypedObjectManager): to exclude from the match. Returns: - contents (list): Matching contents, without excludeobj, if given. + contents (query): Matching contents, without excludeobj, if given. """ exclude_restriction = ( Q(pk__in=[_GA(obj, "id") for obj in make_iter(excludeobj)]) if excludeobj else Q() @@ -291,7 +291,7 @@ class ObjectDBManager(TypedObjectManager): typeclasses (list): Only match objects with typeclasses having thess path strings. Returns: - matches (list): A list of matches of length 0, 1 or more. + matches (query): A list of matches of length 0, 1 or more. """ if not isinstance(ostring, str): if hasattr(ostring, "key"): diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index ff69863be8..a9b0bd5fa8 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -12,7 +12,7 @@ from collections import defaultdict from django.conf import settings from evennia.typeclasses.models import TypeclassBase -from evennia.typeclasses.attributes import NickHandler +from evennia.typeclasses.attributes import NickHandler, ModelAttributeBackend from evennia.objects.manager import ObjectManager from evennia.objects.models import ObjectDB from evennia.scripts.scripthandler import ScriptHandler @@ -225,7 +225,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): @lazy_property def nicks(self): - return NickHandler(self) + return NickHandler(self, ModelAttributeBackend) @lazy_property def sessions(self): @@ -503,7 +503,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): ) if quiet: - return results + return list(results) return _AT_SEARCH_RESULT( results, self, @@ -1059,7 +1059,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # See if we need to kick the account off. for session in self.sessions.all(): - session.msg(_("Your character %s has been destroyed.") % self.key) + session.msg(_("Your character {key} has been destroyed.").format(key=self.key)) # no need to disconnect, Account just jumps to OOC mode. # sever the connection (important!) if self.account: diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index e4a1518a75..90c40747c6 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -2262,7 +2262,7 @@ def main(): if option in ("makemessages", "compilemessages"): # some commands don't require the presence of a game directory to work need_gamedir = False - if option in ("shell", "check", "makemigrations"): + if option in ("shell", "check", "makemigrations", "createsuperuser"): # some django commands requires the database to exist, # or evennia._init to have run before they work right. check_db = True diff --git a/evennia/server/portal/ssh.py b/evennia/server/portal/ssh.py index 1fcf296058..19de7153da 100644 --- a/evennia/server/portal/ssh.py +++ b/evennia/server/portal/ssh.py @@ -465,11 +465,16 @@ def getKeyPair(pubkeyfile, privkeyfile): if not (os.path.exists(pubkeyfile) and os.path.exists(privkeyfile)): # No keypair exists. Generate a new RSA keypair - from Crypto.PublicKey import RSA + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.asymmetric import rsa - rsa_key = Key(RSA.generate(_KEY_LENGTH)) - public_key_string = rsa_key.public().toString(type="OPENSSH") - private_key_string = rsa_key.toString(type="OPENSSH") + rsa_key = Key( + rsa.generate_private_key( + public_exponent=65537, key_size=_KEY_LENGTH, backend=default_backend() + ) + ) + public_key_string = rsa_key.public().toString(type="OPENSSH").decode() + private_key_string = rsa_key.toString(type="OPENSSH").decode() # save keys for the future. with open(privkeyfile, "wt") as pfile: diff --git a/evennia/server/server.py b/evennia/server/server.py index 2093b5b88c..0dc7e1cc57 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -22,6 +22,7 @@ import django django.setup() import evennia +import importlib evennia._init() @@ -31,7 +32,6 @@ from django.conf import settings from evennia.accounts.models import AccountDB from evennia.scripts.models import ScriptDB from evennia.server.models import ServerConfig -from evennia.server import initial_setup from evennia.utils.utils import get_evennia_version, mod_import, make_iter from evennia.utils import logger @@ -105,6 +105,7 @@ _IDMAPPER_CACHE_MAXSIZE = settings.IDMAPPER_CACHE_MAXSIZE _GAMETIME_MODULE = None _IDLE_TIMEOUT = settings.IDLE_TIMEOUT +_LAST_SERVER_TIME_SNAPSHOT = 0 def _server_maintenance(): @@ -113,6 +114,8 @@ def _server_maintenance(): the server needs to do. It is called every minute. """ global EVENNIA, _MAINTENANCE_COUNT, _FLUSH_CACHE, _GAMETIME_MODULE + global _LAST_SERVER_TIME_SNAPSHOT + if not _FLUSH_CACHE: from evennia.utils.idmapper.models import conditional_flush as _FLUSH_CACHE if not _GAMETIME_MODULE: @@ -125,8 +128,13 @@ def _server_maintenance(): # first call after a reload _GAMETIME_MODULE.SERVER_START_TIME = now _GAMETIME_MODULE.SERVER_RUNTIME = ServerConfig.objects.conf("runtime", default=0.0) + _LAST_SERVER_TIME_SNAPSHOT = now else: - _GAMETIME_MODULE.SERVER_RUNTIME += 60.0 + # adjust the runtime not with 60s but with the actual elapsed time + # in case this may varies slightly from 60s. + _GAMETIME_MODULE.SERVER_RUNTIME += (now - _LAST_SERVER_TIME_SNAPSHOT) + _LAST_SERVER_TIME_SNAPSHOT = now + # update game time and save it across reloads _GAMETIME_MODULE.SERVER_RUNTIME_LAST_UPDATED = now ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.SERVER_RUNTIME) @@ -333,6 +341,7 @@ class Evennia(object): Once finished the last_initial_setup_step is set to -1. """ global INFO_DICT + initial_setup = importlib.import_module(settings.INITIAL_SETUP_MODULE) last_initial_setup_step = ServerConfig.objects.conf("last_initial_setup_step") if not last_initial_setup_step: # None is only returned if the config does not exist, diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 2443758b12..15ffa7a42b 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -6,7 +6,6 @@ connection actually happens (so it's the same for telnet, web, ssh etc). It is stored on the Server side (as opposed to protocol-specific sessions which are stored on the Portal side) """ -import weakref import time from django.utils import timezone from django.conf import settings @@ -16,6 +15,7 @@ from evennia.utils.utils import make_iter, lazy_property from evennia.commands.cmdsethandler import CmdSetHandler from evennia.server.session import Session from evennia.scripts.monitorhandler import MONITOR_HANDLER +from evennia.typeclasses.attributes import AttributeHandler, InMemoryAttributeBackend, DbHolder _GA = object.__getattribute__ _SA = object.__setattr__ @@ -25,124 +25,6 @@ _ANSI = None # i18n from django.utils.translation import gettext as _ -# Handlers for Session.db/ndb operation - - -class NDbHolder(object): - """Holder for allowing property access of attributes""" - - def __init__(self, obj, name, manager_name="attributes"): - _SA(self, name, _GA(obj, manager_name)) - _SA(self, "name", name) - - def __getattribute__(self, attrname): - if attrname == "all": - # we allow to overload our default .all - attr = _GA(self, _GA(self, "name")).get("all") - return attr if attr else _GA(self, "all") - return _GA(self, _GA(self, "name")).get(attrname) - - def __setattr__(self, attrname, value): - _GA(self, _GA(self, "name")).add(attrname, value) - - def __delattr__(self, attrname): - _GA(self, _GA(self, "name")).remove(attrname) - - def get_all(self): - return _GA(self, _GA(self, "name")).all() - - all = property(get_all) - - -class NAttributeHandler(object): - """ - NAttributeHandler version without recache protection. - This stand-alone handler manages non-database saving. - It is similar to `AttributeHandler` and is used - by the `.ndb` handler in the same way as `.db` does - for the `AttributeHandler`. - """ - - def __init__(self, obj): - """ - Initialized on the object - """ - self._store = {} - self.obj = weakref.proxy(obj) - - def has(self, key): - """ - Check if object has this attribute or not. - - Args: - key (str): The Nattribute key to check. - - Returns: - has_nattribute (bool): If Nattribute is set or not. - - """ - return key in self._store - - def get(self, key, default=None): - """ - Get the named key value. - - Args: - key (str): The Nattribute key to get. - - Returns: - the value of the Nattribute. - - """ - return self._store.get(key, default) - - def add(self, key, value): - """ - Add new key and value. - - Args: - key (str): The name of Nattribute to add. - value (any): The value to store. - - """ - self._store[key] = value - - def remove(self, key): - """ - Remove Nattribute from storage. - - Args: - key (str): The name of the Nattribute to remove. - - """ - if key in self._store: - del self._store[key] - - def clear(self): - """ - Remove all NAttributes from handler. - - """ - self._store = {} - - def all(self, return_tuples=False): - """ - List the contents of the handler. - - Args: - return_tuples (bool, optional): Defines if the Nattributes - are returns as a list of keys or as a list of `(key, value)`. - - Returns: - nattributes (list): A list of keys `[key, key, ...]` or a - list of tuples `[(key, value), ...]` depending on the - setting of `return_tuples`. - - """ - if return_tuples: - return [(key, value) for (key, value) in self._store.items() if not key.startswith("_")] - return [key for key in self._store if not key.startswith("_")] - # ------------------------------------------------------------- # Server Session @@ -175,6 +57,10 @@ class ServerSession(Session): cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set) + @property + def id(self): + return self.sessid + def at_sync(self): """ This is called whenever a session has been resynced with the @@ -490,7 +376,7 @@ class ServerSession(Session): @lazy_property def nattributes(self): - return NAttributeHandler(self) + return AttributeHandler(self, InMemoryAttributeBackend) @lazy_property def attributes(self): @@ -508,7 +394,7 @@ class ServerSession(Session): try: return self._ndb_holder except AttributeError: - self._ndb_holder = NDbHolder(self, "nattrhandler", manager_name="nattributes") + self._ndb_holder = DbHolder(self, "nattrhandler", manager_name="nattributes") return self._ndb_holder # @ndb.setter diff --git a/evennia/server/session.py b/evennia/server/session.py index f322c551cf..888b05ae45 100644 --- a/evennia/server/session.py +++ b/evennia/server/session.py @@ -101,7 +101,9 @@ class Session(object): the keys given by self._attrs_to_sync. """ - return {attr: getattr(self, attr, None) for attr in settings.SESSION_SYNC_ATTRS} + return { + attr: getattr(self, attr) for attr in settings.SESSION_SYNC_ATTRS if hasattr(self, attr) + } def load_sync_data(self, sessdata): """ diff --git a/evennia/server/tests/test_server.py b/evennia/server/tests/test_server.py index 076a03a879..33a9341cae 100644 --- a/evennia/server/tests/test_server.py +++ b/evennia/server/tests/test_server.py @@ -66,6 +66,7 @@ class TestServer(TestCase): connection=DEFAULT, _IDMAPPER_CACHE_MAXSIZE=1000, _MAINTENANCE_COUNT=3600 - 1, + _LAST_SERVER_TIME_SNAPSHOT=0, ServerConfig=DEFAULT, ) as mocks: mocks["connection"].close = MagicMock() @@ -84,6 +85,7 @@ class TestServer(TestCase): connection=DEFAULT, _IDMAPPER_CACHE_MAXSIZE=1000, _MAINTENANCE_COUNT=3700 - 1, + _LAST_SERVER_TIME_SNAPSHOT=0, ServerConfig=DEFAULT, ) as mocks: mocks["connection"].close = MagicMock() @@ -101,6 +103,7 @@ class TestServer(TestCase): connection=DEFAULT, _IDMAPPER_CACHE_MAXSIZE=1000, _MAINTENANCE_COUNT=(3600 * 7) - 1, + _LAST_SERVER_TIME_SNAPSHOT=0, ServerConfig=DEFAULT, ) as mocks: mocks["connection"].close = MagicMock() @@ -117,6 +120,7 @@ class TestServer(TestCase): connection=DEFAULT, _IDMAPPER_CACHE_MAXSIZE=1000, _MAINTENANCE_COUNT=(3600 * 7) - 1, + _LAST_SERVER_TIME_SNAPSHOT=0, SESSIONS=DEFAULT, _IDLE_TIMEOUT=10, time=DEFAULT, diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 98dc5f50a7..49f9f5fe42 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -4,7 +4,7 @@ Master configuration file for Evennia. NOTE: NO MODIFICATIONS SHOULD BE MADE TO THIS FILE! All settings changes should be done by copy-pasting the variable and -its value to /conf/settings.py. +its value to /server/conf/settings.py. Hint: Don't copy&paste over more from this file than you actually want to change. Anything you don't copy&paste will thus retain its default @@ -332,6 +332,10 @@ CONNECTION_SCREEN_MODULE = "server.conf.connection_screens" # cause issues with menu-logins and autoconnects since the menu will not have # started when the autoconnects starts sending menu commands. DELAY_CMD_LOGINSTART = 0.3 +# A module that must exist - this holds the instructions Evennia will use to +# first prepare the database for use. Generally should not be changed. If this +# cannot be imported, bad things will happen. +INITIAL_SETUP_MODULE = "evennia.server.initial_setup" # An optional module that, if existing, must hold a function # named at_initial_setup(). This hook method can be used to customize # the server's initial setup sequence (the very first startup of the system). diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 6fb4870dae..001cee929d 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -12,6 +12,8 @@ import re import fnmatch import weakref +from collections import defaultdict + from django.db import models from django.conf import settings from django.utils.encoding import smart_str @@ -31,7 +33,7 @@ _TYPECLASS_AGGRESSIVE_CACHE = settings.TYPECLASS_AGGRESSIVE_CACHE # ------------------------------------------------------------- -class Attribute(SharedMemoryModel): +class IAttribute: """ Attributes are things that are specific to different types of objects. For example, a drink container needs to store its fill level, whereas an exit @@ -53,8 +55,113 @@ class Attribute(SharedMemoryModel): - category (str): Optional character string for grouping the Attribute. + This class is an API/Interface/Abstract base class; do not instantiate it directly. """ + @lazy_property + def locks(self): + return LockHandler(self) + + key = property(lambda self: self.db_key) + strvalue = property(lambda self: self.db_strvalue) + category = property(lambda self: self.db_category) + model = property(lambda self: self.db_model) + attrtype = property(lambda self: self.db_attrtype) + date_created = property(lambda self: self.db_date_created) + + def __lock_storage_get(self): + return self.db_lock_storage + + def __lock_storage_set(self, value): + self.db_lock_storage = value + + def __lock_storage_del(self): + self.db_lock_storage = "" + + lock_storage = property(__lock_storage_get, __lock_storage_set, __lock_storage_del) + + def access(self, accessing_obj, access_type="read", default=False, **kwargs): + """ + Determines if another object has permission to access. + + Args: + accessing_obj (object): Entity trying to access this one. + access_type (str, optional): Type of access sought, see + the lock documentation. + default (bool, optional): What result to return if no lock + of access_type was found. The default, `False`, means a lockdown + policy, only allowing explicit access. + kwargs (any, optional): Not used; here to make the API consistent with + other access calls. + + Returns: + result (bool): If the lock was passed or not. + + """ + result = self.locks.check(accessing_obj, access_type=access_type, default=default) + return result + + # + # + # Attribute methods + # + # + + def __str__(self): + return smart_str("%s(%s)" % (self.db_key, self.id)) + + def __repr__(self): + return "%s(%s)" % (self.db_key, self.id) + + +class InMemoryAttribute(IAttribute): + """ + This Attribute is used purely for NAttributes/NAttributeHandler. It has no database backend. + """ + + # Primary Key has no meaning for an InMemoryAttribute. This merely serves to satisfy other code. + + def __init__(self, pk, **kwargs): + """ + Create an Attribute that exists only in Memory. + + Args: + pk (int): This is a fake 'primary key' / id-field. It doesn't actually have to be unique, but is fed an + incrementing number from the InMemoryBackend by default. This is needed only so Attributes can be + sorted. Some parts of the API also see the lack of a .pk field as a sign that the Attribute was + deleted. + **kwargs: Other keyword arguments are used to construct the actual Attribute. + """ + self.id = pk + self.pk = pk + + # Copy all kwargs to local properties. We use db_ for compatability here. + for key, value in kwargs.items(): + # Value and locks are special. We must call the wrappers. + if key == "value": + self.value = value + elif key == "lock_storage": + self.lock_storage = value + else: + setattr(self, f"db_{key}", value) + + # value property (wraps db_value) + def __value_get(self): + return self.db_value + + def __value_set(self, new_value): + self.db_value = new_value + + def __value_del(self): + pass + + value = property(__value_get, __value_set, __value_del) + + +class Attribute(IAttribute, SharedMemoryModel): + """ + This attribute is stored via Django. Most Attributes will be using this class. + """ # # Attribute Database Model setup # @@ -109,35 +216,10 @@ class Attribute(SharedMemoryModel): # Database manager # objects = managers.AttributeManager() - @lazy_property - def locks(self): - return LockHandler(self) - class Meta(object): "Define Django meta options" verbose_name = "Evennia Attribute" - # read-only wrappers - key = property(lambda self: self.db_key) - strvalue = property(lambda self: self.db_strvalue) - category = property(lambda self: self.db_category) - model = property(lambda self: self.db_model) - attrtype = property(lambda self: self.db_attrtype) - date_created = property(lambda self: self.db_date_created) - - def __lock_storage_get(self): - return self.db_lock_storage - - def __lock_storage_set(self, value): - self.db_lock_storage = value - self.save(update_fields=["db_lock_storage"]) - - def __lock_storage_del(self): - self.db_lock_storage = "" - self.save(update_fields=["db_lock_storage"]) - - lock_storage = property(__lock_storage_get, __lock_storage_set, __lock_storage_del) - # Wrapper properties to easily set database fields. These are # @property decorators that allows to access these fields using # normal python operations (without having to remember to save() @@ -146,6 +228,20 @@ class Attribute(SharedMemoryModel): # value = self.attr and del self.attr respectively (where self # is the object in question). + # lock_storage wrapper. Overloaded for saving to database. + def __lock_storage_get(self): + return self.db_lock_storage + + def __lock_storage_set(self, value): + super().__lock_storage_set(value) + self.save(update_fields=["db_lock_storage"]) + + def __lock_storage_del(self): + super().__lock_storage_del() + self.save(update_fields=["db_lock_storage"]) + + lock_storage = property(__lock_storage_get, __lock_storage_set, __lock_storage_del) + # value property (wraps db_value) # @property def __value_get(self): @@ -164,7 +260,6 @@ class Attribute(SharedMemoryModel): see self.__value_get. """ self.db_value = to_pickle(new_value) - # print("value_set, self.db_value:", repr(self.db_value)) # DEBUG self.save(update_fields=["db_value"]) # @value.deleter @@ -174,98 +269,147 @@ class Attribute(SharedMemoryModel): value = property(__value_get, __value_set, __value_del) - # - # - # Attribute methods - # - # - - def __str__(self): - return smart_str("%s(%s)" % (self.db_key, self.id)) - - def __repr__(self): - return "%s(%s)" % (self.db_key, self.id) - - def access(self, accessing_obj, access_type="read", default=False, **kwargs): - """ - Determines if another object has permission to access. - - Args: - accessing_obj (object): Entity trying to access this one. - access_type (str, optional): Type of access sought, see - the lock documentation. - default (bool, optional): What result to return if no lock - of access_type was found. The default, `False`, means a lockdown - policy, only allowing explicit access. - kwargs (any, optional): Not used; here to make the API consistent with - other access calls. - - Returns: - result (bool): If the lock was passed or not. - - """ - result = self.locks.check(accessing_obj, access_type=access_type, default=default) - return result - - # # Handlers making use of the Attribute model # - -class AttributeHandler(object): +class IAttributeBackend: """ - Handler for adding Attributes to the object. + Abstract interface for the backends used by the Attribute Handler. + + All Backends must implement this base class. """ - _m2m_fieldname = "db_attributes" _attrcreate = "attrcreate" _attredit = "attredit" _attrread = "attrread" - _attrtype = None + _attrclass = None - def __init__(self, obj): - """Initialize handler.""" - self.obj = obj - self._objid = obj.id - self._model = to_str(obj.__dbclass__.__name__.lower()) + def __init__(self, handler, attrtype): + self.handler = handler + self.obj = handler.obj + self._attrtype = attrtype + self._objid = handler.obj.id self._cache = {} # store category names fully cached self._catcache = {} # full cache was run on all attributes self._cache_complete = False - def _query_all(self): - "Fetch all Attributes on this object" - query = { - "%s__id" % self._model: self._objid, - "attribute__db_model__iexact": self._model, - "attribute__db_attrtype": self._attrtype, - } - return [ - conn.attribute - for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) - ] + def query_all(self): + """ + Fetch all Attributes from this object. - def _fullcache(self): + Returns: + attrlist (list): A list of Attribute objects. + """ + raise NotImplementedError() + + def query_key(self, key, category): + """ + + Args: + key (str): The key of the Attribute being searched for. + category (str or None): The category of the desired Attribute. + + Returns: + attribute (IAttribute): A single Attribute. + """ + raise NotImplementedError() + + def query_category(self, category): + """ + Returns every matching Attribute as a list, given a category. + + This method calls up whatever storage the backend uses. + + Args: + category (str or None): The category to query. + + Returns: + attrs (list): The discovered Attributes. + """ + raise NotImplementedError() + + def _full_cache(self): """Cache all attributes of this object""" if not _TYPECLASS_AGGRESSIVE_CACHE: return - attrs = self._query_all() - self._cache = dict( - ( - "%s-%s" - % ( - to_str(attr.db_key).lower(), - attr.db_category.lower() if attr.db_category else None, - ), - attr, - ) - for attr in attrs - ) + attrs = self.query_all() + self._cache = {f"{to_str(attr.key).lower()}-{attr.category.lower() if attr.category else None}": attr + for attr in attrs} self._cache_complete = True - def _getcache(self, key=None, category=None): + def _get_cache_key(self, key, category): + """ + + + Args: + key (str): The key of the Attribute being searched for. + category (str or None): The category of the desired Attribute. + + Returns: + attribute (IAttribute): A single Attribute. + """ + cachekey = "%s-%s" % (key, category) + cachefound = False + try: + attr = _TYPECLASS_AGGRESSIVE_CACHE and self._cache[cachekey] + cachefound = True + except KeyError: + attr = None + + if attr and (not hasattr(attr, "pk") and attr.pk is None): + # clear out Attributes deleted from elsewhere. We must search this anew. + attr = None + cachefound = False + del self._cache[cachekey] + if cachefound and _TYPECLASS_AGGRESSIVE_CACHE: + if attr: + return [attr] # return cached entity + else: + return [] # no such attribute: return an empty list + else: + conn = self.query_key(key, category) + if conn: + attr = conn[0].attribute + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = attr + return [attr] if attr.pk else [] + else: + # There is no such attribute. We will explicitly save that + # in our cache to avoid firing another query if we try to + # retrieve that (non-existent) attribute again. + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = None + return [] + + def _get_cache_category(self, category): + """ + Retrieves Attribute list (by category) from cache. + + Args: + category (str or None): The category to query. + + Returns: + attrs (list): The discovered Attributes. + """ + catkey = "-%s" % category + if _TYPECLASS_AGGRESSIVE_CACHE and catkey in self._catcache: + return [attr for key, attr in self._cache.items() if key.endswith(catkey) and attr] + else: + # we have to query to make this category up-date in the cache + attrs = self.query_category(category) + if _TYPECLASS_AGGRESSIVE_CACHE: + for attr in attrs: + if attr.pk: + cachekey = "%s-%s" % (attr.key, category) + self._cache[cachekey] = attr + # mark category cache as up-to-date + self._catcache[catkey] = True + return attrs + + def _get_cache(self, key=None, category=None): """ Retrieve from cache or database (always caches) @@ -291,85 +435,31 @@ class AttributeHandler(object): key = key.strip().lower() if key else None category = category.strip().lower() if category else None if key: - cachekey = "%s-%s" % (key, category) - cachefound = False - try: - attr = _TYPECLASS_AGGRESSIVE_CACHE and self._cache[cachekey] - cachefound = True - except KeyError: - attr = None + return self._get_cache_key(key, category) + return self._get_cache_category(category) - if attr and (not hasattr(attr, "pk") and attr.pk is None): - # clear out Attributes deleted from elsewhere. We must search this anew. - attr = None - cachefound = False - del self._cache[cachekey] - if cachefound and _TYPECLASS_AGGRESSIVE_CACHE: - if attr: - return [attr] # return cached entity - else: - return [] # no such attribute: return an empty list - else: - query = { - "%s__id" % self._model: self._objid, - "attribute__db_model__iexact": self._model, - "attribute__db_attrtype": self._attrtype, - "attribute__db_key__iexact": key.lower(), - "attribute__db_category__iexact": category.lower() if category else None, - } - if not self.obj.pk: - return [] - conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) - if conn: - attr = conn[0].attribute - if _TYPECLASS_AGGRESSIVE_CACHE: - self._cache[cachekey] = attr - return [attr] if attr.pk else [] - else: - # There is no such attribute. We will explicitly save that - # in our cache to avoid firing another query if we try to - # retrieve that (non-existent) attribute again. - if _TYPECLASS_AGGRESSIVE_CACHE: - self._cache[cachekey] = None - return [] - else: - # only category given (even if it's None) - we can't - # assume the cache to be complete unless we have queried - # for this category before - catkey = "-%s" % category - if _TYPECLASS_AGGRESSIVE_CACHE and catkey in self._catcache: - return [attr for key, attr in self._cache.items() if key.endswith(catkey) and attr] - else: - # we have to query to make this category up-date in the cache - query = { - "%s__id" % self._model: self._objid, - "attribute__db_model__iexact": self._model, - "attribute__db_attrtype": self._attrtype, - "attribute__db_category__iexact": category.lower() if category else None, - } - attrs = [ - conn.attribute - for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter( - **query - ) - ] - if _TYPECLASS_AGGRESSIVE_CACHE: - for attr in attrs: - if attr.pk: - cachekey = "%s-%s" % (attr.db_key, category) - self._cache[cachekey] = attr - # mark category cache as up-to-date - self._catcache[catkey] = True - return attrs + def get(self, key=None, category=None): + """ + Frontend for .get_cache. Retrieves Attribute(s). - def _setcache(self, key, category, attr_obj): + Args: + key (str, optional): Attribute key to query for + category (str, optional): Attribiute category + + Returns: + args (list): Returns a list of zero or more matches + found from cache or database. + """ + return self._get_cache(key, category) + + def _set_cache(self, key, category, attr_obj): """ Update cache. Args: key (str): A cleaned key string category (str or None): A cleaned category name - attr_obj (Attribute): The newly saved attribute + attr_obj (IAttribute): The newly saved attribute """ if not _TYPECLASS_AGGRESSIVE_CACHE: @@ -383,7 +473,7 @@ class AttributeHandler(object): self._catcache.pop(catkey, None) self._cache_complete = False - def _delcache(self, key, category): + def _delete_cache(self, key, category): """ Remove attribute from cache @@ -414,6 +504,419 @@ class AttributeHandler(object): self._cache = {} self._catcache = {} + def do_create_attribute(self, key, category, lockstring, value, strvalue): + """ + Does the hard work of actually creating Attributes, whatever is needed. + + Args: + key (str): The Attribute's key. + category (str or None): The Attribute's category, or None + lockstring (str): Any locks for the Attribute. + value (obj): The Value of the Attribute. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + + Returns: + attr (IAttribute): The new Attribute. + """ + raise NotImplementedError() + + def create_attribute(self, key, category, lockstring, value, strvalue=False, cache=True): + """ + Creates Attribute (using the class specified for the backend), (optionally) caches it, and returns it. + + This MUST actively save the Attribute to whatever database backend is used, AND + call self.set_cache(key, category, new_attrobj) + + Args: + key (str): The Attribute's key. + category (str or None): The Attribute's category, or None + lockstring (str): Any locks for the Attribute. + value (obj): The Value of the Attribute. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + cache (bool): Whether to cache the new Attribute + + Returns: + attr (IAttribute): The new Attribute. + """ + attr = self.do_create_attribute(key, category, lockstring, value, strvalue) + if cache: + self._set_cache(key, category, attr) + return attr + + def do_update_attribute(self, attr, value): + """ + Simply sets a new Value to an Attribute. + + Args: + attr (IAttribute): The Attribute being changed. + value (obj): The Value for the Attribute. + + """ + raise NotImplementedError() + + def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + """ + Called opnly by batch add. For the database backend, this is a method + of updating that can alter category and lock-storage. + + Args: + attr_obj (IAttribute): The Attribute being altered. + category (str or None): The attribute's (new) category. + lock_storage (str): The attribute's new locks. + new_value (obj): The Attribute's new value. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + """ + raise NotImplementedError() + + def do_batch_finish(self, attr_objs): + """ + Called only by batch_add. Used for handling database operations and/or caching complications. + + Args: + attr_objs (list of IAttribute): The Attributes created/updated thus far. + """ + raise NotImplementedError() + + def batch_add(self, *args, **kwargs): + """ + Batch-version of `add()`. This is more efficient than + repeat-calling add when having many Attributes to add. + + Args: + indata (list): List of tuples of varying length representing the + Attribute to add to this object. Supported tuples are + - `(key, value)` + - `(key, value, category)` + - `(key, value, category, lockstring)` + - `(key, value, category, lockstring, default_access)` + + Raises: + RuntimeError: If trying to pass a non-iterable as argument. + + Notes: + The indata tuple order matters, so if you want a lockstring + but no category, set the category to `None`. This method + does not have the ability to check editing permissions like + normal .add does, and is mainly used internally. It does not + use the normal self.add but apply the Attributes directly + to the database. + + """ + new_attrobjs = [] + strattr = kwargs.get("strattr", False) + for tup in args: + if not is_iter(tup) or len(tup) < 2: + raise RuntimeError("batch_add requires iterables as arguments (got %r)." % tup) + ntup = len(tup) + keystr = str(tup[0]).strip().lower() + new_value = tup[1] + category = str(tup[2]).strip().lower() if ntup > 2 and tup[2] is not None else None + lockstring = tup[3] if ntup > 3 else "" + + attr_objs = self._get_cache(keystr, category) + + if attr_objs: + attr_obj = attr_objs[0] + # update an existing attribute object + self.do_batch_update_attribute(attr_obj, category, lockstring, new_value, strattr) + else: + new_attr = self.do_create_attribute(keystr, category, lockstring, new_value, strvalue=strattr) + new_attrobjs.append(new_attr) + if new_attrobjs: + self.do_batch_finish(new_attrobjs) + + def do_delete_attribute(self, attr): + """ + Does the hard work of actually deleting things. + + Args: + attr (IAttribute): The attribute to delete. + """ + raise NotImplementedError() + + def delete_attribute(self, attr): + """ + Given an Attribute, deletes it. Also remove it from cache. + + Args: + attr (IAttribute): The attribute to delete. + """ + if not attr: + return + self._delete_cache(attr.key, attr.category) + self.do_delete_attribute(attr) + + def update_attribute(self, attr, value): + """ + Simply updates an Attribute. + + Args: + attr (IAttribute): The attribute to delete. + value (obj): The new value. + """ + self.do_update_attribute(attr, value) + + def do_batch_delete(self, attribute_list): + """ + Given a list of attributes, deletes them all. + The default implementation is fine, but this is overridable since some databases may allow + for a better method. + + Args: + attribute_list (list of IAttribute): + """ + for attribute in attribute_list: + self.delete_attribute(attribute) + + def clear_attributes(self, category, accessing_obj, default_access): + """ + Remove all Attributes on this object. + + Args: + category (str, optional): If given, clear only Attributes + of this category. + accessing_obj (object, optional): If given, check the + `attredit` lock on each Attribute before continuing. + default_access (bool, optional): Use this permission as + fallback if `access_obj` is given but there is no lock of + type `attredit` on the Attribute in question. + + """ + category = category.strip().lower() if category is not None else None + + if not self._cache_complete: + self._full_cache() + + if category is not None: + attrs = [attr for attr in self._cache.values() if attr.category == category] + else: + attrs = self._cache.values() + + if accessing_obj: + self.do_batch_delete([attr for attr in attrs if attr.access(accessing_obj, self._attredit, + default=default_access)]) + else: + # have to cast the results to a list or we'll get a RuntimeError for removing from the dict we're iterating + self.do_batch_delete(list(attrs)) + self.reset_cache() + + def get_all_attributes(self): + """ + Simply returns all Attributes of this object, sorted by their IDs. + + Returns: + attributes (list of IAttribute) + """ + if _TYPECLASS_AGGRESSIVE_CACHE: + if not self._cache_complete: + self._full_cache() + return sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id) + else: + return sorted([attr for attr in self.query_all() if attr], key=lambda o: o.id) + + +class InMemoryAttributeBackend(IAttributeBackend): + """ + This Backend for Attributes stores NOTHING in the database. Everything is kept in memory, and normally lost + on a crash, reload, shared memory flush, etc. It generates IDs for the Attributes it manages, but these are + of little importance beyond sorting and satisfying the caching logic to know an Attribute hasn't been + deleted out from under the cache's nose. + + """ + + _attrclass = InMemoryAttribute + + def __init__(self, handler, attrtype): + super().__init__(handler, attrtype) + self._storage = dict() + self._category_storage = defaultdict(list) + self._id_counter = 0 + + def _next_id(self): + """ + Increments the internal ID counter and returns the new value. + + Returns: + next_id (int): A simple integer. + """ + self._id_counter += 1 + return self._id_counter + + def query_all(self): + return self._storage.values() + + def query_key(self, key, category): + found = self._storage.get((key, category), None) + if found: + return [found] + return [] + + def query_category(self, category): + if category is None: + return self._storage.values() + return self._category_storage.get(category, []) + + def do_create_attribute(self, key, category, lockstring, value, strvalue): + """ + See parent class. + + strvalue has no meaning for InMemory attributes. + """ + new_attr = self._attrclass(pk=self._next_id(), key=key, category=category, lock_storage=lockstring, value=value) + self._storage[(key, category)] = new_attr + self._category_storage[category].append(new_attr) + return new_attr + + def do_update_attribute(self, attr, value): + attr.value = value + + def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + """ + No need to bother saving anything. Just set some values. + """ + attr_obj.db_category = category + attr_obj.db_lock_storage = lock_storage if lock_storage else "" + attr_obj.value = new_value + + def do_batch_finish(self, attr_objs): + """ + Nothing to do here for In-Memory. + + Args: + attr_objs (list of IAttribute): The Attributes created/updated thus far. + """ + pass + + def do_delete_attribute(self, attr): + """ + Removes the Attribute from local storage. Once it's out of the cache, garbage collection will handle the rest. + + Args: + attr (IAttribute): The attribute to delete. + """ + del self._storage[(attr.key, attr.category)] + self._category_storage[attr.category].remove(attr) + + +class ModelAttributeBackend(IAttributeBackend): + """ + Uses Django models for storing Attributes. + """ + _attrclass = Attribute + _m2m_fieldname = "db_attributes" + + def __init__(self, handler, attrtype): + super().__init__(handler, attrtype) + self._model = to_str(handler.obj.__dbclass__.__name__.lower()) + + def query_all(self): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + } + return [ + conn.attribute + for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) + ] + + def query_key(self, key, category): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + "attribute__db_key__iexact": key.lower(), + "attribute__db_category__iexact": category.lower() if category else None, + } + if not self.obj.pk: + return [] + return getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) + + def query_category(self, category): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + "attribute__db_category__iexact": category.lower() if category else None, + } + return [ + conn.attribute + for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter( + **query + ) + ] + + def do_create_attribute(self, key, category, lockstring, value, strvalue): + kwargs = { + "db_key": key, + "db_category": category, + "db_model": self._model, + "db_lock_storage": lockstring if lockstring else "", + "db_attrtype": self._attrtype + } + if strvalue: + kwargs["db_value"] = None + kwargs["db_strvalue"] = value + else: + kwargs["db_value"] = to_pickle(value) + kwargs["db_strvalue"] = None + new_attr = self._attrclass(**kwargs) + new_attr.save() + getattr(self.obj, self._m2m_fieldname).add(new_attr) + self._set_cache(key, category, new_attr) + return new_attr + + def do_update_attribute(self, attr, value): + attr.value = value + + def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + attr_obj.db_category = category + attr_obj.db_lock_storage = lock_storage if lock_storage else "" + if strvalue: + # store as a simple string (will not notify OOB handlers) + attr_obj.db_strvalue = new_value + attr_obj.value = None + else: + # store normally (this will also notify OOB handlers) + attr_obj.value = new_value + attr_obj.db_strvalue = None + attr_obj.save(update_fields=["db_strvalue", "db_value", "db_category", "db_lock_storage"]) + + def do_batch_finish(self, attr_objs): + # Add new objects to m2m field all at once + getattr(self.obj, self._m2m_fieldname).add(*attr_objs) + + def do_delete_attribute(self, attr): + try: + attr.delete() + except AssertionError: + # This could happen if the Attribute has already been deleted. + pass + + +class AttributeHandler: + """ + Handler for adding Attributes to the object. + """ + _attrcreate = "attrcreate" + _attredit = "attredit" + _attrread = "attrread" + _attrtype = None + + def __init__(self, obj, backend_class): + """ + Setup the AttributeHandler. + + Args: + obj (TypedObject): An Account, Object, Channel, ServerSession (not technically a typed object), etc. + backend_class (IAttributeBackend class): The class of the backend to use. + """ + self.obj = obj + self.backend = backend_class(self, self._attrtype) + def has(self, key=None, category=None): """ Checks if the given Attribute (or list of Attributes) exists on @@ -435,7 +938,7 @@ class AttributeHandler(object): category = category.strip().lower() if category is not None else None for keystr in make_iter(key): keystr = key.strip().lower() - ret.extend(bool(attr) for attr in self._getcache(keystr, category)) + ret.extend(bool(attr) for attr in self.backend.get(keystr, category)) return ret[0] if len(ret) == 1 else ret def get( @@ -493,7 +996,7 @@ class AttributeHandler(object): ret = [] for keystr in make_iter(key): # it's okay to send a None key - attr_objs = self._getcache(keystr, category) + attr_objs = self.backend.get(keystr, category) if attr_objs: ret.extend(attr_objs) elif raise_exception: @@ -559,33 +1062,15 @@ class AttributeHandler(object): category = category.strip().lower() if category is not None else None keystr = key.strip().lower() - attr_obj = self._getcache(key, category) + attr_obj = self.backend.get(key, category) if attr_obj: # update an existing attribute object attr_obj = attr_obj[0] - if strattr: - # store as a simple string (will not notify OOB handlers) - attr_obj.db_strvalue = value - attr_obj.save(update_fields=["db_strvalue"]) - else: - # store normally (this will also notify OOB handlers) - attr_obj.value = value + self.backend.update_attribute(attr_obj, value) else: # create a new Attribute (no OOB handlers can be notified) - kwargs = { - "db_key": keystr, - "db_category": category, - "db_model": self._model, - "db_attrtype": self._attrtype, - "db_value": None if strattr else to_pickle(value), - "db_strvalue": value if strattr else None, - } - new_attr = Attribute(**kwargs) - new_attr.save() - getattr(self.obj, self._m2m_fieldname).add(new_attr) - # update cache - self._setcache(keystr, category, new_attr) + self.backend.create_attribute(keystr, category, lockstring, value, strattr) def batch_add(self, *args, **kwargs): """ @@ -618,50 +1103,7 @@ class AttributeHandler(object): to the database. """ - new_attrobjs = [] - strattr = kwargs.get("strattr", False) - for tup in args: - if not is_iter(tup) or len(tup) < 2: - raise RuntimeError("batch_add requires iterables as arguments (got %r)." % tup) - ntup = len(tup) - keystr = str(tup[0]).strip().lower() - new_value = tup[1] - category = str(tup[2]).strip().lower() if ntup > 2 and tup[2] is not None else None - lockstring = tup[3] if ntup > 3 else "" - - attr_objs = self._getcache(keystr, category) - - if attr_objs: - attr_obj = attr_objs[0] - # update an existing attribute object - attr_obj.db_category = category - attr_obj.db_lock_storage = lockstring or "" - attr_obj.save(update_fields=["db_category", "db_lock_storage"]) - if strattr: - # store as a simple string (will not notify OOB handlers) - attr_obj.db_strvalue = new_value - attr_obj.save(update_fields=["db_strvalue"]) - else: - # store normally (this will also notify OOB handlers) - attr_obj.value = new_value - else: - # create a new Attribute (no OOB handlers can be notified) - kwargs = { - "db_key": keystr, - "db_category": category, - "db_model": self._model, - "db_attrtype": self._attrtype, - "db_value": None if strattr else to_pickle(new_value), - "db_strvalue": new_value if strattr else None, - "db_lock_storage": lockstring or "", - } - new_attr = Attribute(**kwargs) - new_attr.save() - new_attrobjs.append(new_attr) - self._setcache(keystr, category, new_attr) - if new_attrobjs: - # Add new objects to m2m field all at once - getattr(self.obj, self._m2m_fieldname).add(*new_attrobjs) + self.backend.batch_add(*args, **kwargs) def remove( self, @@ -710,20 +1152,13 @@ class AttributeHandler(object): for keystr in make_iter(key): keystr = keystr.lower() - attr_objs = self._getcache(keystr, category) + attr_objs = self.backend.get(keystr, category) for attr_obj in attr_objs: if not ( accessing_obj and not attr_obj.access(accessing_obj, self._attredit, default=default_access) ): - try: - attr_obj.delete() - except AssertionError: - print("Assertionerror for attr.delete()") - # this happens if the attr was already deleted - pass - finally: - self._delcache(keystr, category) + self.backend.delete_attribute(attr_obj) if not attr_objs and raise_exception: raise AttributeError @@ -741,27 +1176,7 @@ class AttributeHandler(object): type `attredit` on the Attribute in question. """ - category = category.strip().lower() if category is not None else None - - if not self._cache_complete: - self._fullcache() - - if category is not None: - attrs = [attr for attr in self._cache.values() if attr.category == category] - else: - attrs = self._cache.values() - - if accessing_obj: - [ - attr.delete() - for attr in attrs - if attr and attr.access(accessing_obj, self._attredit, default=default_access) - ] - else: - [attr.delete() for attr in attrs if attr and attr.pk] - self._cache = {} - self._catcache = {} - self._cache_complete = False + self.backend.clear_attributes(category, accessing_obj, default_access) def all(self, accessing_obj=None, default_access=True): """ @@ -780,12 +1195,7 @@ class AttributeHandler(object): their values!) in the handler. """ - if _TYPECLASS_AGGRESSIVE_CACHE: - if not self._cache_complete: - self._fullcache() - attrs = sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id) - else: - attrs = sorted([attr for attr in self._query_all() if attr], key=lambda o: o.id) + attrs = self.backend.get_all_attributes() if accessing_obj: return [ @@ -796,6 +1206,41 @@ class AttributeHandler(object): else: return attrs + def reset_cache(self): + self.backend.reset_cache() + + +# DbHolders for .db and .ndb properties on Typeclasses. + +_GA = object.__getattribute__ +_SA = object.__setattr__ + + +class DbHolder(object): + "Holder for allowing property access of attributes" + + def __init__(self, obj, name, manager_name="attributes"): + _SA(self, name, _GA(obj, manager_name)) + _SA(self, "name", name) + + def __getattribute__(self, attrname): + if attrname == "all": + # we allow to overload our default .all + attr = _GA(self, _GA(self, "name")).get("all") + return attr if attr else _GA(self, "all") + return _GA(self, _GA(self, "name")).get(attrname) + + def __setattr__(self, attrname, value): + _GA(self, _GA(self, "name")).add(attrname, value) + + def __delattr__(self, attrname): + _GA(self, _GA(self, "name")).remove(attrname) + + def get_all(self): + return _GA(self, _GA(self, "name")).get_all_attributes() + + all = property(get_all) + # Nick templating # @@ -1037,92 +1482,3 @@ class NickHandler(AttributeHandler): if is_match: break return raw_string - - -class NAttributeHandler(object): - """ - This stand-alone handler manages non-database saving. - It is similar to `AttributeHandler` and is used - by the `.ndb` handler in the same way as `.db` does - for the `AttributeHandler`. - """ - - def __init__(self, obj): - """ - Initialized on the object - """ - self._store = {} - self.obj = weakref.proxy(obj) - - def has(self, key): - """ - Check if object has this attribute or not. - - Args: - key (str): The Nattribute key to check. - - Returns: - has_nattribute (bool): If Nattribute is set or not. - - """ - return key in self._store - - def get(self, key): - """ - Get the named key value. - - Args: - key (str): The Nattribute key to get. - - Returns: - the value of the Nattribute. - - """ - return self._store.get(key, None) - - def add(self, key, value): - """ - Add new key and value. - - Args: - key (str): The name of Nattribute to add. - value (any): The value to store. - - """ - self._store[key] = value - - def remove(self, key): - """ - Remove Nattribute from storage. - - Args: - key (str): The name of the Nattribute to remove. - - """ - if key in self._store: - del self._store[key] - - def clear(self): - """ - Remove all NAttributes from handler. - - """ - self._store = {} - - def all(self, return_tuples=False): - """ - List the contents of the handler. - - Args: - return_tuples (bool, optional): Defines if the Nattributes - are returns as a list of keys or as a list of `(key, value)`. - - Returns: - nattributes (list): A list of keys `[key, key, ...]` or a - list of tuples `[(key, value), ...]` depending on the - setting of `return_tuples`. - - """ - if return_tuples: - return [(key, value) for (key, value) in self._store.items() if not key.startswith("_")] - return [key for key in self._store if not key.startswith("_")] diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index c886a67400..fe2ffef3b2 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -31,7 +31,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): # Attribute manager methods def get_attribute( - self, key=None, category=None, value=None, strvalue=None, obj=None, attrtype=None + self, key=None, category=None, value=None, strvalue=None, obj=None, attrtype=None, **kwargs ): """ Return Attribute objects by key, by category, by value, by @@ -55,6 +55,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): attrype (str, optional): An attribute-type to search for. By default this is either `None` (normal Attributes) or `"nick"`. + kwargs (any): Currently unused. Reserved for future use. Returns: attributes (list): The matching Attributes. @@ -102,7 +103,9 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): key=key, category=category, value=value, strvalue=strvalue, obj=obj ) - def get_by_attribute(self, key=None, category=None, value=None, strvalue=None, attrtype=None): + def get_by_attribute( + self, key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs + ): """ Return objects having attributes with the given key, category, value, strvalue or combination of those criteria. @@ -122,6 +125,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): attrype (str, optional): An attribute-type to search for. By default this is either `None` (normal Attributes) or `"nick"`. + kwargs (any): Currently unused. Reserved for future use. Returns: obj (list): Objects having the matching Attributes. @@ -488,12 +492,12 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): def get_typeclass_totals(self, *args, **kwargs) -> object: """ Returns a queryset of typeclass composition statistics. - + Returns: - qs (Queryset): A queryset of dicts containing the typeclass (name), + qs (Queryset): A queryset of dicts containing the typeclass (name), the count of objects with that typeclass and a float representing the percentage of objects associated with the typeclass. - + """ return ( self.values("db_typeclass_path") diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index c6869b339f..84ec1dd408 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -36,7 +36,8 @@ from django.urls import reverse from django.utils.encoding import smart_str from django.utils.text import slugify -from evennia.typeclasses.attributes import Attribute, AttributeHandler, NAttributeHandler +from evennia.typeclasses.attributes import Attribute, AttributeHandler, ModelAttributeBackend, InMemoryAttributeBackend +from evennia.typeclasses.attributes import DbHolder from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler from evennia.utils.idmapper.models import SharedMemoryModel, SharedMemoryModelBase @@ -121,33 +122,6 @@ class TypeclassBase(SharedMemoryModelBase): signals.pre_delete.connect(remove_attributes_on_delete, sender=new_class) return new_class - -class DbHolder(object): - "Holder for allowing property access of attributes" - - def __init__(self, obj, name, manager_name="attributes"): - _SA(self, name, _GA(obj, manager_name)) - _SA(self, "name", name) - - def __getattribute__(self, attrname): - if attrname == "all": - # we allow to overload our default .all - attr = _GA(self, _GA(self, "name")).get("all") - return attr if attr else _GA(self, "all") - return _GA(self, _GA(self, "name")).get(attrname) - - def __setattr__(self, attrname, value): - _GA(self, _GA(self, "name")).add(attrname, value) - - def __delattr__(self, attrname): - _GA(self, _GA(self, "name")).remove(attrname) - - def get_all(self): - return _GA(self, _GA(self, "name")).all() - - all = property(get_all) - - # # Main TypedObject abstraction # @@ -301,7 +275,7 @@ class TypedObject(SharedMemoryModel): # initialize all handlers in a lazy fashion @lazy_property def attributes(self): - return AttributeHandler(self) + return AttributeHandler(self, ModelAttributeBackend) @lazy_property def locks(self): @@ -321,7 +295,7 @@ class TypedObject(SharedMemoryModel): @lazy_property def nattributes(self): - return NAttributeHandler(self) + return AttributeHandler(self, InMemoryAttributeBackend) class Meta(object): """ diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py index 8632db5fc6..eb2f8e45e1 100644 --- a/evennia/typeclasses/tests.py +++ b/evennia/typeclasses/tests.py @@ -26,12 +26,12 @@ class TestAttributes(EvenniaTest): key = "testattr" value = "test attr value " self.obj1.attributes.add(key, value) - self.assertFalse(self.obj1.attributes._cache) + self.assertFalse(self.obj1.attributes.backend._cache) self.assertEqual(self.obj1.attributes.get(key), value) self.obj1.db.testattr = value self.assertEqual(self.obj1.db.testattr, value) - self.assertFalse(self.obj1.attributes._cache) + self.assertFalse(self.obj1.attributes.backend._cache) def test_weird_text_save(self): "test 'weird' text type (different in py2 vs py3)" diff --git a/evennia/utils/create.py b/evennia/utils/create.py index 5076f60ee3..182a1e4ba3 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -501,6 +501,8 @@ def create_account( report_to (Object): An object with a msg() method to report errors to. If not given, errors will be logged. + Returns: + Account: The newly created Account. Raises: ValueError: If `key` already exists in database. diff --git a/evennia/utils/search.py b/evennia/utils/search.py index 1a788300cf..c7231309ac 100644 --- a/evennia/utils/search.py +++ b/evennia/utils/search.py @@ -205,27 +205,35 @@ help_entries = search_help # not the attribute object itself (this is usually what you want) -def search_object_attribute(key=None, category=None, value=None, strvalue=None): +def search_object_attribute( + key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs +): return ObjectDB.objects.get_by_attribute( - key=key, category=category, value=value, strvalue=strvalue + key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) -def search_account_attribute(key=None, category=None, value=None, strvalue=None): +def search_account_attribute( + key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs +): return AccountDB.objects.get_by_attribute( - key=key, category=category, value=value, strvalue=strvalue + key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) -def search_script_attribute(key=None, category=None, value=None, strvalue=None): +def search_script_attribute( + key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs +): return ScriptDB.objects.get_by_attribute( - key=key, category=category, value=value, strvalue=strvalue + key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) -def search_channel_attribute(key=None, category=None, value=None, strvalue=None): +def search_channel_attribute( + key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs +): return Channel.objects.get_by_attribute( - key=key, category=category, value=value, strvalue=strvalue + key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) @@ -243,7 +251,7 @@ search_attribute_object = ObjectDB.objects.get_attribute # object itself (this is usually what you want) -def search_object_by_tag(key=None, category=None): +def search_object_by_tag(key=None, category=None, tagtype=None, **kwargs): """ Find object based on tag or category. @@ -252,6 +260,11 @@ def search_object_by_tag(key=None, category=None): category (str, optional): The category of tag to search for. If not set, uncategorized tags will be searched. + tagtype (str, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. This always apply to all queried tags. + kwargs (any): Other optional parameter that may be supported + by the manager method. Returns: matches (list): List of Objects with tags matching @@ -259,13 +272,13 @@ def search_object_by_tag(key=None, category=None): matches were found. """ - return ObjectDB.objects.get_by_tag(key=key, category=category) + return ObjectDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) search_tag = search_object_by_tag # this is the most common case -def search_account_tag(key=None, category=None): +def search_account_tag(key=None, category=None, tagtype=None, **kwargs): """ Find account based on tag or category. @@ -274,6 +287,11 @@ def search_account_tag(key=None, category=None): category (str, optional): The category of tag to search for. If not set, uncategorized tags will be searched. + tagtype (str, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. This always apply to all queried tags. + kwargs (any): Other optional parameter that may be supported + by the manager method. Returns: matches (list): List of Accounts with tags matching @@ -281,10 +299,10 @@ def search_account_tag(key=None, category=None): matches were found. """ - return AccountDB.objects.get_by_tag(key=key, category=category) + return AccountDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) -def search_script_tag(key=None, category=None): +def search_script_tag(key=None, category=None, tagtype=None, **kwargs): """ Find script based on tag or category. @@ -293,6 +311,11 @@ def search_script_tag(key=None, category=None): category (str, optional): The category of tag to search for. If not set, uncategorized tags will be searched. + tagtype (str, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. This always apply to all queried tags. + kwargs (any): Other optional parameter that may be supported + by the manager method. Returns: matches (list): List of Scripts with tags matching @@ -300,10 +323,10 @@ def search_script_tag(key=None, category=None): matches were found. """ - return ScriptDB.objects.get_by_tag(key=key, category=category) + return ScriptDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) -def search_channel_tag(key=None, category=None): +def search_channel_tag(key=None, category=None, tagtype=None, **kwargs): """ Find channel based on tag or category. @@ -312,6 +335,11 @@ def search_channel_tag(key=None, category=None): category (str, optional): The category of tag to search for. If not set, uncategorized tags will be searched. + tagtype (str, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. This always apply to all queried tags. + kwargs (any): Other optional parameter that may be supported + by the manager method. Returns: matches (list): List of Channels with tags matching @@ -319,7 +347,7 @@ def search_channel_tag(key=None, category=None): matches were found. """ - return Channel.objects.get_by_tag(key=key, category=category) + return Channel.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) # search for tag objects (not the objects they are attached to diff --git a/evennia/utils/test_resources.py b/evennia/utils/test_resources.py index d5ca8e1f32..606927e524 100644 --- a/evennia/utils/test_resources.py +++ b/evennia/utils/test_resources.py @@ -157,3 +157,15 @@ class EvenniaTest(TestCase): self.account.delete() self.account2.delete() super().tearDown() + +class LocalEvenniaTest(EvenniaTest): + """ + This test class is intended for inheriting in mygame tests. + It helps ensure your tests are run with your own objects. + """ + account_typeclass = settings.BASE_ACCOUNT_TYPECLASS + object_typeclass = settings.BASE_OBJECT_TYPECLASS + character_typeclass = settings.BASE_CHARACTER_TYPECLASS + exit_typeclass = settings.BASE_EXIT_TYPECLASS + room_typeclass = settings.BASE_ROOM_TYPECLASS + script_typeclass = settings.BASE_SCRIPT_TYPECLASS diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 5642caad25..dba466e454 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -2101,7 +2101,9 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): if multimatch_string: error = "%s\n" % multimatch_string else: - error = _("More than one match for '%s' (please narrow target):\n" % query) + error = _("More than one match for '{query}' (please narrow target):\n").format( + query=query + ) for num, result in enumerate(matches): # we need to consider Commands, where .aliases is a list diff --git a/evennia/utils/validatorfuncs.py b/evennia/utils/validatorfuncs.py index bca25874cb..3443c47d5c 100644 --- a/evennia/utils/validatorfuncs.py +++ b/evennia/utils/validatorfuncs.py @@ -15,6 +15,7 @@ from django.core.exceptions import ValidationError as _error from django.core.validators import validate_email as _val_email from evennia.utils.ansi import strip_ansi from evennia.utils.utils import string_partial_matching as _partial +from django.utils.translation import gettext as _ _TZ_DICT = {str(tz): _pytz.timezone(tz) for tz in _pytz.common_timezones} @@ -58,7 +59,7 @@ def datetime(entry, option_key="Datetime", account=None, from_tz=None, **kwargs) """ if not entry: - raise ValueError(f"No {option_key} entered!") + raise ValueError(_("No {option_key} entered!").format(option_key=option_key)) if not from_tz: from_tz = _pytz.UTC if account: @@ -66,7 +67,11 @@ def datetime(entry, option_key="Datetime", account=None, from_tz=None, **kwargs) try: from_tz = _pytz.timezone(acct_tz) except Exception as err: - raise ValueError(f"Timezone string '{acct_tz}' is not a valid timezone ({err})") + raise ValueError( + _("Timezone string '{acct_tz}' is not a valid timezone ({err})").format( + acct_tz=acct_tz, err=err + ) + ) else: from_tz = _pytz.UTC