Merge lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor into lp:~openerp-connector-core-editors/openerp-connector/7.0

Proposed by Guewen Baconnier @ Camptocamp on 2013-11-08
Status: Merged
Approved by: Guewen Baconnier @ Camptocamp on 2014-01-14
Approved revision: 595
Merged at revision: 605
Proposed branch: lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor
Merge into: lp:~openerp-connector-core-editors/openerp-connector/7.0
Prerequisite: lp:~camptocamp/openerp-connector/7.0-connector-closure-functions
Diff against target: 1271 lines (+790/-197)
6 files modified
connector/CHANGES.rst (+1/-5)
connector/backend.py (+7/-4)
connector/exception.py (+4/-0)
connector/tests/test_backend.py (+2/-1)
connector/tests/test_mapper.py (+273/-67)
connector/unit/mapper.py (+503/-120)
To merge this branch: bzr merge lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor
Reviewer Review Type Date Requested Status
Joël Grand-Guillaume @ camptocamp code review, no tests 2013-11-08 Approve on 2013-12-12
Review via email: mp+194485@code.launchpad.net

This proposal supersedes a proposal from 2013-11-08.

Description of the Change

Refactoring of the Mappers.
===========================

Work in progress.

Now that I have gained more experience with my framework and that I have been confronted with different implementations, I discover some potentialities as well as some of its limitations.

This proposal aims to address some issues and limitations of the actual mappers.

It is _as possible_ backward compatible, but a few things will need to be changed in the implementations using the connector (not in the Mappers themselves though, but in the way we use them, so the change has a limited effect).

Rationale
---------

State
~~~~~

The synchronizers have a a direct access to the most used ConnectorUnit classes, self.binder, self.backend_adapter, self.mapper will return an instance of the appropriate one. The same instance is reused. There is a design issue on the Mappers because they contains a state of the records:

    self.mapper.convert(record)
    values = self.mapper.data

One of my point was to re-establish a stateless Mapper. Now, the mapper returns a MapRecord prepared for the mapping. The MapRecord can ask to be mapped at any moment:

    map_record = self.mapper.map_record(record)
    map_record2 = self.mapper.map_record(record2)
    values = map_record.values()

(in fact, there is some transient state in the Mapper, only when we call map_record.values(), which is self.options, so it is not thread-safe - that is not a goal -, but there is no issues like before).

This forms allows brings a nice feature which are the options.

Options
~~~~~~~

The new form with a MapRecord allows to specify custom options for the mappings, example:

      map_record = self.mapper.map_record(record)
      values = map_record.values(tax_include=True)

      # in the mapper
      @mapping
      def fees(self, record):
          if self.options.tax_include:
              return ...
          else:
              return ...

The 'for_create' and 'fields' are now handled like options.

Coupling of "children" mappings
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Children mappings are, for instance, the lines of a sales order that we want to create in the same call to "create()" than the sales order's one.

In the current code, they are defined in the mappers with:

   children = [('items', 'magento_order_line_ids', 'magento.sale.order.line')]

