diff --git a/bin/addons/__init__.py b/bin/addons/__init__.py
index cc3bdc2ce7b..0c591f9ca4f 100644
--- a/bin/addons/__init__.py
+++ b/bin/addons/__init__.py
@@ -757,16 +757,21 @@ def load_module_graph(cr, graph, status=None, perform_checks=True, **kwargs):
return has_updates
+def _check_module_names(cr, module_names):
+ mod_names = set(module_names)
+ if 'base' in mod_names:
+ # ignore dummy 'all' module
+ if 'all' in mod_names:
+ mod_names.remove('all')
+ if mod_names:
+ cr.execute("SELECT count(id) AS count FROM ir_module_module WHERE name in %s", (tuple(mod_names),))
+ if cr.dictfetchone()['count'] != len(mod_names):
+ # find out what module name(s) are incorrect:
+ cr.execute("SELECT name FROM ir_module_module")
+ incorrect_names = mod_names.difference([x['name'] for x in cr.dictfetchall()])
+ logging.getLogger('init').warning('invalid module names, ignored: %s', ", ".join(incorrect_names))
+
def load_modules(db, force_demo=False, status=None, update_module=False):
-
- def check_module_name(cr, mods, state):
- for mod in mods:
- id = modobj.search(cr, 1, ['&', ('state', '=', state), ('name', '=', mod)])
- if id:
- getattr(modobj, states[state])(cr, 1, id)
- elif mod != 'all':
- logger.notifyChannel('init', netsvc.LOG_WARNING, 'module %s: invalid module name!' % (mod))
-
if not status:
status = {}
cr = db.cursor()
@@ -795,16 +800,23 @@ def load_modules(db, force_demo=False, status=None, update_module=False):
if update_module:
modobj = pool.get('ir.module.module')
- states = {'installed': 'button_upgrade', 'uninstalled': 'button_install'}
logger.notifyChannel('init', netsvc.LOG_INFO, 'updating modules list')
if ('base' in tools.config['init']) or ('base' in tools.config['update']):
modobj.update_list(cr, 1)
+ _check_module_names(cr, itertools.chain(tools.config['init'].keys(), tools.config['update'].keys()))
+
mods = [k for k in tools.config['init'] if tools.config['init'][k]]
- check_module_name(cr, mods, 'uninstalled')
+ if mods:
+ ids = modobj.search(cr, 1, ['&', ('state', '=', 'uninstalled'), ('name', 'in', mods)])
+ if ids:
+ modobj.button_install(cr, 1, ids)
mods = [k for k in tools.config['update'] if tools.config['update'][k]]
- check_module_name(cr, mods, 'installed')
+ if mods:
+ ids = modobj.search(cr, 1, ['&', ('state', '=', 'installed'), ('name', 'in', mods)])
+ if ids:
+ modobj.button_upgrade(cr, 1, ids)
cr.execute("update ir_module_module set state=%s where name=%s", ('installed', 'base'))
diff --git a/bin/addons/base/ir/ir.xml b/bin/addons/base/ir/ir.xml
index 5890317dd87..e76af79ea01 100644
--- a/bin/addons/base/ir/ir.xml
+++ b/bin/addons/base/ir/ir.xml
@@ -1906,8 +1906,10 @@
+
-
+
+
@@ -1927,10 +1929,8 @@
-
-
+
+
diff --git a/bin/addons/base/ir/ir_actions.py b/bin/addons/base/ir/ir_actions.py
index afcb7668783..a20d780c671 100644
--- a/bin/addons/base/ir/ir_actions.py
+++ b/bin/addons/base/ir/ir_actions.py
@@ -753,7 +753,7 @@ act_window_close()
TODO_STATES = [('open', 'To Do'),
('done', 'Done'),
('skip','Skipped'),
- ('cancel','Cancel')]
+ ('cancel','Cancelled')]
class ir_actions_todo(osv.osv):
_name = 'ir.actions.todo'
@@ -769,9 +769,9 @@ class ir_actions_todo(osv.osv):
'note':fields.text('Text', translate=True),
}
_defaults={
- 'state': lambda *a: 'open',
- 'sequence': lambda *a: 10,
- 'restart': lambda *a: 'always',
+ 'state': 'open',
+ 'sequence': 10,
+ 'restart': 'onskip',
}
_order="sequence,id"
diff --git a/bin/addons/base/ir/ir_attachment.py b/bin/addons/base/ir/ir_attachment.py
index abba9222d25..173632c4411 100644
--- a/bin/addons/base/ir/ir_attachment.py
+++ b/bin/addons/base/ir/ir_attachment.py
@@ -105,8 +105,8 @@ class ir_attachment(osv.osv):
'datas': fields.binary('Data'),
'datas_fname': fields.char('Filename',size=256),
'description': fields.text('Description'),
- 'res_name': fields.function(_name_get_resname, type='char',
- string='Resource Name', method=True),
+ 'res_name': fields.function(_name_get_resname, type='char', size=128,
+ string='Resource Name', method=True, store=True),
'res_model': fields.char('Resource Object',size=64, readonly=True,
help="The database object this attachment will be attached to"),
'res_id': fields.integer('Resource ID', readonly=True,
diff --git a/bin/addons/base/ir/ir_report_custom.py b/bin/addons/base/ir/ir_report_custom.py
index 036469fb417..3c7daa09d34 100644
--- a/bin/addons/base/ir/ir_report_custom.py
+++ b/bin/addons/base/ir/ir_report_custom.py
@@ -88,7 +88,7 @@ class report_custom(osv.osv):
c_f[i] = []
tmp = report.fields_child0[i]
for j in range(3):
- c_f[i].append((not isinstance(eval('tmp.field_child'+str(j)), browse_null) and eval('tmp.field_child'+str(j)+'.ttype')) or None)
+ c_f[i].append((not isinstance(eval('tmp.field_child'+str(j),{},{'tmp':tmp}), browse_null) and eval('tmp.field_child'+str(j)+'.ttype',{},{'tmp':tmp})) or None)
if not reduce(lambda x,y : x or y, map(lambda x: x in ['integer', 'float'], c_f[1])):
raise osv.except_osv(_('Invalid operation'), _('Second field should be figures'))
@@ -101,7 +101,7 @@ class report_custom(osv.osv):
c_f[i] = []
tmp = report.fields_child0[i]
for j in range(3):
- c_f[i].append((not isinstance(eval('tmp.field_child'+str(j)), browse_null) and eval('tmp.field_child'+str(j)+'.ttype')) or None)
+ c_f[i].append((not isinstance(eval('tmp.field_child'+str(j),{},{'tmp':tmp}), browse_null) and eval('tmp.field_child'+str(j)+'.ttype',{},{'tmp':tmp})) or None)
if i == 0:
pass
diff --git a/bin/addons/base/module/module_view.xml b/bin/addons/base/module/module_view.xml
index 6f2155b7c7f..bf5164d8276 100644
--- a/bin/addons/base/module/module_view.xml
+++ b/bin/addons/base/module/module_view.xml
@@ -162,9 +162,9 @@
-
+
-
+
diff --git a/bin/addons/base/module/wizard/base_language_install_view.xml b/bin/addons/base/module/wizard/base_language_install_view.xml
index b97ba101284..794ea6bed7d 100644
--- a/bin/addons/base/module/wizard/base_language_install_view.xml
+++ b/bin/addons/base/module/wizard/base_language_install_view.xml
@@ -3,27 +3,24 @@
- Install Language
+ Load Official Translation
base.language.install
form
-
+
- Install Language
+ Load Official Translation
ir.actions.act_window
base.language.install
form
@@ -38,23 +35,21 @@
parent="menu_translation"/>
- Install Language Message
+ Official Translation Loaded
base.module.import.msg
form
-
- Install Language Message
+ Official Translation Loaded
base.module.import.msg
form
form
diff --git a/bin/addons/base/res/ir_property.py b/bin/addons/base/res/ir_property.py
index 17514898eeb..27536ff80f5 100644
--- a/bin/addons/base/res/ir_property.py
+++ b/bin/addons/base/res/ir_property.py
@@ -20,8 +20,9 @@
##############################################################################
from osv import osv,fields
-from operator import attrgetter
+from tools.misc import attrgetter
import time
+
# -------------------------------------------------------------------------
# Properties
# -------------------------------------------------------------------------
@@ -31,7 +32,6 @@ class ir_property(osv.osv):
def _models_field_get(self, cr, uid, field_key, field_value, context=None):
get = attrgetter(field_key, field_value)
-
obj = self.pool.get('ir.model.fields')
ids = obj.search(cr, uid, [('view_load','=',1)], context=context)
res = set()
@@ -40,7 +40,7 @@ class ir_property(osv.osv):
return res
def _models_get(self, cr, uid, context=None):
- return self._models_field_get(cr, uid, 'model', 'name', context)
+ return self._models_field_get(cr, uid, 'model', 'model_id.name', context)
def _models_get2(self, cr, uid, context=None):
return self._models_field_get(cr, uid, 'relation', 'relation', context)
@@ -50,7 +50,7 @@ class ir_property(osv.osv):
'name': fields.char('Name', size=128, select=1),
'res_id': fields.reference('Resource', selection=_models_get, size=128,
- help="If not set, act as default property", select=1),
+ help="If not set, acts as a default value for new resources", select=1),
'company_id': fields.many2one('res.company', 'Company', select=1),
'fields_id': fields.many2one('ir.model.fields', 'Field', ondelete='cascade', required=True, select=1),
diff --git a/bin/addons/base/test/bug_lp541545.xml b/bin/addons/base/test/bug_lp541545.xml
index 4c1df7a0383..79c3c05c729 100644
--- a/bin/addons/base/test/bug_lp541545.xml
+++ b/bin/addons/base/test/bug_lp541545.xml
@@ -5,7 +5,7 @@
diff --git a/bin/addons/base/test/test_context.xml b/bin/addons/base/test/test_context.xml
index d6c6fa0afed..44751644403 100644
--- a/bin/addons/base/test/test_context.xml
+++ b/bin/addons/base/test/test_context.xml
@@ -6,15 +6,15 @@
--->
+
-
+
- 58.8287
+ 59.9739
diff --git a/bin/netsvc.py b/bin/netsvc.py
index d29aebd92b5..e5a60f9d3ae 100644
--- a/bin/netsvc.py
+++ b/bin/netsvc.py
@@ -348,11 +348,19 @@ class Agent(object):
current_thread.dbname = dbname # hack hack
cls._logger.notifyChannel('timers', LOG_DEBUG, "Run %s.%s(*%r, **%r)" % (function.im_class.__name__, function.func_name, args, kwargs))
delattr(current_thread, 'dbname')
- threading.Thread(target=function, args=args, kwargs=kwargs).start()
+ task_thread = threading.Thread(target=function, name='netsvc.Agent.task', args=args, kwargs=kwargs)
+ # force non-daemon task threads (the runner thread must be daemon, and this property is inherited by default)
+ task_thread.setDaemon(False)
+ task_thread.start()
time.sleep(1)
time.sleep(60)
-threading.Thread(target=Agent.runner).start()
+agent_runner = threading.Thread(target=Agent.runner, name="netsvc.Agent.runner")
+# the agent runner is a typical daemon thread, that will never quit and must be
+# terminated when the main process exits - with no consequence (the processing
+# threads it spawns are not marked daemon)
+agent_runner.setDaemon(True)
+agent_runner.start()
import traceback
diff --git a/bin/openerp-server.py b/bin/openerp-server.py
index f03c64d3400..9486d6e49d5 100755
--- a/bin/openerp-server.py
+++ b/bin/openerp-server.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
##############################################################################
-#
+#
# OpenERP, Open Source Management Solution
# Copyright (C) 2004-2009 Tiny SPRL ().
#
@@ -16,7 +16,7 @@
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
+# along with this program. If not, see .
#
##############################################################################
@@ -27,16 +27,19 @@ OpenERP is an ERP+CRM program for small and medium businesses.
The whole source code is distributed under the terms of the
GNU Public Licence.
-(c) 2003-TODAY, Fabien Pinckaers - Tiny sprl
+(c) 2003-TODAY, Fabien Pinckaers - OpenERP s.a.
"""
#----------------------------------------------------------
# python imports
#----------------------------------------------------------
-import sys
+import logging
import os
-import signal
import pwd
+import signal
+import sys
+import threading
+import traceback
import release
__author__ = release.author
@@ -52,23 +55,22 @@ if pwd.getpwuid(os.getuid())[0] == 'root' :
# get logger
#----------------------------------------------------------
import netsvc
-logger = netsvc.Logger()
+logger = logging.getLogger('server')
#-----------------------------------------------------------------------
# import the tools module so that the commandline parameters are parsed
#-----------------------------------------------------------------------
import tools
-
-logger.notifyChannel("server", netsvc.LOG_INFO, "version - %s" % release.version )
+logger.info("OpenERP version - %s", release.version)
for name, value in [('addons_path', tools.config['addons_path']),
('database hostname', tools.config['db_host'] or 'localhost'),
('database port', tools.config['db_port'] or '5432'),
('database user', tools.config['db_user'])]:
- logger.notifyChannel("server", netsvc.LOG_INFO, "%s - %s" % ( name, value ))
+ logger.info("%s - %s", name, value)
# Don't allow if the connection to PostgreSQL done by postgres user
if tools.config['db_user'] == 'postgres':
- logger.notifyChannel("server", netsvc.LOG_ERROR, "%s" % ("Attempted to connect database with postgres user. This is a security flaws, aborting."))
+ logger.error("Connecting to the database as 'postgres' user is forbidden, as it present major security issues. Shutting down.")
sys.exit(1)
import time
@@ -76,7 +78,7 @@ import time
#----------------------------------------------------------
# init net service
#----------------------------------------------------------
-logger.notifyChannel("objects", netsvc.LOG_INFO, 'initialising distributed objects services')
+logger.info('initialising distributed objects services')
#---------------------------------------------------------------
# connect to the database and initialize it with base if needed
@@ -117,8 +119,7 @@ if tools.config['db_name']:
for dbname in tools.config['db_name'].split(','):
db,pool = pooler.get_db_and_pool(dbname, update_module=tools.config['init'] or tools.config['update'], pooljobs=False)
if tools.config["test_file"]:
- logger.notifyChannel("init", netsvc.LOG_INFO,
- 'loading test file %s' % (tools.config["test_file"],))
+ logger.info('loading test file %s', tools.config["test_file"])
cr = db.cursor()
tools.convert_yaml_import(cr, 'base', file(tools.config["test_file"]), {}, 'test', True)
cr.rollback()
@@ -134,16 +135,14 @@ if tools.config["translate_out"]:
msg = "language %s" % (tools.config["language"],)
else:
msg = "new language"
- logger.notifyChannel("init", netsvc.LOG_INFO,
- 'writing translation file for %s to %s' % (msg,
- tools.config["translate_out"]))
+ logger.info('writing translation file for %s to %s', msg, tools.config["translate_out"])
fileformat = os.path.splitext(tools.config["translate_out"])[-1][1:].lower()
buf = file(tools.config["translate_out"], "w")
tools.trans_export(tools.config["language"], tools.config["translate_modules"], buf, fileformat)
buf.close()
- logger.notifyChannel("init", netsvc.LOG_INFO, 'translation file written successfully')
+ logger.info('translation file written successfully')
sys.exit(0)
if tools.config["translate_in"]:
@@ -169,30 +168,23 @@ SIGNALS = dict(
[(getattr(signal, sign), sign) for sign in LST_SIGNALS]
)
-def handler(signum, _):
+netsvc.quit_signals_received = 0
+
+def handler(signum, frame):
"""
:param signum: the signal number
- :param _:
+ :param frame: the interrupted stack frame or None
"""
- netsvc.Agent.quit()
- netsvc.Server.quitAll()
- if tools.config['pidfile']:
- os.unlink(tools.config['pidfile'])
- logger.notifyChannel('shutdown', netsvc.LOG_INFO,
- "Shutdown Server! - %s" % ( SIGNALS[signum], ))
- logger.shutdown()
- sys.exit(0)
+ netsvc.quit_signals_received += 1
+ if netsvc.quit_signals_received > 1:
+ sys.stderr.write("Forced shutdown.\n")
+ os._exit(0)
-for signum in SIGNALS:
- signal.signal(signum, handler)
-
-
-import threading
-import traceback
-def dumpstacks(signum, _):
+def dumpstacks(signum, frame):
# code from http://stackoverflow.com/questions/132058/getting-stack-trace-from-a-running-python-application#answer-2569696
-
- id2name = dict([(th.ident, th.name) for th in threading.enumerate()])
+ # modified for python 2.5 compatibility
+ thread_map = dict(threading._active, **threading._limbo)
+ id2name = dict([(threadId, thread.getName()) for threadId, thread in thread_map.items()])
code = []
for threadId, stack in sys._current_frames().items():
code.append("\n# Thread: %s(%d)" % (id2name[threadId], threadId))
@@ -200,25 +192,49 @@ def dumpstacks(signum, _):
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
if line:
code.append(" %s" % (line.strip()))
+ logging.getLogger('dumpstacks').info("\n".join(code))
- logger.notifyChannel("dumpstacks", netsvc.LOG_INFO, "\n".join(code))
+for signum in SIGNALS:
+ signal.signal(signum, handler)
if os.name == 'posix':
signal.signal(signal.SIGQUIT, dumpstacks)
+def quit():
+ netsvc.Agent.quit()
+ netsvc.Server.quitAll()
+ if tools.config['pidfile']:
+ os.unlink(tools.config['pidfile'])
+ logger = logging.getLogger('shutdown')
+ logger.info("Initiating OpenERP Server shutdown")
+ logger.info("Hit CTRL-C again or send a second signal to immediately terminate the server...")
+ logging.shutdown()
+
+ # manually join() all threads before calling sys.exit() to allow a second signal
+ # to trigger _force_quit() in case some non-daemon threads won't exit cleanly.
+ # threading.Thread.join() should not mask signals (at least in python 2.5)
+ for thread in threading.enumerate():
+ if thread != threading.currentThread() and not thread.isDaemon():
+ while thread.isAlive():
+ # need a busyloop here as thread.join() masks signals
+ # and would present the forced shutdown
+ thread.join(0.05)
+ time.sleep(0.05)
+ sys.exit(0)
+
if tools.config['pidfile']:
fd = open(tools.config['pidfile'], 'w')
pidtext = "%d" % (os.getpid())
fd.write(pidtext)
fd.close()
-
netsvc.Server.startAll()
-logger.notifyChannel("web-services", netsvc.LOG_INFO,
- 'the server is running, waiting for connections...')
+logger.info('OpenERP server is running, waiting for connections...')
-while True:
+while netsvc.quit_signals_received == 0:
time.sleep(60)
+quit()
+
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/bin/osv/fields.py b/bin/osv/fields.py
index 3ab140e4088..64432ba15d7 100644
--- a/bin/osv/fields.py
+++ b/bin/osv/fields.py
@@ -777,7 +777,7 @@ class related(function):
if not ids: return {}
relation = obj._name
if self._type in ('one2many', 'many2many'):
- res = {}.fromkeys(ids, [])
+ res = dict([(i, []) for i in ids])
else:
res = {}.fromkeys(ids, False)
diff --git a/bin/osv/orm.py b/bin/osv/orm.py
index 25a987dac61..64b118f3e15 100644
--- a/bin/osv/orm.py
+++ b/bin/osv/orm.py
@@ -2275,6 +2275,9 @@ class orm(orm_template):
del r['id']
if groupby and fget[groupby]['type'] == 'many2one':
data_ids = self.search(cr, uid, [('id', 'in', alldata.keys())], order=groupby, context=context)
+ # the IDS of the records that has groupby field value = False or ''
+ # should be added too
+ data_ids += filter(lambda x:x not in data_ids, alldata.keys())
data = self.read(cr, uid, data_ids, groupby and [groupby] or ['id'], context=context)
# restore order of the search as read() uses the default _order (this is only for groups, so the size of data_read shoud be small):
data.sort(lambda x,y: cmp(data_ids.index(x['id']), data_ids.index(y['id'])))
diff --git a/bin/report/custom.py b/bin/report/custom.py
index b53be34cfdb..4b02901bdb2 100644
--- a/bin/report/custom.py
+++ b/bin/report/custom.py
@@ -73,7 +73,7 @@ class report_custom(report_int):
for cond in conditions:
if cond and cond[0]:
c = cond[0]
- temp = c[0](eval('obj.'+c[1]))
+ temp = c[0](eval('obj.'+c[1],{'obj': obj}))
if not eval('\''+temp+'\''+' '+c[2]+' '+'\''+str(c[3])+'\''):
tobreak = True
if tobreak:
@@ -87,7 +87,7 @@ class report_custom(report_int):
row_canvas[i]=False
elif len(fields[i])==1:
if not isinstance(obj, browse_null):
- row.append(str(eval('obj.'+fields[i][0])))
+ row.append(str(eval('obj.'+fields[i][0],{'obj': obj})))
else:
row.append(None)
else:
@@ -107,7 +107,7 @@ class report_custom(report_int):
else:
key = levels.keys()
for l in key:
- objs = eval('obj.'+l)
+ objs = eval('obj.'+l,{'obj': obj})
if not isinstance(objs, browse_record_list) and type(objs) <> type([]):
objs = [objs]
field_new = []
@@ -192,7 +192,7 @@ class report_custom(report_int):
def build_tree(obj, level, depth):
res = self._row_get(cr, uid,[obj], new_fields, new_cond)
level.append(depth)
- new_obj = eval('obj.'+report['field_parent'][1])
+ new_obj = eval('obj.'+report['field_parent'][1],{'obj': obj})
if not isinstance(new_obj, list) :
new_obj = [new_obj]
for o in new_obj:
diff --git a/bin/service/http_server.py b/bin/service/http_server.py
index 7b62dda6c05..86e5943f170 100644
--- a/bin/service/http_server.py
+++ b/bin/service/http_server.py
@@ -137,7 +137,7 @@ class BaseHttpDaemon(threading.Thread, netsvc.Server):
_RealProto = '??'
def __init__(self, interface, port, handler):
- threading.Thread.__init__(self)
+ threading.Thread.__init__(self, name='%sDaemon-%d'%(self._RealProto, port))
netsvc.Server.__init__(self)
self.__port = port
self.__interface = interface
diff --git a/bin/service/netrpc_server.py b/bin/service/netrpc_server.py
index 80cb968c8e9..9d25c98083a 100644
--- a/bin/service/netrpc_server.py
+++ b/bin/service/netrpc_server.py
@@ -102,7 +102,7 @@ class TinySocketClientThread(threading.Thread, netsvc.OpenERPDispatcher):
class TinySocketServerThread(threading.Thread,netsvc.Server):
def __init__(self, interface, port, secure=False):
- threading.Thread.__init__(self, name="Net-RPC socket")
+ threading.Thread.__init__(self, name="NetRPCDaemon-%d"%port)
netsvc.Server.__init__(self)
self.__port = port
self.__interface = interface
diff --git a/bin/tools/misc.py b/bin/tools/misc.py
index 1256adb46f6..5a3d21bef86 100644
--- a/bin/tools/misc.py
+++ b/bin/tools/misc.py
@@ -419,6 +419,13 @@ def _email_send(smtp_from, smtp_to_list, message, openobject_id=None, ssl=False,
:return: True if the mail was delivered successfully to the smtp,
else False (+ exception logged)
"""
+ class WriteToLogger(object):
+ def __init__(self):
+ self.logger = netsvc.Logger()
+
+ def write(self, s):
+ self.logger.notifyChannel('email_send', netsvc.LOG_DEBUG, s)
+
if openobject_id:
message['Message-Id'] = generate_tracking_message_id(openobject_id)
@@ -534,13 +541,6 @@ def email_send(email_from, email_to, subject, body, email_cc=None, email_bcc=Non
part.add_header('Content-Disposition', 'attachment; filename="%s"' % (fname,))
msg.attach(part)
- class WriteToLogger(object):
- def __init__(self):
- self.logger = netsvc.Logger()
-
- def write(self, s):
- self.logger.notifyChannel('email_send', netsvc.LOG_DEBUG, s)
-
return _email_send(email_from, flatten([email_to, email_cc, email_bcc]), msg, openobject_id=openobject_id, ssl=ssl, debug=debug)
#----------------------------------------------------------
@@ -1406,5 +1406,24 @@ def upload_data(email, data, type='SURVEY'):
a = upload_data_thread(email, data, type)
a.start()
return True
+
+
+# port of python 2.6's attrgetter with support for dotted notation
+def resolve_attr(obj, attr):
+ for name in attr.split("."):
+ obj = getattr(obj, name)
+ return obj
+
+def attrgetter(*items):
+ if len(items) == 1:
+ attr = items[0]
+ def g(obj):
+ return resolve_attr(obj, attr)
+ else:
+ def g(obj):
+ return tuple(resolve_attr(obj, attr) for attr in items)
+ return g
+
+
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: