1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 ##############################################################################
23 from os.path import join as opj
34 from osv import fields
42 from zipfile import PyZipFile, ZIP_DEFLATED
43 from cStringIO import StringIO
48 logger = netsvc.Logger()
50 _ad = os.path.abspath(opj(tools.config['root_path'], 'addons')) # default addons path (base)
51 ad_paths= map(lambda m: os.path.abspath(m.strip()),tools.config['addons_path'].split(','))
53 sys.path.insert(1, _ad)
58 sys.path.insert(ad_cnt, adp)
61 ad_paths.append(_ad) # for get_module_path
63 # Modules already loaded
66 #Modules whch raised error
71 def addNode(self, name, deps):
72 max_depth, father = 0, None
73 for n in [Node(x, self) for x in deps]:
74 if n.depth >= max_depth:
82 def update_from_db(self, cr):
83 # update the graph with values from the database (if exist)
84 ## First, we set the default values for each package in graph
85 additional_data = dict.fromkeys(self.keys(), {'id': 0, 'state': 'uninstalled', 'dbdemo': False, 'installed_version': None})
86 ## Then we get the values from the database
87 cr.execute('SELECT name, id, state, demo AS dbdemo, latest_version AS installed_version'
88 ' FROM ir_module_module'
89 ' WHERE name in (%s)' % (','.join(['%s'] * len(self))),
90 additional_data.keys()
93 ## and we update the default values with values from the database
94 additional_data.update(dict([(x.pop('name'), x) for x in cr.dictfetchall()]))
96 for package in self.values():
97 for k, v in additional_data[package.name].items():
98 setattr(package, k, v)
104 done = set(self.keys())
106 level_modules = [(name, module) for name, module in self.items() if module.depth==level]
107 for name, module in level_modules:
112 class Singleton(object):
113 def __new__(cls, name, graph):
117 inst = object.__new__(cls)
123 class Node(Singleton):
125 def __init__(self, name, graph):
127 if not hasattr(self, 'children'):
129 if not hasattr(self, 'depth'):
132 def addChild(self, name):
133 node = Node(name, self.graph)
134 node.depth = self.depth + 1
135 if node not in self.children:
136 self.children.append(node)
137 for attr in ('init', 'update', 'demo'):
138 if hasattr(self, attr):
139 setattr(node, attr, True)
140 self.children.sort(lambda x, y: cmp(x.name, y.name))
142 def __setattr__(self, name, value):
143 super(Singleton, self).__setattr__(name, value)
144 if name in ('init', 'update', 'demo'):
145 tools.config[name][self.name] = 1
146 for child in self.children:
147 setattr(child, name, value)
149 for child in self.children:
150 setattr(child, name, value + 1)
153 return itertools.chain(iter(self.children), *map(iter, self.children))
156 return self._pprint()
158 def _pprint(self, depth=0):
159 s = '%s\n' % self.name
160 for c in self.children:
161 s += '%s`-> %s' % (' ' * depth, c._pprint(depth+1))
165 def get_module_path(module, downloaded=False):
166 """Return the path of the given module."""
168 if os.path.exists(opj(adp, module)) or os.path.exists(opj(adp, '%s.zip' % module)):
169 return opj(adp, module)
172 return opj(_ad, module)
173 logger.notifyChannel('init', netsvc.LOG_WARNING, 'module %s: module not found' % (module,))
177 def get_module_filetree(module, dir='.'):
178 path = get_module_path(module)
182 dir = os.path.normpath(dir)
185 if dir.startswith('..') or (dir and dir[0] == '/'):
186 raise Exception('Cannot access file outside the module')
188 if not os.path.isdir(path):
190 zip = zipfile.ZipFile(path + ".zip")
191 files = ['/'.join(f.split('/')[1:]) for f in zip.namelist()]
193 files = tools.osutil.listdir(path, True)
197 if not f.startswith(dir):
201 f = f[len(dir)+int(not dir.endswith('/')):]
202 lst = f.split(os.sep)
205 current = current.setdefault(lst.pop(0), {})
206 current[lst.pop(0)] = None
210 def get_module_as_zip_from_module_directory(module_directory, b64enc=True, src=True):
211 """Compress a module directory
213 @param module_directory: The module directory
214 @param base64enc: if True the function will encode the zip file with base64
215 @param src: Integrate the source files
217 @return: a stream to store in a file-like object
220 RE_exclude = re.compile('(?:^\..+\.swp$)|(?:\.py[oc]$)|(?:\.bak$)|(?:\.~.~$)', re.I)
222 def _zippy(archive, path, src=True):
223 path = os.path.abspath(path)
224 base = os.path.basename(path)
225 for f in tools.osutil.listdir(path, True):
226 bf = os.path.basename(f)
227 if not RE_exclude.search(bf) and (src or bf in ('__openerp__.py', '__terp__.py') or not bf.endswith('.py')):
228 archive.write(os.path.join(path, f), os.path.join(base, f))
230 archname = StringIO()
231 archive = PyZipFile(archname, "w", ZIP_DEFLATED)
232 archive.writepy(module_directory)
233 _zippy(archive, module_directory, src=src)
235 val = archname.getvalue()
239 val = base64.encodestring(val)
243 def get_module_as_zip(modulename, b64enc=True, src=True):
244 """Generate a module as zip file with the source or not and can do a base64 encoding
246 @param modulename: The module name
247 @param b64enc: if True the function will encode the zip file with base64
248 @param src: Integrate the source files
250 @return: a stream to store in a file-like object
253 ap = get_module_path(str(modulename))
255 raise Exception('Unable to find path for module %s' % modulename)
257 ap = ap.encode('utf8')
258 if os.path.isfile(ap + '.zip'):
259 val = file(ap + '.zip', 'rb').read()
261 val = base64.encodestring(val)
263 val = get_module_as_zip_from_module_directory(ap, b64enc, src)
268 def get_module_resource(module, *args):
269 """Return the full path of a resource of the given module.
271 @param module: the module
272 @param args: the resource path components
274 @return: absolute path to the resource
276 a = get_module_path(module)
277 return a and opj(a, *args) or False
281 """Returns the list of module names
285 name = os.path.basename(name)
286 if name[-4:] == '.zip':
290 def is_really_module(name):
291 name = opj(dir, name)
292 return os.path.isdir(name) or zipfile.is_zipfile(name)
293 return map(clean, filter(is_really_module, os.listdir(dir)))
297 plist.extend(listdir(ad))
298 return list(set(plist))
300 def load_information_from_description_file(module):
302 :param module: The name of the module (sale, purchase, ...)
304 for filename in ['__openerp__.py', '__terp__.py']:
305 description_file = addons.get_module_resource(module, filename)
306 if os.path.isfile(description_file):
307 return eval(tools.file_open(description_file).read())
309 #TODO: refactor the logger in this file to follow the logging guidelines
311 logging.getLogger('addons').debug('The module %s does not contain a description file:'\
312 '__openerp__.py or __terp__.py (deprecated)', module)
315 def get_modules_with_version():
316 modules = get_modules()
318 for module in modules:
320 info = load_information_from_description_file(module)
321 res[module] = "%s.%s" % (release.major_version, info['version'])
326 def create_graph(cr, module_list, force=None):
328 upgrade_graph(graph, cr, module_list, force)
331 def upgrade_graph(graph, cr, module_list, force=None):
335 len_graph = len(graph)
336 for module in module_list:
337 mod_path = get_module_path(module)
338 terp_file = get_module_resource(module, '__openerp__.py')
339 if not terp_file or not os.path.isfile(terp_file):
340 terp_file = get_module_resource(module, '__terp__.py')
342 if not mod_path or not terp_file:
344 not_loaded.append(module)
345 logger.notifyChannel('init', netsvc.LOG_WARNING, 'module %s: not installable' % (module))
346 raise osv.osv.except_osv('Error!',"Module '%s' was not found" % (module,))
349 if os.path.isfile(terp_file) or zipfile.is_zipfile(mod_path+'.zip'):
351 info = eval(tools.file_open(terp_file).read())
353 logger.notifyChannel('init', netsvc.LOG_ERROR, 'module %s: eval file %s' % (module, terp_file))
355 if info.get('installable', True):
356 packages.append((module, info.get('depends', []), info))
359 dependencies = dict([(p, deps) for p, deps, data in packages])
360 current, later = set([p for p, dep, data in packages]), set()
362 while packages and current > later:
363 package, deps, data = packages[0]
365 # if all dependencies of 'package' are already in the graph, add 'package' in the graph
366 if reduce(lambda x, y: x and y in graph, deps, True):
367 if not package in current:
371 current.remove(package)
372 graph.addNode(package, deps)
373 node = Node(package, graph)
375 for kind in ('init', 'demo', 'update'):
376 if package in tools.config[kind] or 'all' in tools.config[kind] or kind in force:
377 setattr(node, kind, True)
380 packages.append((package, deps, data))
383 graph.update_from_db(cr)
385 for package in later:
386 unmet_deps = filter(lambda p: p not in graph, dependencies[package])
387 logger.notifyChannel('init', netsvc.LOG_ERROR, 'module %s: Unmet dependencies: %s' % (package, ', '.join(unmet_deps)))
389 result = len(graph) - len_graph
390 if result != len(module_list):
391 logger.notifyChannel('init', netsvc.LOG_WARNING, 'Not all modules have loaded.')
395 def init_module_objects(cr, module_name, obj_list):
396 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: creating or updating database tables' % module_name)
400 result = obj._auto_init(cr, {'module': module_name})
405 if hasattr(obj, 'init'):
414 def register_class(m):
416 Register module named m, if not already registered
420 mt = isinstance(e, zipimport.ZipImportError) and 'zip ' or ''
421 msg = "Couldn't load %smodule %s" % (mt, m)
422 logger.notifyChannel('init', netsvc.LOG_CRITICAL, msg)
423 logger.notifyChannel('init', netsvc.LOG_CRITICAL, e)
428 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: registering objects' % m)
429 mod_path = get_module_path(m)
432 zip_mod_path = mod_path + '.zip'
433 if not os.path.isfile(zip_mod_path):
434 fm = imp.find_module(m, ad_paths)
436 imp.load_module(m, *fm)
441 zimp = zipimport.zipimporter(zip_mod_path)
450 class MigrationManager(object):
452 This class manage the migration of modules
453 Migrations files must be python files containing a "migrate(cr, installed_version)" function.
454 Theses files must respect a directory tree structure: A 'migrations' folder which containt a
455 folder by version. Version can be 'module' version or 'server.module' version (in this case,
456 the files will only be processed by this version of the server). Python file names must start
457 by 'pre' or 'post' and will be executed, respectively, before and after the module initialisation
463 | |-- pre-update_table_x.py
464 | |-- pre-update_table_y.py
465 | |-- post-clean-data.py
466 | `-- README.txt # not processed
467 |-- 5.0.1.1 # files in this folder will be executed only on a 5.0 server
468 | |-- pre-delete_table_z.py
469 | `-- post-clean-data.py
470 `-- foo.py # not processed
472 This similar structure is generated by the maintenance module with the migrations files get by
473 the maintenance contract
476 def __init__(self, cr, graph):
482 def _get_files(self):
485 import addons.base.maintenance.utils as maintenance_utils
486 maintenance_utils.update_migrations_files(self.cr)
489 for pkg in self.graph:
490 self.migrations[pkg.name] = {}
491 if not (hasattr(pkg, 'update') or pkg.state == 'to upgrade'):
494 self.migrations[pkg.name]['module'] = get_module_filetree(pkg.name, 'migrations') or {}
495 self.migrations[pkg.name]['maintenance'] = get_module_filetree('base', 'maintenance/migrations/' + pkg.name) or {}
497 def migrate_module(self, pkg, stage):
498 assert stage in ('pre', 'post')
499 stageformat = {'pre': '[>%s]',
503 if not (hasattr(pkg, 'update') or pkg.state == 'to upgrade'):
506 def convert_version(version):
507 if version.startswith(release.major_version) and version != release.major_version:
508 return version # the version number already containt the server version
509 return "%s.%s" % (release.major_version, version)
511 def _get_migration_versions(pkg):
513 return [d for d in tree if tree[d] is not None]
516 __get_dir(self.migrations[pkg.name]['module']) +
517 __get_dir(self.migrations[pkg.name]['maintenance'])
519 versions.sort(key=lambda k: parse_version(convert_version(k)))
522 def _get_migration_files(pkg, version, stage):
523 """ return a list of tuple (module, file)
525 m = self.migrations[pkg.name]
528 mapping = {'module': opj(pkg.name, 'migrations'),
529 'maintenance': opj('base', 'maintenance', 'migrations', pkg.name),
532 for x in mapping.keys():
534 for f in m[x][version]:
535 if m[x][version][f] is not None:
537 if not f.startswith(stage + '-'):
539 lst.append(opj(mapping[x], version, f))
548 from tools.parse_version import parse_version
550 parsed_installed_version = parse_version(pkg.installed_version or '')
551 current_version = parse_version(convert_version(pkg.data.get('version', '0')))
553 versions = _get_migration_versions(pkg)
555 for version in versions:
556 if parsed_installed_version < parse_version(convert_version(version)) <= current_version:
558 strfmt = {'addon': pkg.name,
560 'version': stageformat[stage] % version,
563 for pyfile in _get_migration_files(pkg, version, stage):
564 name, ext = os.path.splitext(os.path.basename(pyfile))
565 if ext.lower() != '.py':
567 mod = fp = fp2 = None
569 fp = tools.file_open(pyfile)
571 # imp.load_source need a real file object, so we create
572 # one from the file-like object we get from file_open
577 mod = imp.load_source(name, pyfile, fp2)
578 logger.notifyChannel('migration', netsvc.LOG_INFO, 'module %(addon)s: Running migration %(version)s %(name)s' % mergedict({'name': mod.__name__}, strfmt))
579 mod.migrate(self.cr, pkg.installed_version)
581 logger.notifyChannel('migration', netsvc.LOG_ERROR, 'module %(addon)s: Unable to load %(stage)s-migration file %(file)s' % mergedict({'file': pyfile}, strfmt))
583 except AttributeError:
584 logger.notifyChannel('migration', netsvc.LOG_ERROR, 'module %(addon)s: Each %(stage)s-migration file must have a "migrate(cr, installed_version)" function' % strfmt)
595 log = logging.getLogger('init')
597 def load_module_graph(cr, graph, status=None, perform_checks=True, **kwargs):
599 def process_sql_file(cr, file):
600 queries = fp.read().split(';')
601 for query in queries:
602 new_query = ' '.join(query.split())
604 cr.execute(new_query)
606 def load_init_update_xml(cr, m, idref, mode, kind):
607 for filename in package.data.get('%s_xml' % kind, []):
608 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: loading %s' % (m, filename))
609 _, ext = os.path.splitext(filename)
610 fp = tools.file_open(opj(m, filename))
612 noupdate = (kind == 'init')
613 tools.convert_csv_import(cr, m, os.path.basename(filename), fp.read(), idref, mode=mode, noupdate=noupdate)
615 process_sql_file(cr, fp)
617 tools.convert_yaml_import(cr, m, fp, idref, mode=mode, **kwargs)
619 tools.convert_xml_import(cr, m, fp, idref, mode=mode, **kwargs)
622 def load_demo_xml(cr, m, idref, mode):
623 for xml in package.data.get('demo_xml', []):
624 name, ext = os.path.splitext(xml)
625 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: loading %s' % (m, xml))
626 fp = tools.file_open(opj(m, xml))
628 tools.convert_csv_import(cr, m, os.path.basename(xml), fp.read(), idref, mode=mode, noupdate=True)
630 tools.convert_yaml_import(cr, m, fp, idref, mode=mode, noupdate=True, **kwargs)
632 tools.convert_xml_import(cr, m, fp, idref, mode=mode, noupdate=True, **kwargs)
635 def load_data(cr, module_name, id_map, mode):
636 _load_data(cr, module_name, id_map, mode, 'data')
638 def load_demo(cr, module_name, id_map, mode):
639 _load_data(cr, module_name, id_map, mode, 'demo')
641 def load_test(cr, module_name, id_map, mode):
644 _load_data(cr, module_name, id_map, mode, 'test')
648 def _load_data(cr, module_name, id_map, mode, kind):
649 noupdate = (kind == 'demo')
650 for filename in package.data.get(kind, []):
651 _, ext = os.path.splitext(filename)
652 log.info("module %s: loading %s", module_name, filename)
653 pathname = os.path.join(module_name, filename)
654 file = tools.file_open(pathname)
655 # TODO manage .csv file with noupdate == (kind == 'init')
657 process_sql_file(cr, fp)
659 tools.convert_yaml_import(cr, module_name, file, id_map, mode, noupdate)
661 tools.convert_xml_import(cr, module_name, file, id_map, mode, noupdate)
664 # **kwargs is passed directly to convert_xml_import
668 status = status.copy()
671 pool = pooler.get_pool(cr.dbname)
673 migrations = MigrationManager(cr, graph)
678 logger.notifyChannel('init', netsvc.LOG_DEBUG, 'loading %d packages..' % len(graph))
680 for package in graph:
681 logger.notifyChannel('init', netsvc.LOG_INFO, 'module %s: loading objects' % package.name)
682 migrations.migrate_module(package, 'pre')
683 register_class(package.name)
684 modules = pool.instanciate(package.name, cr)
685 if hasattr(package, 'init') or hasattr(package, 'update') or package.state in ('to install', 'to upgrade'):
686 init_module_objects(cr, package.name, modules)
689 for package in graph:
690 status['progress'] = (float(statusi)+0.1) / len(graph)
695 modobj = pool.get('ir.module.module')
697 if modobj and perform_checks:
698 modobj.check(cr, 1, [mid])
701 status['progress'] = (float(statusi)+0.4) / len(graph)
704 if hasattr(package, 'init') or package.state == 'to install':
707 if hasattr(package, 'init') or hasattr(package, 'update') or package.state in ('to install', 'to upgrade'):
709 for kind in ('init', 'update'):
710 if package.state=='to upgrade':
711 # upgrading the module information
712 modobj.write(cr, 1, [mid], {
713 'description': package.data.get('description', ''),
714 'shortdesc': package.data.get('name', ''),
715 'author': package.data.get('author', 'Unknown'),
716 'website': package.data.get('website', ''),
717 'license': package.data.get('license', 'GPL-2'),
718 'certificate': package.data.get('certificate') or None,
720 load_init_update_xml(cr, m, idref, mode, kind)
721 load_data(cr, m, idref, mode)
722 if hasattr(package, 'demo') or (package.dbdemo and package.state != 'installed'):
723 status['progress'] = (float(statusi)+0.75) / len(graph)
724 load_demo_xml(cr, m, idref, mode)
725 load_demo(cr, m, idref, mode)
726 cr.execute('update ir_module_module set demo=%s where id=%s', (True, mid))
728 load_test(cr, m, idref, mode)
730 package_todo.append(package.name)
732 migrations.migrate_module(package, 'post')
735 ver = release.major_version + '.' + package.data.get('version', '1.0')
736 # Set new modules and dependencies
737 modobj.write(cr, 1, [mid], {'state': 'installed', 'latest_version': ver})
739 # Update translations for all installed languages
740 modobj.update_translations(cr, 1, [mid], None)
743 package.state = 'installed'
744 for kind in ('init', 'demo', 'update'):
745 if hasattr(package, kind):
746 delattr(package, kind)
750 cr.execute('select model from ir_model where state=%s', ('manual',))
751 for model in cr.dictfetchall():
752 pool.get('ir.model').instanciate(cr, 1, model['model'], {})
754 pool.get('ir.model.data')._process_end(cr, 1, package_todo)
759 def load_modules(db, force_demo=False, status=None, update_module=False):
764 cr.execute("SELECT relname FROM pg_class WHERE relkind='r' AND relname='ir_module_module'")
765 if len(cr.fetchall())==0:
766 logger.notifyChannel("init", netsvc.LOG_INFO, "init db")
768 tools.config["init"]["all"] = 1
769 tools.config['update']['all'] = 1
770 if not tools.config['without_demo']:
771 tools.config["demo"]['all'] = 1
775 pool = pooler.get_pool(cr.dbname)
777 report = tools.assertion_report()
778 # NOTE: Try to also load the modules that have been marked as uninstallable previously...
779 STATES_TO_LOAD = ['installed', 'to upgrade', 'uninstallable']
780 graph = create_graph(cr, ['base'], force)
781 has_updates = load_module_graph(cr, graph, status, perform_checks=(not update_module), report=report)
785 #If some module is not loaded don't proceed further
789 modobj = pool.get('ir.module.module')
790 logger.notifyChannel('init', netsvc.LOG_INFO, 'updating modules list')
791 if ('base' in tools.config['init']) or ('base' in tools.config['update']):
792 modobj.update_list(cr, 1)
794 mods = [k for k in tools.config['init'] if tools.config['init'][k]]
796 ids = modobj.search(cr, 1, ['&', ('state', '=', 'uninstalled'), ('name', 'in', mods)])
798 modobj.button_install(cr, 1, ids)
800 mods = [k for k in tools.config['update'] if tools.config['update'][k]]
802 ids = modobj.search(cr, 1, ['&', ('state', '=', 'installed'), ('name', 'in', mods)])
804 modobj.button_upgrade(cr, 1, ids)
806 cr.execute("update ir_module_module set state=%s where name=%s", ('installed', 'base'))
808 STATES_TO_LOAD += ['to install']
813 if loop_guardrail > 100:
814 raise ProgrammingError()
815 cr.execute("SELECT name from ir_module_module WHERE state in (%s)" % ','.join(['%s']*len(STATES_TO_LOAD)), STATES_TO_LOAD)
817 module_list = [name for (name,) in cr.fetchall() if name not in graph]
821 new_modules_in_graph = upgrade_graph(graph, cr, module_list, force)
822 if new_modules_in_graph == 0:
826 logger.notifyChannel('init', netsvc.LOG_DEBUG, 'Updating graph with %d more modules' % (len(module_list)))
827 r = load_module_graph(cr, graph, status, report=report)
828 has_updates = has_updates or r
831 cr.execute("""select model,name from ir_model where id not in (select model_id from ir_model_access)""")
832 for (model, name) in cr.fetchall():
833 logger.notifyChannel('init', netsvc.LOG_WARNING, 'object %s (%s) has no access rules!' % (model, name))
835 cr.execute("SELECT model from ir_model")
836 for (model,) in cr.fetchall():
837 obj = pool.get(model)
839 obj._check_removed_columns(cr, log=True)
841 if report.get_report():
842 logger.notifyChannel('init', netsvc.LOG_INFO, report)
844 for kind in ('init', 'demo', 'update'):
845 tools.config[kind] = {}
849 cr.execute("select id,name from ir_module_module where state=%s", ('to remove',))
850 for mod_id, mod_name in cr.fetchall():
851 cr.execute('select model,res_id from ir_model_data where noupdate=%s and module=%s order by id desc', (False, mod_name,))
852 for rmod, rid in cr.fetchall():
854 rmod_module= pool.get(rmod)
856 rmod_module.unlink(cr, uid, [rid])
858 logger.notifyChannel('init', netsvc.LOG_ERROR, 'Could not locate %s to remove res=%d' % (rmod,rid))
859 cr.execute('delete from ir_model_data where noupdate=%s and module=%s', (False, mod_name,))
862 # TODO: remove menu without actions of children
865 cr.execute('''delete from
868 (id not in (select parent_id from ir_ui_menu where parent_id is not null))
870 (id not in (select res_id from ir_values where model='ir.ui.menu'))
872 (id not in (select res_id from ir_model_data where model='ir.ui.menu'))''')
877 logger.notifyChannel('init', netsvc.LOG_INFO, 'removed %d unused menus' % (cr.rowcount,))
879 cr.execute("update ir_module_module set state=%s where state=%s", ('uninstalled', 'to remove',))
885 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: