Imported from archive.
authorJeremy Stanley <fungi@yuggoth.org>
Fri, 25 Dec 2009 20:15:24 +0000 (20:15 +0000)
committerJeremy Stanley <fungi@yuggoth.org>
Fri, 25 Dec 2009 20:15:24 +0000 (20:15 +0000)
* LICENSE: Moved to doc subdirectory, indicating it's a
documentation file.

* archetype, command, menu: Moved to share subdirectory, indicating
it's generally read-only data. Also appended .mpy extention to file
names, to indicate they're mudpy data.

* banner.txt, login.txt, mudpy.conf: Moved to etc subdirectory,
indicating they're generally hand-edited configuration data.

* lib/mudpy/__init__.py: Created a new Python module package, to
begin the process of splitting up the old monolithic module.

* mudpy: Moved to bin subdirectory, indicating it's directly
executable.

* mudpy.conf (__control__, internal:storage), mudpy.py
(DataFile.load, find_file, replace_macros): New functionality allows
arbitrary location of data files from relative or absolute paths out
of a prioritized series of potential directory trees.

* mudpy.conf (internal:network): Changed host to the IPv6 localhost
address of ::1 instead of the old IPv4 127.0.0.1 equivalent.

* mudpy.py: Renamed to misc.py and added to the new lib/mudpy Python
module package.
(Universe.initialize_server_socket): If local IPv6 support is
present default to listening on ::, otherwise fall back to 0.0.0.0
like before.
(command_say): Instead of denying parameters which look like
replacement macros, they are simply escaped before being processed.
Messages enclosed in quotation marks no longer get language fix-ups
applied. Fixed a bug where a type exception could be triggered if an
actor provided parameters to the say command which evaluated to an
empty string; reported by Mark Kolloros, a.k.a. Colourful.

* sample/index: Renamed to __init__.mpy in an effort to emulate
Python module package structure for groups of mudpy data files.

* sample/location, sample/prop: Appended .mpy extention to file
names, to indicate they're mudpy data.

13 files changed:
bin/mudpy [moved from mudpy with 50% similarity]
doc/LICENSE [moved from LICENSE with 100% similarity]
etc/banner.txt [moved from banner.txt with 100% similarity]
etc/login.txt [moved from login.txt with 100% similarity]
etc/mudpy.conf [moved from mudpy.conf with 79% similarity]
lib/mudpy/__init__.py [new file with mode: 0644]
lib/mudpy/misc.py [moved from mudpy.py with 90% similarity]
sample/__init__.mpy [moved from sample/index with 83% similarity]
sample/location.mpy [moved from sample/location with 100% similarity]
sample/prop.mpy [moved from sample/prop with 100% similarity]
share/archetype.mpy [moved from archetype with 100% similarity]
share/command.mpy [moved from command with 95% similarity]
share/menu.mpy [moved from menu with 100% similarity]

diff --git a/mudpy b/bin/mudpy
similarity index 50%
rename from mudpy
rename to bin/mudpy
index 230ab0e..0dfefd2 100755 (executable)
--- a/mudpy
+++ b/bin/mudpy
@@ -7,34 +7,25 @@ u"""Skeletal executable for the mudpy engine."""
 # terms provided in the LICENSE file distributed with this software.
 
 # core objects for the mudpy engine
+import os.path, sys
+sys.path.append( os.path.realpath(u"lib") )
 import mudpy
 
-# log an initial message
-import sys
-mudpy.log(u"Started mudpy with command line: " + u" ".join(sys.argv))
-
-# fork and disassociate
-mudpy.daemonize(mudpy.universe)
-
-# make the pidfile
-mudpy.create_pidfile(mudpy.universe)
+# start it up
+mudpy.misc.setup()
 
 # loop indefinitely while the world is not flagged for termination or
 # there are still connected users
-while not mudpy.universe.terminate_flag or mudpy.universe.userlist:
+while not mudpy.misc.universe.terminate_flag or mudpy.misc.universe.userlist:
 
    # the world was flagged for a reload of all code/data
-   if mudpy.universe.reload_flag: reload(mudpy)
+   if mudpy.misc.universe.reload_flag:
+      reload(mudpy)
+      mudpy.misc.reload_data()
+      mudpy.misc.universe.reload_flag = False
 
    # do what needs to be done on each pulse
-   mudpy.on_pulse()
-
-# the loop has terminated, so save persistent data
-mudpy.universe.save()
-
-# log a final message
-mudpy.log(u"Shutting down now.")
-
-# get rid of the pidfile
-mudpy.remove_pidfile(mudpy.universe)
+   mudpy.misc.on_pulse()
 
+# shut it all down
+mudpy.misc.finish()
similarity index 100%
rename from LICENSE
rename to doc/LICENSE
similarity index 100%
rename from banner.txt
rename to etc/banner.txt
similarity index 100%
rename from login.txt
rename to etc/login.txt
similarity index 79%
rename from mudpy.conf
rename to etc/mudpy.conf
index 5368c15..584da6a 100644 (file)
@@ -3,9 +3,10 @@
 # terms provided in the LICENSE file distributed with this software.
 
 [__control__]
-default_files = { "account": "account", "actor": "actor", "command": "command", "internal": "internal", "location": "location", "menu": "menu", "other": "other", "prop": "prop" }
-include_files = [ "archetype", "sample/index" ]
-private_files = account
+default_files = { "account": "account.mpy", "actor": "actor.mpy", "command": "command.mpy", "internal": "internal.mpy", "location": "location.mpy", "menu": "menu.mpy", "other": "other.mpy", "prop": "prop.mpy" }
+include_dirs = "sample"
+include_files = "archetype.mpy"
+private_files = "account.mpy"
 read_only = yes
 
 [internal:language]
@@ -26,13 +27,19 @@ stdout = yes
 #syslog = mudpy
 
 [internal:network]
-host = 127.0.0.1
+host = ::1
+#host = 127.0.0.1
 port = 6669
 
 [internal:process]
 #daemon = yes
 #pidfile = mudpy.pid
 
+[internal:storage]
+default_dir = "data"
+#root_path = "."
+search_path = [ "", "etc", "share", "data" ]
+
 [internal:time]
 definition_d = 24h
 definition_h = 60mi
diff --git a/lib/mudpy/__init__.py b/lib/mudpy/__init__.py
new file mode 100644 (file)
index 0000000..06b1e23
--- /dev/null
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+u"""Core modules package for the mudpy engine."""
+
+# Copyright (c) 2004-2009 Jeremy Stanley <fungi@yuggoth.org>. Permission
+# to use, copy, modify, and distribute this software is granted under
+# terms provided in the LICENSE file distributed with this software.
+
+def load():
+   u"""Import/reload some modules (be careful, as this can result in loops)."""
+
+   # pick up the modules list from this package
+   global modules
+
+   # iterate over the list of modules provided
+   for module in modules:
+
+      # attempt to reload the module, assuming it was probably imported earlier
+      try: exec(u"reload(%s)" % module)
+
+      # must not have been, so import it now
+      except NameError: exec(u"import %s" % module)
+
+# load the modules contained in this package
+modules = [ u"misc" ]
+load()
similarity index 90%
rename from mudpy.py
rename to lib/mudpy/misc.py
index 6ec7c5b..033699c 100644 (file)
--- a/mudpy.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-u"""Core objects for the mudpy engine."""
+u"""Miscellaneous functions for the mudpy engine."""
 
 # Copyright (c) 2004-2009 Jeremy Stanley <fungi@yuggoth.org>. Permission
 # to use, copy, modify, and distribute this software is granted under
@@ -102,6 +102,9 @@ class Element:
       if default is None: default = u""
       if self.origin.data.has_option(self.key, facet):
          raw_data = self.origin.data.get(self.key, facet)
+         if raw_data.startswith(u"u\"") or raw_data.startswith(u"u'"):
+            raw_data = raw_data[1:]
+         raw_data.strip(u"\"'")
          if type(raw_data) == str: return unicode(raw_data, "utf-8")
          else: return raw_data
       elif self.has_facet(u"inherit"):
@@ -356,34 +359,58 @@ class DataFile:
       if os.access(self.filename, os.R_OK): self.data.read(self.filename)
       if not hasattr(self.universe, u"files"): self.universe.files = {}
       self.universe.files[self.filename] = self
+      includes = []
       if self.data.has_option(u"__control__", u"include_files"):
-         includes = makelist(self.data.get(u"__control__", u"include_files"))
-      else: includes = []
+         for included in makelist(
+            self.data.get(u"__control__", u"include_files")
+         ):
+            included = find_file(
+               included,
+               relative=self.filename,
+               universe=self.universe
+            )
+            if included not in includes: includes.append(included)
+      if self.data.has_option(u"__control__", u"include_dirs"):
+         for included in [ os.path.join(x, u"__init__.mpy") for x in makelist(
+            self.data.get(u"__control__", u"include_dirs")
+         ) ]:
+            included = find_file(
+               included,
+               relative=self.filename,
+               universe=self.universe
+            )
+            if included not in includes: includes.append(included)
       if self.data.has_option(u"__control__", u"default_files"):
          origins = makedict(self.data.get(u"__control__", u"default_files"))
          for key in origins.keys():
-            if not os.path.isabs(origins[key]):
-               origins[key] = os.path.join(
-                  os.path.dirname(self.filename), origins[key]
-               )
-            if not origins[key] in includes: includes.append(origins[key])
+            origins[key] = find_file(
+               origins[key],
+               relative=self.filename,
+               universe=self.universe
+            )
+            if origins[key] not in includes: includes.append(origins[key])
             self.universe.default_origins[key] = origins[key]
-            if not key in self.universe.categories:
+            if key not in self.universe.categories:
                self.universe.categories[key] = {}
       if self.data.has_option(u"__control__", u"private_files"):
          for item in makelist(self.data.get(u"__control__", u"private_files")):
-            if not item in includes: includes.append(item)
-            if not item in self.universe.private_files:
-               if not os.path.isabs(item):
-                  item = os.path.join(os.path.dirname(self.filename), item)
+            item = find_file(
+               item,
+               relative=self.filename,
+               universe=self.universe
+            )
+            if item not in includes: includes.append(item)
+            if item not in self.universe.private_files:
                self.universe.private_files.append(item)
       for section in self.data.sections():
          if section != u"__control__":
             Element(section, self.universe, self.filename)
       for include_file in includes:
          if not os.path.isabs(include_file):