Where 'items' is the key containing the lines in the source record, 'magento_order_line_ids' the key in the target record and 'magento.sale.order.line' the name of the model of the lines.
The connector would search the Mapper for the model of the lines, process each lines and add them in the target record (for instance with the OpenERP commands (0, 0, {values}).

This was done directly in the main Mapper and thus was completely coupled with it. It brought difficulties when we wanted a more elaborate behavior than the default one: skip some lines, update existing lines instead of just creating them...

So I introduce a "MapChild" ConnectorUnit, which defines *how* items are converted and added to the main target record. The result of the default implementation is similar to the previous one. It can be easily extended to customize it though. It is not mandatory to create a MapChild each time we use 'children', the default one will be used if it not defined (although this ensures the backward compatibility).

Changes after the mapping
~~~~~~~~~~~~~~~~~~~~~~~~~

The Mappers apply all the 'direct' mappings, then all the @mapping methods, then all the 'children' mappings. Finally, we have the opportunity to override _after_mapping() so we can alter the values just before they are returned by the Mapper. Unfortunately, this method wasn't aware of the source values, which was a pity since we may need them if we want to change the target values. This is fixed in the new finalize() method.

Modifiers
~~~~~~~~~

See https://code.launchpad.net/~camptocamp/openerp-connector/7.0-connector-closure-functions/+merge/193389

Non-Backward compatible changes
-------------------------------

Usage of a Mapper
~~~~~~~~~~~~~~~~~
Previously:

    self.mapper.convert(record, fields=['a', 'b')
    values = self.mapper.data
    values = self.mapper.data_for_create

Now:

    map_record = self.mapper.map_record(record)
    values = map_record.values()
    values = map_record.values(for_create=True)
    values = map_record.values(fields=['a', 'b')
    values = map_record.values(tax_include=True)

This change impacts the Synchronizers.

Filtering of items
~~~~~~~~~~~~~~~~~~

For 'children' mappings (submappings), (e.g. lines of a sales order),
the way in place to filter out some items was:

       # method in 'Mapper'
       def skip_convert_child(self, record, parent_values=None):
        return record['type'] == 'configurable'

Now, we need to create a 'MapChild' ConnectorUnit that will decide *how* the items are converted (note that creating a 'MapChild' is not necessary as soon as we don't need to modify its default behavior):

    @magento
    class SalesLineMapChild(MapChild):
        _model_name = 'magento.sale.order.line'

        def skip_item(self, map_record):
        # map_record.parent is accessible
        return map_record.source['type'] == 'configurable'

Changes at the end of a mapping
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

At the end of a mapping, Mapper._after_mapping() was called with the output values, letting the developer changing the values when all of them are known. This was handy for applying onchanges for instance. Though, it was not aware of the source values,
which was unfortunate if we needed them.

Now, instead of overriding Mapper._after_mapping(self, record), override:

    def finalize(self, map_record, values):
        # call onchanges...
        # add extra order lines...
        return values

Concretely
~~~~~~~~~~

Here are the changes I needed to do for the Magento Connector:
https://code.launchpad.net/~camptocamp/openerp-connector-magento/7.0-new-mapper-api-gbr/+merge/194630

----------------------------------------------------------------------
Ran 74 tests in 4.278s

OK

To post a comment you must log in.

Branch is now sufficiently stable to be tested and used for development.

Hi,

Thanks for this work ! As far as my skills can judge of that MP, it LGTM. Tested on my machine, all tests are green and I found no coding standard issues so I approve this proposal.

Though, I'll feel comfortable here if a more invested person could validate that work.

Regards,

Joël

review: Approve (code review, no tests)
594. By Guewen Baconnier @ Camptocamp on 2013-12-20

[IMP] extract the build of the MapChild unit instance to allow better reusability

595. By Guewen Baconnier @ Camptocamp on 2013-12-24

[MRG] from lp:openerp-connector

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'connector/CHANGES.rst'
2--- connector/CHANGES.rst 2013-12-24 07:45:03 +0000
3+++ connector/CHANGES.rst 2013-12-24 07:45:03 +0000
4@@ -4,17 +4,13 @@
5 2.0.1.dev0
6 ~~~~~~~~~~
7
8-<<<<<<< TREE
9 * Add a new optional keyword argument 'description' to the delay() function of a
10 job. If given, the description is used as name of the queue.job record stored
11 in OpenERP and displayed in the list of jobs.
12-* Fix: assignment of jobs to workers respect the priority of the jobs (lp:1252681)
13-
14-=======
15 * Add the possibility to use 'Modifiers' functions in the 'direct
16 mappings' (details in the documentation of the Mapper class)
17+* Fix: assignment of jobs to workers respect the priority of the jobs (lp:1252681)
18
19->>>>>>> MERGE-SOURCE
20 2.0.1 (2013-09-12)
21 ~~~~~~~~~~~~~~~~~~
22
23
24=== modified file 'connector/backend.py'
25--- connector/backend.py 2013-08-26 20:05:18 +0000
26+++ connector/backend.py 2013-12-24 07:45:03 +0000
27@@ -20,6 +20,7 @@
28 ##############################################################################
29 from functools import partial
30 from collections import namedtuple
31+from .exception import NoConnectorUnitError
32
33 __all__ = ['Backend']
34
35@@ -257,10 +258,12 @@
36 """
37 matching_classes = self._get_classes(base_class, session,
38 model_name)
39- assert matching_classes, ('No matching class found for %s '
40- 'with session: %s, '
41- 'model name: %s' %
42- (base_class, session, model_name))
43+ if not matching_classes:
44+ raise NoConnectorUnitError('No matching class found for %s '
45+ 'with session: %s, '
46+ 'model name: %s' %
47+ (base_class, session, model_name))
48+
49 assert len(matching_classes) == 1, (
50 'Several classes found for %s '
51 'with session %s, model name: %s. Found: %s' %
52
53=== modified file 'connector/exception.py'
54--- connector/exception.py 2013-06-19 14:47:17 +0000
55+++ connector/exception.py 2013-12-24 07:45:03 +0000
56@@ -24,6 +24,10 @@
57 """ Base Exception for the connectors """
58
59
60+class NoConnectorUnitError(ConnectorException):
61+ """ No ConnectorUnit has been found """
62+
63+
64 class InvalidDataError(ConnectorException):
65 """ Data Invalid """
66
67
68=== modified file 'connector/tests/test_backend.py'
69--- connector/tests/test_backend.py 2013-12-24 07:45:03 +0000
70+++ connector/tests/test_backend.py 2013-12-24 07:45:03 +0000
71@@ -6,6 +6,7 @@
72 from openerp.addons.connector.backend import (Backend,
73 get_backend,
74 BACKENDS)
75+from openerp.addons.connector.exception import NoConnectorUnitError
76 from openerp.addons.connector.connector import (Binder,
77 ConnectorUnit)
78 from openerp.addons.connector.unit.mapper import (Mapper,
79@@ -110,7 +111,7 @@
80
81 def test_no_register_error(self):
82 """ Error when asking for a class and none is found"""
83- with self.assertRaises(AssertionError):
84+ with self.assertRaises(NoConnectorUnitError):
85 ref = self.backend.get_class(BackendAdapter,
86 self.session,
87 'res.users')
88
89=== modified file 'connector/tests/test_mapper.py'
90--- connector/tests/test_mapper.py 2013-12-24 07:45:03 +0000
91+++ connector/tests/test_mapper.py 2013-12-24 07:45:03 +0000
92@@ -7,6 +7,7 @@
93 from openerp.addons.connector.unit.mapper import (
94 Mapper,
95 ImportMapper,
96+ ImportMapChild,
97 MappingDefinition,
98 changed_by,
99 only_create,
100@@ -14,8 +15,10 @@
101 m2o_to_backend,
102 backend_to_m2o,
103 none,
104+ MapOptions,
105 mapping)
106
107+from openerp.addons.connector.exception import NoConnectorUnitError
108 from openerp.addons.connector.backend import Backend
109 from openerp.addons.connector.connector import Environment
110 from openerp.addons.connector.session import ConnectorSession
111@@ -160,11 +163,11 @@
112 record = {'name': 'Guewen',
113 'street': 'street'}
114 mapper = MyMapper(env)
115- mapper.convert(record)
116+ map_record = mapper.map_record(record)
117 expected = {'out_name': 'Guewen',
118 'out_street': 'STREET'}
119- self.assertEqual(mapper.data, expected)
120- self.assertEqual(mapper.data_for_create, expected)
121+ self.assertEqual(map_record.values(), expected)
122+ self.assertEqual(map_record.values(for_create=True), expected)
123
124 def test_mapping_record_on_create(self):
125 """ Map a record and check the result for creation of record """
126@@ -185,64 +188,98 @@
127 record = {'name': 'Guewen',
128 'street': 'street'}
129 mapper = MyMapper(env)
130- mapper.convert(record)
131+ map_record = mapper.map_record(record)
132 expected = {'out_name': 'Guewen',
133 'out_street': 'STREET'}
134- self.assertEqual(mapper.data, expected)
135+ self.assertEqual(map_record.values(), expected)
136 expected = {'out_name': 'Guewen',
137 'out_street': 'STREET',
138 'out_city': 'city'}
139- self.assertEqual(mapper.data_for_create, expected)
140-
141- def test_mapping_record_children(self):
142- """ Map a record with children and check the result """
143-
144- class LineMapper(ImportMapper):
145- direct = [('name', 'name')]
146+ self.assertEqual(map_record.values(for_create=True), expected)
147+
148+ def test_mapping_update(self):
149+ """ Force values on a map record """
150+ class MyMapper(ImportMapper):
151+
152+ direct = [('name', 'out_name')]
153
154 @mapping
155- def price(self, record):
156- return {'price': record['price'] * 2}
157+ def street(self, record):
158+ return {'out_street': record['street'].upper()}
159
160 @only_create
161 @mapping
162- def discount(self, record):
163- return {'discount': .5}
164-
165- class SaleMapper(ImportMapper):
166-
167- direct = [('name', 'name')]
168-
169- children = [('lines', 'line_ids', 'sale.order.line')]
170-
171- def _init_child_mapper(self, model_name):
172- return LineMapper(self.environment)
173-
174- env = mock.MagicMock()
175-
176- record = {'name': 'SO1',
177- 'lines': [{'name': 'Mango',
178- 'price': 10},
179- {'name': 'Pumpkin',
180- 'price': 20}]}
181- mapper = SaleMapper(env)
182- mapper.convert(record)
183- expected = {'name': 'SO1',
184- 'line_ids': [(0, 0, {'name': 'Mango',
185- 'price': 20}),
186- (0, 0, {'name': 'Pumpkin',
187- 'price': 40})]
188- }
189- self.assertEqual(mapper.data, expected)
190- expected = {'name': 'SO1',
191- 'line_ids': [(0, 0, {'name': 'Mango',
192- 'price': 20,
193- 'discount': .5}),
194- (0, 0, {'name': 'Pumpkin',
195- 'price': 40,
196- 'discount': .5})]
197- }
198- self.assertEqual(mapper.data_for_create, expected)
199+ def city(self, record):
200+ return {'out_city': 'city'}
201+
202+ env = mock.MagicMock()
203+ record = {'name': 'Guewen',
204+ 'street': 'street'}
205+ mapper = MyMapper(env)
206+ map_record = mapper.map_record(record)
207+ map_record.update({'test': 1}, out_city='forced')
208+ expected = {'out_name': 'Guewen',
209+ 'out_street': 'STREET',
210+ 'out_city': 'forced',
211+ 'test': 1}
212+ self.assertEqual(map_record.values(), expected)
213+ expected = {'out_name': 'Guewen',
214+ 'out_street': 'STREET',
215+ 'out_city': 'forced',
216+ 'test': 1}
217+ self.assertEqual(map_record.values(for_create=True), expected)
218+
219+ def test_finalize(self):
220+ """ Inherit finalize to modify values """
221+ class MyMapper(ImportMapper):
222+
223+ direct = [('name', 'out_name')]
224+
225+ def finalize(self, record, values):
226+ result = super(MyMapper, self).finalize(record, values)
227+ result['test'] = 'abc'
228+ return result
229+
230+ env = mock.MagicMock()
231+ record = {'name': 'Guewen',
232+ 'street': 'street'}
233+ mapper = MyMapper(env)
234+ map_record = mapper.map_record(record)
235+ expected = {'out_name': 'Guewen',
236+ 'test': 'abc'}
237+ self.assertEqual(map_record.values(), expected)
238+ expected = {'out_name': 'Guewen',
239+ 'test': 'abc'}
240+ self.assertEqual(map_record.values(for_create=True), expected)
241+
242+ def test_some_fields(self):
243+ """ Map only a selection of fields """
244+ class MyMapper(ImportMapper):
245+
246+ direct = [('name', 'out_name'),
247+ ('street', 'out_street'),
248+ ]
249+
250+ @changed_by('country')
251+ @mapping
252+ def country(self, record):
253+ return {'country': 'country'}
254+
255+ env = mock.MagicMock()
256+ record = {'name': 'Guewen',
257+ 'street': 'street',
258+ 'country': 'country'}
259+ mapper = MyMapper(env)
260+ map_record = mapper.map_record(record)
261+ expected = {'out_name': 'Guewen',
262+ 'country': 'country'}
263+ self.assertEqual(map_record.values(fields=['name', 'country']),
264+ expected)
265+ expected = {'out_name': 'Guewen',
266+ 'country': 'country'}
267+ self.assertEqual(map_record.values(for_create=True,
268+ fields=['name', 'country']),
269+ expected)
270
271 def test_mapping_modifier(self):
272 """ Map a direct record with a modifier function """
273@@ -258,10 +295,10 @@
274 env = mock.MagicMock()
275 record = {'name': 'Guewen'}
276 mapper = MyMapper(env)
277- mapper.convert(record)
278+ map_record = mapper.map_record(record)
279 expected = {'out_name': 'Guewen'}
280- self.assertEqual(mapper.data, expected)
281- self.assertEqual(mapper.data_for_create, expected)
282+ self.assertEqual(map_record.values(), expected)
283+ self.assertEqual(map_record.values(for_create=True), expected)
284
285 def test_mapping_convert(self):
286 """ Map a direct record with the convert modifier function """
287@@ -271,10 +308,10 @@
288 env = mock.MagicMock()
289 record = {'name': '300'}
290 mapper = MyMapper(env)
291- mapper.convert(record)
292+ map_record = mapper.map_record(record)
293 expected = {'out_name': 300}
294- self.assertEqual(mapper.data, expected)
295- self.assertEqual(mapper.data_for_create, expected)
296+ self.assertEqual(map_record.values(), expected)
297+ self.assertEqual(map_record.values(for_create=True), expected)
298
299 def test_mapping_modifier_none(self):
300 """ Pipeline of modifiers """
301@@ -285,10 +322,10 @@
302 env = mock.MagicMock()
303 record = {'in_f': False, 'in_t': True}
304 mapper = MyMapper(env)
305- mapper.convert(record)
306+ map_record = mapper.map_record(record)
307 expected = {'out_f': None, 'out_t': True}
308- self.assertEqual(mapper.data, expected)
309- self.assertEqual(mapper.data_for_create, expected)
310+ self.assertEqual(map_record.values(), expected)
311+ self.assertEqual(map_record.values(for_create=True), expected)
312
313 def test_mapping_modifier_pipeline(self):
314 """ Pipeline of modifiers """
315@@ -299,10 +336,59 @@
316 env = mock.MagicMock()
317 record = {'in_f': 0, 'in_t': 1}
318 mapper = MyMapper(env)
319- mapper.convert(record)
320+ map_record = mapper.map_record(record)
321 expected = {'out_f': None, 'out_t': True}
322- self.assertEqual(mapper.data, expected)
323- self.assertEqual(mapper.data_for_create, expected)
324+ self.assertEqual(map_record.values(), expected)
325+ self.assertEqual(map_record.values(for_create=True), expected)
326+
327+ def test_mapping_custom_option(self):
328+ """ Usage of custom options in mappings """
329+ class MyMapper(ImportMapper):
330+ @mapping
331+ def any(self, record):
332+ if self.options.custom:
333+ res = True
334+ else:
335+ res = False
336+ return {'res': res}
337+
338+ env = mock.MagicMock()
339+ record = {}
340+ mapper = MyMapper(env)
341+ map_record = mapper.map_record(record)
342+ expected = {'res': True}
343+ self.assertEqual(map_record.values(custom=True), expected)
344+
345+ def test_mapping_custom_option_not_defined(self):
346+ """ Usage of custom options not defined raise AttributeError """
347+ class MyMapper(ImportMapper):
348+ @mapping
349+ def any(self, record):
350+ if self.options.custom is None:
351+ res = True
352+ else:
353+ res = False
354+ return {'res': res}
355+
356+ env = mock.MagicMock()
357+ record = {}
358+ mapper = MyMapper(env)
359+ map_record = mapper.map_record(record)
360+ expected = {'res': True}
361+ self.assertEqual(map_record.values(), expected)
362+
363+ def test_map_options(self):
364+ """ Test MapOptions """
365+ options = MapOptions({'xyz': 'abc'}, k=1)
366+ options.l = 2
367+ self.assertEqual(options['xyz'], 'abc')
368+ self.assertEqual(options['k'], 1)
369+ self.assertEqual(options['l'], 2)
370+ self.assertEqual(options.xyz, 'abc')
371+ self.assertEqual(options.k, 1)
372+ self.assertEqual(options.l, 2)
373+ self.assertEqual(options['undefined'], None)
374+ self.assertEqual(options.undefined, None)
375
376
377 class test_mapper_binding(common.TransactionCase):
378@@ -334,10 +420,10 @@
379 self.country_binder.to_backend.return_value = 10
380
381 mapper = MyMapper(self.env)
382- mapper.convert(partner)
383+ map_record = mapper.map_record(partner)
384+ self.assertEqual(map_record.values(), {'country': 10})
385 self.country_binder.to_backend.assert_called_once_with(
386 partner.country_id.id, wrap=False)
387- self.assertEqual(mapper.data, {'country': 10})
388
389 def test_mapping_backend_to_m2o(self):
390 """ Map a direct record with the backend_to_m2o modifier function """
391@@ -348,7 +434,127 @@
392 record = {'country': 10}
393 self.country_binder.to_openerp.return_value = 44
394 mapper = MyMapper(self.env)
395- mapper.convert(record)
396+ map_record = mapper.map_record(record)
397+ self.assertEqual(map_record.values(), {'country_id': 44})
398 self.country_binder.to_openerp.assert_called_once_with(
399 10, unwrap=False)
400- self.assertEqual(mapper.data, {'country_id': 44})
401+
402+ def test_mapping_record_children_no_map_child(self):
403+ """ Map a record with children, using default MapChild """
404+
405+ backend = Backend('backend', '42')
406+
407+ @backend
408+ class LineMapper(ImportMapper):
409+ _model_name = 'res.currency.rate'
410+ direct = [('name', 'name')]
411+
412+ @mapping
413+ def price(self, record):
414+ return {'rate': record['rate'] * 2}
415+
416+ @only_create
417+ @mapping
418+ def discount(self, record):
419+ return {'test': .5}
420+
421+ @backend
422+ class ObjectMapper(ImportMapper):
423+ _model_name = 'res.currency'
424+
425+ direct = [('name', 'name')]
426+
427+ children = [('lines', 'line_ids', 'res.currency.rate')]
428+
429+
430+ backend_record = mock.Mock()
431+ backend_record.get_backend.side_effect = lambda *a: backend
432+ env = Environment(backend_record, self.session, 'res.currency')
433+
434+ record = {'name': 'SO1',
435+ 'lines': [{'name': '2013-11-07',
436+ 'rate': 10},
437+ {'name': '2013-11-08',
438+ 'rate': 20}]}
439+ mapper = ObjectMapper(env)
440+ map_record = mapper.map_record(record)
441+ expected = {'name': 'SO1',
442+ 'line_ids': [(0, 0, {'name': '2013-11-07',
443+ 'rate': 20}),
444+ (0, 0, {'name': '2013-11-08',
445+ 'rate': 40})]
446+ }
447+ self.assertEqual(map_record.values(), expected)
448+ expected = {'name': 'SO1',
449+ 'line_ids': [(0, 0, {'name': '2013-11-07',
450+ 'rate': 20,
451+ 'test': .5}),
452+ (0, 0, {'name': '2013-11-08',
453+ 'rate': 40,
454+ 'test': .5})]
455+ }
456+ self.assertEqual(map_record.values(for_create=True), expected)
457+
458+ def test_mapping_record_children(self):
459+ """ Map a record with children, using defined MapChild """
460+
461+ backend = Backend('backend', '42')
462+
463+ @backend
464+ class LineMapper(ImportMapper):
465+ _model_name = 'res.currency.rate'
466+ direct = [('name', 'name')]
467+
468+ @mapping
469+ def price(self, record):
470+ return {'rate': record['rate'] * 2}
471+
472+ @only_create
473+ @mapping
474+ def discount(self, record):
475+ return {'test': .5}
476+
477+ @backend
478+ class SaleLineImportMapChild(ImportMapChild):
479+ _model_name = 'res.currency.rate'
480+
481+ def format_items(self, items_values):
482+ return [('ABC', values) for values in items_values]
483+
484+
485+ @backend
486+ class ObjectMapper(ImportMapper):
487+ _model_name = 'res.currency'
488+
489+ direct = [('name', 'name')]
490+
491+ children = [('lines', 'line_ids', 'res.currency.rate')]
492+
493+
494+ backend_record = mock.Mock()
495+ backend_record.get_backend.side_effect = lambda *a: backend
496+ env = Environment(backend_record, self.session, 'res.currency')
497+
498+ record = {'name': 'SO1',
499+ 'lines': [{'name': '2013-11-07',
500+ 'rate': 10},
501+ {'name': '2013-11-08',
502+ 'rate': 20}]}
503+ mapper = ObjectMapper(env)
504+ map_record = mapper.map_record(record)
505+ expected = {'name': 'SO1',
506+ 'line_ids': [('ABC', {'name': '2013-11-07',
507+ 'rate': 20}),
508+ ('ABC', {'name': '2013-11-08',
509+ 'rate': 40})]
510+ }
511+ self.assertEqual(map_record.values(), expected)
512+ expected = {'name': 'SO1',
513+ 'line_ids': [('ABC', {'name': '2013-11-07',
514+ 'rate': 20,
515+ 'test': .5}),
516+ ('ABC', {'name': '2013-11-08',
517+ 'rate': 40,
518+ 'test': .5})]
519+ }
520+ self.assertEqual(map_record.values(for_create=True), expected)
521
522=== modified file 'connector/unit/mapper.py'
523--- connector/unit/mapper.py 2013-12-24 07:45:03 +0000
524+++ connector/unit/mapper.py 2013-12-24 07:45:03 +0000
525@@ -19,33 +19,65 @@
526 #
527 ##############################################################################
528
529+"""
530+
531+Mappers
532+=======
533+
534+Mappers are the ConnectorUnit classes responsible to transform
535+external records into OpenERP records and conversely.
536+
537+"""
538+
539 import logging
540 from collections import namedtuple
541+from contextlib import contextmanager
542
543 from ..connector import ConnectorUnit, MetaConnectorUnit, Environment
544-from ..exception import MappingError
545+from ..exception import MappingError, NoConnectorUnitError
546
547 _logger = logging.getLogger(__name__)
548
549
550 def mapping(func):
551- """ Decorator declarating a mapping for a field """
552+ """ Declare that a method is a mapping method.
553+
554+ It is then used by the :py:class:`Mapper` to convert the records.
555+
556+ Usage::
557+
558+ @mapping
559+ def any(self, record):
560+ return {'output_field': record['input_field']}
561+
562+ """
563 func.is_mapping = True
564 return func
565
566
567 def changed_by(*args):
568- """ Decorator for the mappings. When fields are modified, we want to modify
569- only the modified fields. Using this decorator, we can specify which fields
570- updates should trigger which mapping.
571+ """ Decorator for the mapping methods (:py:func:`mapping`)
572+
573+ When fields are modified in OpenERP, we want to export only the
574+ modified fields. Using this decorator, we can specify which fields
575+ updates should trigger which mapping method.
576
577 If ``changed_by`` is empty, the mapping is always active.
578- As far as possible, it should be used, thus, when we do an update on
579- only a small number of fields on a record, the size of the output
580- record will be limited to only the fields really having to be
581- modified.
582+
583+ As far as possible, this decorator should be used for the exports,
584+ thus, when we do an update on only a small number of fields on a
585+ record, the size of the output record will be limited to only the
586+ fields really having to be exported.
587+
588+ Usage::
589+
590+ @changed_by('input_field')
591+ @mapping
592+ def any(self, record):
593+ return {'output_field': record['input_field']}
594
595 :param *args: field names which trigger the mapping when modified
596+
597 """
598 def register_mapping(func):
599 func.changed_by = args
600@@ -54,8 +86,19 @@
601
602
603 def only_create(func):
604- """ A mapping decorated with ``only_create`` means that it has to be
605- used only for the creation of the records. """
606+ """ Decorator for the mapping methods (:py:func:`mapping`)
607+
608+ A mapping decorated with ``only_create`` means that it has to be
609+ used only for the creation of the records.
610+
611+ Usage::
612+
613+ @only_create
614+ @mapping
615+ def any(self, record):
616+ return {'output_field': record['input_field']}
617+
618+ """
619 func.only_create = True
620 return func
621
622@@ -241,6 +284,157 @@
623 return cls
624
625
626+class MapChild(ConnectorUnit):
627+ """ MapChild is responsible to convert items.
628+
629+ Items are sub-records of a main record.
630+ In this example, the items are the records in ``lines``::
631+
632+ sales = {'name': 'SO10',
633+ 'lines': [{'product_id': 1, 'quantity': 2},
634+ {'product_id': 2, 'quantity': 2}]}
635+
636+ A MapChild is always called from another :py:class:`Mapper` which
637+ provides a ``children`` configuration.
638+
639+ Considering the example above, the "main" :py:class:`Mapper` would
640+ returns something as follows::
641+
642+ {'name': 'SO10',
643+ 'lines': [(0, 0, {'product_id': 11, 'quantity': 2}),
644+ (0, 0, {'product_id': 12, 'quantity': 2})]}
645+
646+ A MapChild is responsible to:
647+
648+ * Find the :py:class:`Mapper` to convert the items
649+ * Eventually filter out some lines (can be done by inheriting
650+ :py:meth:`skip_item`)
651+ * Convert the items' records using the found :py:class:`Mapper`
652+ * Format the output values to the format expected by OpenERP or the
653+ backend (as seen above with ``(0, 0, {values})``
654+
655+ A MapChild can be extended like any other
656+ :py:class:`~connector.connector.ConnectorUnit`. However, it is not mandatory
657+ to explicitly create a MapChild for each children mapping, the default
658+ one will be used (:py:class:`ImportMapChild` or :py:class:`ExportMapChild`).
659+
660+ The implementation by default does not take care of the updates: if
661+ I import a sales order 2 times, the lines will be duplicated. This
662+ is not a problem as long as an importation should only support the
663+ creation (typical for sales orders). It can be implemented on a
664+ case-by-case basis by inheriting :py:meth:`get_item_values` and
665+ :py:meth:`format_items`.
666+
667+ """
668+ _model_name = None
669+
670+ def _child_mapper(self):
671+ raise NotImplementedError
672+
673+ def skip_item(self, map_record):
674+ """ Hook to implement in sub-classes when some child
675+ records should be skipped.
676+
677+ The parent record is accessible in ``map_record``.
678+ If it returns True, the current child record is skipped.
679+
680+ :param map_record: record that we are converting
681+ :type map_record: :py:class:`MapRecord`
682+ """
683+ return False
684+
685+ def get_items(self, items, parent, to_attr, options):
686+ """ Returns the formatted output values of items from a main record
687+
688+ :param items: list of item records
689+ :type items: list
690+ :param parent: parent record
691+ :param to_attr: destination field (can be used for introspecting
692+ the relation)
693+ :type to_attr: str
694+ :param options: dict of options, herited from the main mapper
695+ :return: formatted output values for the item
696+
697+ """
698+ mapper = self._child_mapper()
699+ mapped = []
700+ for item in items:
701+ map_record = mapper.map_record(item, parent=parent)
702+ if self.skip_item(map_record):
703+ continue
704+ mapped.append(self.get_item_values(map_record, to_attr, options))
705+ return self.format_items(mapped)
706+
707+ def get_item_values(self, map_record, to_attr, options):
708+ """ Get the raw values from the child Mappers for the items.
709+
710+ It can be overridden for instance to:
711+
712+ * Change options
713+ * Use a :py:class:`~connector.connector.Binder` to know if an
714+ item already exists to modify an existing item, rather than to
715+ add it
716+
717+ :param map_record: record that we are converting
718+ :type map_record: :py:class:`MapRecord`
719+ :param to_attr: destination field (can be used for introspecting
720+ the relation)
721+ :type to_attr: str
722+ :param options: dict of options, herited from the main mapper
723+
724+ """
725+ return map_record.values(**options)
726+
727+ def format_items(self, items_values):
728+ """ Format the values of the items mapped from the child Mappers.
729+
730+ It can be overridden for instance to add the OpenERP
731+ relationships commands ``(6, 0, [IDs])``, ...
732+
733+ As instance, it can be modified to handle update of existing
734+ items: check if an 'id' has been defined by
735+ :py:meth:`get_item_values` then use the ``(1, ID, {values}``)
736+ command
737+
738+ :param items_values: mapped values for the items
739+ :type items_values: list
740+
741+ """
742+ return items_values
743+
744+
745+class ImportMapChild(MapChild):
746+ """ :py:class:`MapChild` for the Imports """
747+
748+ def _child_mapper(self):
749+ return self.get_connector_unit_for_model(ImportMapper, self.model._name)
750+
751+ def format_items(self, items_values):
752+ """ Format the values of the items mapped from the child Mappers.
753+
754+ It can be overridden for instance to add the OpenERP
755+ relationships commands ``(6, 0, [IDs])``, ...
756+
757+ As instance, it can be modified to handle update of existing
758+ items: check if an 'id' has been defined by
759+ :py:meth:`get_item_values` then use the ``(1, ID, {values}``)
760+ command
761+
762+ :param items_values: list of values for the items to create
763+ :type items_values: list
764+
765+ """
766+ return [(0, 0, values) for values in items_values]
767+
768+
769+class ExportMapChild(MapChild):
770+ """ :py:class:`MapChild` for the Exports """
771+
772+
773+ def _child_mapper(self):
774+ return self.get_connector_unit_for_model(ExportMapper, self.model._name)
775+
776+
777 class Mapper(ConnectorUnit):
778 """ A Mapper translates an external record to an OpenERP record and
779 conversely. The output of a Mapper is a ``dict``.
780@@ -323,7 +517,22 @@
781 When a record contains sub-items, like the lines of a sales order,
782 we can convert the children using another Mapper::
783
784- children = [('items', 'line_ids', LineMapper)]
785+ children = [('items', 'line_ids', 'model.name')]
786+
787+ It allows to create the sales order and all its lines with the
788+ same call to :py:meth:`openerp.osv.orm.BaseModel.create()`.
789+
790+ When using ``children`` for items of a record, we need to create
791+ a :py:class:`Mapper` for the model of the items, and optionally a
792+ :py:class:`MapChild`.
793+
794+ Usage of a Mapper::
795+
796+ mapper = Mapper(env)
797+ map_record = mapper.map_record()
798+ values = map_record.values()
799+ values = map_record.values(only_create=True)
800+ values = map_record.values(fields=['name', 'street'])
801
802 """
803
804@@ -337,6 +546,8 @@
805
806 _map_methods = None
807
808+ _map_child_class = None
809+
810 def __init__(self, environment):
811 """
812
813@@ -344,15 +555,7 @@
814 :type environment: :py:class:`connector.connector.Environment`
815 """
816 super(Mapper, self).__init__(environment)
817- self._data = None
818- self._data_for_create = None
819- self._data_children = None
820-
821- def _init_child_mapper(self, model_name):
822- raise NotImplementedError
823-
824- def _after_mapping(self, result):
825- return result
826+ self._options = None
827
828 def _map_direct(self, record, from_attr, to_attr):
829 """ Apply the ``direct`` mappings.
830@@ -370,120 +573,185 @@
831
832 @property
833 def map_methods(self):
834+ """ Yield all the methods decorated with ``@mapping`` """
835 for meth, definition in self._map_methods.iteritems():
836 yield getattr(self, meth), definition
837
838- def _convert(self, record, fields=None, parent_values=None):
839- if fields is None:
840- fields = {}
841-
842- self._data = {}
843- self._data_for_create = {}
844- self._data_children = {}
845-
846- _logger.debug('converting record %s to model %s', record, self._model_name)
847+ def _get_map_child_unit(self, model_name):
848+ try:
849+ mapper_child = self.get_connector_unit_for_model(
850+ self._map_child_class, model_name)
851+ except NoConnectorUnitError:
852+ # does not force developers to use a MapChild ->
853+ # will use the default one if not explicitely defined
854+ env = Environment(self.backend_record,
855+ self.session,
856+ model_name)
857+ mapper_child = self._map_child_class(env)
858+ return mapper_child
859+
860+ def _map_child(self, map_record, from_attr, to_attr, model_name):
861+ """ Convert items of the record as defined by children """
862+ assert self._map_child_class is not None, "_map_child_class required"
863+ child_records = map_record.source[from_attr]
864+ mapper_child = self._get_map_child_unit(model_name)
865+ items = mapper_child.get_items(child_records, map_record,
866+ to_attr, options=self.options)
867+ return items
868+
869+ @contextmanager
870+ def _mapping_options(self, options):
871+ """ Change the mapping options for the Mapper.
872+
873+ Context Manager to use in order to alter the behavior
874+ of the mapping, when using ``_apply`` or ``finalize``.
875+
876+ """
877+ current = self._options
878+ self._options = options
879+ yield
880+ self._options = current
881+
882+ @property
883+ def options(self):
884+ """ Options can be accessed in the mapping methods with
885+ ``self.options``. """
886+ return self._options
887+
888+ def map_record(self, record, parent=None):
889+ """ Get a :py:class:`MapRecord` with record, ready to be
890+ converted using the current Mapper.
891+
892+ :param record: record to transform
893+ :param parent: optional parent record, for items
894+
895+ """
896+ return MapRecord(self, record, parent=parent)
897+
898+ def _apply(self, map_record, options=None):
899+ """ Apply the mappings on a :py:class:`MapRecord`
900+
901+ :param map_record: source record to convert
902+ :type map_record: :py:class:`MapRecord`
903+
904+ """
905+ if options is None:
906+ options = {}
907+ with self._mapping_options(options):
908+ return self._apply_with_options(map_record)
909+
910+ def _apply_with_options(self, map_record):
911+ """ Apply the mappings on a :py:class:`MapRecord` with
912+ contextual options (the ``options`` given in
913+ :py:meth:`MapRecord.values()` are accessible in
914+ ``self.options``)
915+
916+ :param map_record: source record to convert
917+ :type map_record: :py:class:`MapRecord`
918+
919+ """
920+ assert self.options is not None, (
921+ "options should be defined with '_mapping_options'")
922+ _logger.debug('converting record %s to model %s',
923+ map_record.source, self.model._name)
924+
925+ fields = self.options.fields
926+ for_create = self.options.for_create
927+ result = {}
928 for from_attr, to_attr in self.direct:
929 if (not fields or from_attr in fields):
930- value = self._map_direct(record,
931+ value = self._map_direct(map_record.source,
932 from_attr,
933 to_attr)
934- self._data[to_attr] = value
935+ result[to_attr] = value
936
937 for meth, definition in self.map_methods:
938 changed_by = definition.changed_by
939 if (not fields or not changed_by or
940 changed_by.intersection(fields)):
941- values = meth(record)
942+ values = meth(map_record.source)
943 if not values:
944 continue
945 if not isinstance(values, dict):
946 raise ValueError('%s: invalid return value for the '
947 'mapping method %s' % (values, meth))
948- if definition.only_create:
949- self._data_for_create.update(values)
950- else:
951- self._data.update(values)
952+ if not definition.only_create or for_create:
953+ result.update(values)
954
955 for from_attr, to_attr, model_name in self.children:
956 if (not fields or from_attr in fields):
957- self._map_child(record, from_attr, to_attr, model_name)
958-
959- def skip_convert_child(self, record, parent_values=None):
960- """ Hook to implement in sub-classes when some child
961- records should be skipped.
962-
963- This method is only relevable for child mappers.
964-
965- If it returns True, the current child record is skipped."""
966- return False
967+ result[to_attr] = self._map_child(map_record, from_attr,
968+ to_attr, model_name)
969+
970+ return self.finalize(map_record, result)
971+
972+ def finalize(self, map_record, values):
973+ """ Called at the end of the mapping.
974+
975+ Can be used to modify the values before returning them, as the
976+ ``on_change``.
977+
978+ :param map_record: source map_record
979+ :type map_record: :py:class:`MapRecord`
980+ :param values: mapped values
981+ :returns: mapped values
982+ :rtype: dict
983+ """
984+ return values
985+
986+ def _after_mapping(self, result):
987+ """ .. deprecated:: 2.1 """
988+ raise DeprecationWarning('Mapper._after_mapping() has been deprecated, '
989+ 'use Mapper.finalize()')
990
991 def convert_child(self, record, parent_values=None):
992- """ Transform child row contained in a main record, only
993- called from another Mapper.
994-
995- :param parent_values: openerp record of the containing object
996- (e.g. sale_order for a sale_order_line)
997- """
998- self._convert(record, parent_values=parent_values)
999+ """ .. deprecated:: 2.1 """
1000+ raise DeprecationWarning('Mapper.convert_child() has been deprecated, '
1001+ 'use Mapper.map_record() then '
1002+ 'map_record.values() ')
1003
1004 def convert(self, record, fields=None):
1005- """ Transform an external record to an OpenERP record or the opposite
1006-
1007- Sometimes we want to map values only when we create the records.
1008- The mapping methods have to be decorated with ``only_create`` to
1009- map values only for creation of records.
1010-
1011- :param record: record to transform
1012- :param fields: list of fields to convert, if empty, all fields
1013- are converted
1014+ """ .. deprecated:: 2.1
1015+ See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
1016 """
1017- self._convert(record, fields=fields)
1018+ raise DeprecationWarning('Mapper.convert() has been deprecated, '
1019+ 'use Mapper.map_record() then '
1020+ 'map_record.values() ')
1021
1022 @property
1023 def data(self):
1024- """ Returns a dict for a record processed by
1025- :py:meth:`~_convert` """
1026- if self._data is None:
1027- raise ValueError('Mapper.convert should be called before '
1028- 'accessing the data')
1029- result = self._data.copy()
1030- for attr, mappers in self._data_children.iteritems():
1031- child_data = [mapper.data for mapper in mappers]
1032- if child_data:
1033- result[attr] = self._format_child_rows(child_data)
1034- return self._after_mapping(result)
1035+ """ .. deprecated:: 2.1
1036+ See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
1037+ """
1038+ raise DeprecationWarning('Mapper.data has been deprecated, '
1039+ 'use Mapper.map_record() then '
1040+ 'map_record.values() ')
1041
1042 @property
1043 def data_for_create(self):
1044- """ Returns a dict for a record processed by
1045- :py:meth:`~_convert` to use only for creation of the record. """
1046- if self._data is None:
1047- raise ValueError('Mapper.convert should be called before '
1048- 'accessing the data')
1049- result = self._data.copy()
1050- result.update(self._data_for_create)
1051- for attr, mappers in self._data_children.iteritems():
1052- child_data = [mapper.data_for_create for mapper in mappers]
1053- if child_data:
1054- result[attr] = self._format_child_rows(child_data)
1055- return self._after_mapping(result)
1056-
1057- def _format_child_rows(self, child_records):
1058- return child_records
1059-
1060- def _map_child(self, record, from_attr, to_attr, model_name):
1061- child_records = record[from_attr]
1062- self._data_children[to_attr] = []
1063- for child_record in child_records:
1064- mapper = self._init_child_mapper(model_name)
1065- if mapper.skip_convert_child(child_record, parent_values=record):
1066- continue
1067- mapper.convert_child(child_record, parent_values=record)
1068- self._data_children[to_attr].append(mapper)
1069+ """ .. deprecated:: 2.1
1070+ See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
1071+ """
1072+ raise DeprecationWarning('Mapper.data_for_create has been deprecated, '
1073+ 'use Mapper.map_record() then '
1074+ 'map_record.values() ')
1075+
1076+ def skip_convert_child(self, record, parent_values=None):
1077+ """ .. deprecated:: 2.1
1078+ Use :py:meth:`MapChild.skip_item` instead.
1079+ """
1080+ raise DeprecationWarning('Mapper.skip_convert_child has been deprecated, '
1081+ 'use MapChild.skip_item().')
1082
1083
1084 class ImportMapper(Mapper):
1085- """ Transform a record from a backend to an OpenERP record """
1086+ """ :py:class:`Mapper` for imports.
1087+
1088+ Transform a record from a backend to an OpenERP record
1089+
1090+ """
1091+
1092+ _map_child_class = ImportMapChild
1093
1094 def _map_direct(self, record, from_attr, to_attr):
1095 """ Apply the ``direct`` mappings.
1096@@ -501,25 +769,25 @@
1097 if not value:
1098 return False
1099
1100- # may be replaced by the explicit backend_to_m2o
1101+ # Backward compatibility: when a field is a relation, and a modifier is
1102+ # not used, we assume that the relation model is a binding.
1103+ # Use an explicit modifier backend_to_m2o in the 'direct' mappings to
1104+ # change that.
1105 column = self.model._all_columns[to_attr].column
1106 if column._type == 'many2one':
1107- mapping = backend_to_m2o(from_attr, binding=True)
1108+ mapping = backend_to_m2o(from_attr)
1109 value = mapping(self, record, to_attr)
1110 return value
1111
1112- def _init_child_mapper(self, model_name):
1113- env = Environment(self.backend_record,
1114- self.session,
1115- model_name)
1116- return env.get_connector_unit(ImportMapper)
1117-
1118- def _format_child_rows(self, child_records):
1119- return [(0, 0, data) for data in child_records]
1120-
1121
1122 class ExportMapper(Mapper):
1123- """ Transform a record from OpenERP to a backend record """
1124+ """ :py:class:`Mapper` for exports.
1125+
1126+ Transform a record from OpenERP to a backend record
1127+
1128+ """
1129+
1130+ _map_child_class = ExportMapChild
1131
1132 def _map_direct(self, record, from_attr, to_attr):
1133 """ Apply the ``direct`` mappings.
1134@@ -537,15 +805,130 @@
1135 if not value:
1136 return False
1137
1138- # may be replaced by the explicit m2o_to_backend
1139+ # Backward compatibility: when a field is a relation, and a modifier is
1140+ # not used, we assume that the relation model is a binding.
1141+ # Use an explicit modifier m2o_to_backend in the 'direct' mappings to
1142+ # change that.
1143 column = self.model._all_columns[from_attr].column
1144 if column._type == 'many2one':
1145- mapping = m2o_to_backend(from_attr, binding=True)
1146+ mapping = m2o_to_backend(from_attr)
1147 value = mapping(self, record, to_attr)
1148 return value
1149
1150- def _init_child_mapper(self, model_name):
1151- env = Environment(self.backend_record,
1152- self.session,
1153- model_name)
1154- return env.get_connector_unit(ExportMapper)
1155+
1156+class MapRecord(object):
1157+ """ A record prepared to be converted using a :py:class:`Mapper`.
1158+
1159+ MapRecord instances are prepared by :py:meth:`Mapper.map_record`.
1160+
1161+ Usage::
1162+
1163+ mapper = SomeMapper(env)
1164+ map_record = mapper.map_record()
1165+ output_values = map_record.values()
1166+
1167+ See :py:meth:`values` for more information on the available arguments.
1168+
1169+ """
1170+
1171+ def __init__(self, mapper, source, parent=None):
1172+ self._source = source
1173+ self._mapper = mapper
1174+ self._parent = parent
1175+ self._forced_values = {}
1176+
1177+ @property
1178+ def source(self):
1179+ """ Source record to be converted """
1180+ return self._source
1181+
1182+ @property
1183+ def parent(self):
1184+ """ Parent record if the current record is an item """
1185+ return self._parent
1186+
1187+ def values(self, for_create=None, fields=None, **kwargs):
1188+ """ Build and returns the mapped values according to the options.
1189+
1190+ Usage::
1191+
1192+ mapper = SomeMapper(env)
1193+ map_record = mapper.map_record()
1194+ output_values = map_record.values()
1195+
1196+ Creation of records
1197+ When using the option ``for_create``, only the mappings decorated
1198+ with ``@only_create`` will be mapped.
1199+
1200+ ::
1201+
1202+ output_values = map_record.values(for_create=True)
1203+
1204+ Filter on fields
1205+ When using the ``fields`` argument, the mappings will be
1206+ filtered using either the source key in ``direct`` arguments,
1207+ either the ``changed_by`` arguments for the mapping methods.
1208+
1209+ ::
1210+
1211+ output_values = map_record.values(fields=['name', 'street'])
1212+
1213+ Custom options
1214+ Arbitrary key and values can be defined in the ``kwargs``
1215+ arguments. They can later be used in the mapping methods
1216+ using ``self.options``.
1217+
1218+ ::
1219+
1220+ output_values = map_record.values(tax_include=True)
1221+
1222+ :param for_create: specify if only the mappings for creation
1223+ (``@only_create``) should be mapped.
1224+ :type for_create: boolean
1225+ :param fields: filter on fields
1226+ :type fields: list
1227+ :param **kwargs: custom options, they can later be used in the
1228+ mapping methods
1229+
1230+ """
1231+ options = MapOptions(for_create=for_create, fields=fields, **kwargs)
1232+ values = self._mapper._apply(self, options=options)
1233+ values.update(self._forced_values)
1234+ return values
1235+
1236+ def update(self, *args, **kwargs):
1237+ """ Force values to be applied after a mapping.
1238+
1239+ Usage::
1240+
1241+ mapper = SomeMapper(env)
1242+ map_record = mapper.map_record()
1243+ map_record.update(a=1)
1244+ output_values = map_record.values()
1245+ # output_values will at least contain {'a': 1}
1246+
1247+ The values assigned with ``update()`` are in any case applied,
1248+ they have a greater priority than the mapping values.
1249+
1250+ """
1251+ self._forced_values.update(*args, **kwargs)
1252+
1253+
1254+class MapOptions(dict):
1255+ """ Container for the options of mappings.
1256+
1257+ Offer the convenience to access options with attributes
1258+
1259+ """
1260+
1261+ def __getitem__(self, key):
1262+ try:
1263+ return super(MapOptions, self).__getitem__(key)
1264+ except KeyError:
1265+ return None
1266+
1267+ def __getattr__(self, key):
1268+ return self[key]
1269+
1270+ def __setattr__(self, key, value):
1271+ self[key] = value