-            include_file = os.path.join(
-               os.path.dirname(self.filename), include_file
+            include_file = find_file(
+               include_file,
+               relative=self.filename,
+               universe=self.universe
             )
          if include_file not in self.universe.files or not self.universe.files[
             include_file
@@ -567,8 +594,22 @@ class Universe:
       u"""Create and open the listening socket."""
       import socket
 
-      # create a new ipv4 stream-type socket object
-      self.listening_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+      # need to know the local address and port number for the listener
+      host = self.categories[u"internal"][u"network"].get(u"host")
+      port = self.categories[u"internal"][u"network"].getint(u"port")
+
+      # if no host was specified, bind to all local addresses (preferring ipv6)
+      if not host:
+         if socket.has_ipv6: host = u"::"
+         else: host = u"0.0.0.0"
+
+      # figure out if this is ipv4 or v6
+      family = socket.getaddrinfo(host, port)[0][0]
+      if family is socket.AF_INET6 and not socket.has_ipv6:
+         log(u"No support for IPv6 address %s (use IPv4 instead)." % host)
+
+      # create a new stream-type socket object
+      self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
 
       # set the socket options to allow existing open ones to be
       # reused (fixes a bug where the server can't bind for a minute
@@ -578,8 +619,6 @@ class Universe:
       )
 
       # bind the socket to to our desired server ipa and port
-      host = self.categories[u"internal"][u"network"].get(u"host")
-      port = self.categories[u"internal"][u"network"].getint(u"port")
       self.listening_socket.bind((host, port))
 
       # disable blocking so we can proceed whether or not we can
@@ -590,7 +629,6 @@ class Universe:
       self.listening_socket.listen(1)
 
       # note that we're now ready for user connections
-      if not host: host = u"0.0.0.0"
       log(
          u"Listening for Telnet connections on: " + host + u":" + unicode(port)
       )
@@ -659,7 +697,7 @@ class User:
             logline += self.account.get(u"name")
          else:
             logline += u"an unknown user"
-         logline += u" after idling too long in a " + self.state + u" state."
+         logline += u" after idling too long in the " + self.state + u" state."
          log(logline, 2)
          self.state = u"disconnecting"
          self.menu_seen = False
@@ -920,8 +958,8 @@ class User:
                account = self.account.get(u"name")
             else: account = u"an unknown user"
             log(
-               u"Sending to " + account \
-                  + u" raised an exception (broken pipe?)."
+               u"Sending to %s raised an exception (broken pipe?)." % account,
+               7
             )
             pass
 
@@ -1200,6 +1238,7 @@ class User:
 def makelist(value):
    u"""Turn string into list type."""
    if value[0] + value[-1] == u"[]": return eval(value)
+   elif value[0] + value[-1] == u"\"\"": return [ value[1:-1] ]
    else: return [ value ]
 
 def makedict(value):
@@ -1551,7 +1590,7 @@ def replace_macros(user, text, is_input=False):
 
       # this is how we handle local file inclusion (dangerous!)
       elif macro.startswith(u"inc:"):
-         incfile = os.path.join(universe.startdir, macro[4:])
+         incfile = find_file(macro[4:], universe=universe)
          if os.path.exists(incfile):
             incfd = codecs.open(incfile, u"r", u"utf-8")
             replacement = u""
@@ -2210,15 +2249,21 @@ def command_say(actor, parameters):
    u"""Speak to others in the same room."""
    import unicodedata
 
-   # check for replacement macros
-   if replace_macros(actor.owner, parameters, True) != parameters:
-      actor.send(u"You cannot speak $_(replacement macros).")
+   # check for replacement macros and escape them
+   parameters = escape_macros(parameters)
 
-   # the user entered a message
-   elif parameters:
+   # if the message is wrapped in quotes, remove them and leave contents intact
+   if parameters.startswith(u"\"") and parameters.endswith(u"\""):
+      message = parameters[1:-1]
+      literal = True
 
-      # get rid of quote marks on the ends of the message
+   # otherwise, get rid of stray quote marks on the ends of the message
+   else:
       message = parameters.strip(u"\"'`")
+      literal = False
+
+   # the user entered a message
+   if message:
 
       # match the punctuation used, if any, to an action
       actions = universe.categories[u"internal"][u"language"].getdict(
@@ -2229,36 +2274,44 @@ def command_say(actor, parameters):
       )
       action = u""
       for mark in actions.keys():
-         if message.endswith(mark):
+         if not literal and message.endswith(mark):
             action = actions[mark]
             break
 
       # add punctuation if needed
       if not action:
          action = actions[default_punctuation]
-         if message and not unicodedata.category(message[-1]) == u"Po":
+         if message and not (
+            literal or unicodedata.category(message[-1]) == u"Po"
+         ):
             message += default_punctuation
 
-      # decapitalize the first letter to improve matching
-      message = message[0].lower() + message[1:]
-
-      # iterate over all words in message, replacing typos
-      typos = universe.categories[u"internal"][u"language"].getdict(u"typos")
-      words = message.split()
-      for index in range(len(words)):
-         word = words[index]
-         while unicodedata.category(word[0]) == u"Po":
-            word = word[1:]
-         while unicodedata.category(word[-1]) == u"Po":
-            word = word[:-1]
-         if word in typos.keys():
-            words[index] = words[index].replace(word, typos[word])
-      message = u" ".join(words)
-
-      # capitalize the first letter
-      message = message[0].upper() + message[1:]
-
-      # tell the room
+      # failsafe checks to avoid unwanted reformatting and null strings
+      if message and not literal:
+
+         # decapitalize the first letter to improve matching
+         message = message[0].lower() + message[1:]
+
+         # iterate over all words in message, replacing typos
+         typos = universe.categories[u"internal"][u"language"].getdict(
+            u"typos"
+         )
+         words = message.split()
+         for index in range(len(words)):
+            word = words[index]
+            while unicodedata.category(word[0]) == u"Po":
+               word = word[1:]
+            while unicodedata.category(word[-1]) == u"Po":
+               word = word[:-1]
+            if word in typos.keys():
+               words[index] = words[index].replace(word, typos[word])
+         message = u" ".join(words)
+
+         # capitalize the first letter
+         message = message[0].upper() + message[1:]
+
+   # tell the room
+   if message:
       actor.echo_to_location(
          actor.get(u"name") + u" " + action + u"s, \"" + message + u"\""
       )
@@ -2484,6 +2537,128 @@ def command_error(actor, input_data):
    # send the error message
    actor.send(message)
 
+def find_file(
+   file_name=None,
+   root_path=None,
+   search_path=None,
+   default_dir=None,
+   relative=None,
+   universe=None
+):
+   u"""Return an absolute file path based on configuration."""
+   import os, os.path, sys
+
+   # make sure to get rid of any surrounding quotes first thing
+   if file_name: file_name = file_name.strip(u"\"'")
+
+   # this is all unnecessary if it's already absolute
+   if file_name and os.path.isabs(file_name):
+      return os.path.realpath(file_name)
+
+   # when no file name is specified, look for <argv[0]>.conf
+   elif not file_name: file_name = os.path.basename( sys.argv[0] ) + u".conf"
+
+   # if a universe was provided, try to get some defaults from there
+   if universe:
+
+      if hasattr(
+         universe,
+         u"contents"
+      ) and u"internal:storage" in universe.contents:
+         storage = universe.categories[u"internal"][u"storage"]
+         if not root_path: root_path = storage.get(u"root_path").strip("\"'")
+         if not search_path: search_path = storage.getlist(u"search_path")
+         if not default_dir: default_dir = storage.get(u"default_dir").strip("\"'")
+
+      # if there's only one file loaded, try to work around a chicken<egg
+      elif hasattr(universe, u"files") and len(
+         universe.files
+      ) == 1 and not universe.files[universe.files.keys()[0]].is_writeable():
+         data_file = universe.files[universe.files.keys()[0]].data
+
+         # try for a fallback default directory
+         if not default_dir and data_file.has_option(
+            u"internal:storage",
+            u"default_dir"
+         ):
+            default_dir = data_file.get(
+               u"internal:storage",
+               u"default_dir"
+            ).strip(u"\"'")
+
+         # try for a fallback root path
+         if not root_path and data_file.has_option(
+            u"internal:storage",
+            u"root_path"
+         ):
+            root_path = data_file.get(
+               u"internal:storage",
+               u"root_path"
+            ).strip(u"\"'")
+
+         # try for a fallback search path
+         if not search_path and data_file.has_option(
+            u"internal:storage",
+            u"search_path"
+         ):
+            search_path = makelist(
+               data_file.get(u"internal:storage", u"search_path").strip(u"\"'")
+            )
+
+      # another fallback root path, this time from the universe startdir
+      if not root_path and hasattr(universe, "startdir"):
+         root_path = universe.startdir
+
+   # when no root path is specified, assume the current working directory
+   if not root_path: root_path = os.getcwd()
+
+   # otherwise, make sure it's absolute
+   elif not os.path.isabs(root_path): root_path = os.path.realpath(root_path)
+
+   # if there's no search path, just use the root path and etc
+   if not search_path: search_path = [root_path, u"etc"]
+
+   # work on a copy of the search path, to avoid modifying the caller's
+   else: search_path = search_path[:]
+
+   # if there's no default path, use the last element of the search path
+   if not default_dir: default_dir = search_path[-1]
+
+   # if an existing file or directory reference was supplied, prepend it
+   if relative:
+      relative = relative.strip(u"\"'")
+      if os.path.isdir(relative): search_path = [relative] + search_path
+      else: search_path = [ os.path.dirname(relative) ] + search_path
+
+   # make the search path entries absolute and throw away any dupes
+   clean_search_path = []
+   for each_path in search_path:
+      each_path = each_path.strip(u"\"'")
+      if not os.path.isabs(each_path):
+         each_path = os.path.realpath( os.path.join(root_path, each_path) )
+      if each_path not in clean_search_path:
+         clean_search_path.append(each_path)
+
+   # start hunting for the file now
+   for each_path in clean_search_path:
+
+      # if the file exists and is readable, we're done
+      if os.path.isfile( os.path.join(each_path, file_name) ):
+         file_name = os.path.realpath( os.path.join(each_path, file_name) )
+         break
+
+   # it didn't exist after all, so use the default path instead
+   if not os.path.isabs(file_name):
+      file_name = os.path.join(default_dir, file_name)
+   if not os.path.isabs(file_name):
+      file_name = os.path.join(root_path, file_name)
+
+   # and normalize it last thing before returning
+   file_name = os.path.realpath(file_name)
+
+   # normalize the resulting file path and hand it back
+   return file_name
+
 def daemonize(universe):
    u"""Fork and disassociate from everything."""
    import codecs, ctypes, ctypes.util, os, os.path, sys
@@ -2624,21 +2799,55 @@ def sighook(what, where):
    # log what happened
    log(message, 8)
 
-# redefine sys.excepthook with ours
-import sys
-sys.excepthook = excepthook
+def override_excepthook():
+   u"""Redefine sys.excepthook with our own."""
+   import sys
+   sys.excepthook = excepthook
 
-# assign the sgnal handlers
-import signal
-signal.signal(signal.SIGHUP, sighook)
-signal.signal(signal.SIGTERM, sighook)
+def assign_sighook():
+   u"""Assign a customized handler for some signals."""
+   import signal
+   signal.signal(signal.SIGHUP, sighook)
+   signal.signal(signal.SIGTERM, sighook)
+
+def setup():
+   """This contains functions to be performed when starting the engine."""
+   import sys
 
-# if there is no universe, create an empty one
-if not u"universe" in locals():
+   # see if a configuration file was specified
    if len(sys.argv) > 1: conffile = sys.argv[1]
    else: conffile = u""
+
+   # the big bang
+   global universe
    universe = Universe(conffile, True)
-elif universe.reload_flag:
-   universe = universe.new()
-   reload_data()
 
+   # log an initial message
+   log(u"Started mudpy with command line: " + u" ".join(sys.argv))
+
+   # fork and disassociate
+   daemonize(universe)
+
+   # override the default exception handler so we get logging first thing
+   override_excepthook()
+
+   # set up custom signal handlers
+   assign_sighook()
+
+   # make the pidfile
+   create_pidfile(universe)
+
+   # pass the initialized universe back
+   return universe
+
+def finish():
+   """This contains functions to be performed when shutting down the engine."""
+
+   # the loop has terminated, so save persistent data
+   universe.save()
+
+   # log a final message
+   log(u"Shutting down now.")
+
+   # get rid of the pidfile
+   remove_pidfile(universe)
similarity index 83%
rename from sample/index
rename to sample/__init__.mpy
index e543503..93925a0 100644 (file)
@@ -3,6 +3,6 @@
 # terms provided in the LICENSE file distributed with this software.
 
 [__control__]
-include_files = [ "location", "prop" ]
+include_files = [ "location.mpy", "prop.mpy" ]
 read_only = yes
 
similarity index 100%
rename from sample/location
rename to sample/location.mpy
similarity index 100%
rename from sample/prop
rename to sample/prop.mpy
similarity index 100%
rename from archetype
rename to share/archetype.mpy
similarity index 95%
rename from command
rename to share/command.mpy
index 17ac3e3..cd907ff 100644 (file)
--- a/command
@@ -64,7 +64,7 @@ help = This will reload all python modules and read-only data files.
 [command:say]
 action = command_say(actor, parameters)
 description = State something out loud.
-help = This allows you to speak to other characters within the same room. If you end your sentence with punctuation, the message displayed will incorporate an appropriate action (ask, exclaim, et cetera). It will also correct common typographical errors, add punctuation and capitalize your sentence as needed (assuming you speak one sentence per line). For example:$(eol)$(eol)   > say youre sure i went teh wrong way?$(eol)   You ask, "You're sure I went the wrong way?"
+help = This allows you to speak to other characters within the same room. If you end your sentence with punctuation, the message displayed will incorporate an appropriate action (ask, exclaim, et cetera). It will also correct common typographical errors, add punctuation and capitalize your sentence as needed (assuming you speak one sentence per line). For example:$(eol)$(eol)   > say youre sure i went teh wrong way?$(eol)   You ask, "You're sure I went the wrong way?"$(eol)$(eol)If necessary, enclose literal statements in quotation marks:$(eol)$(eol)   > say "youre sure i went teh wrong way?"$(eol)   You say, "youre sure i went teh wrong way?"
 see_also = chat
 
 [command:set]
similarity index 100%
rename from menu
rename to share/menu.mpy