Merge lp:~zyga/checkbox/session-peek-api into lp:checkbox
- session-peek-api
- Merge into trunk
Proposed by
Zygmunt Krynicki
Status: | Merged |
---|---|
Approved by: | Daniel Manrique |
Approved revision: | 2965 |
Merged at revision: | 2964 |
Proposed branch: | lp:~zyga/checkbox/session-peek-api |
Merge into: | lp:checkbox |
Diff against target: |
699 lines (+290/-175) 4 files modified
plainbox/plainbox/impl/session/__init__.py (+3/-1) plainbox/plainbox/impl/session/resume.py (+251/-155) plainbox/plainbox/impl/session/storage.py (+17/-0) plainbox/plainbox/impl/session/test_resume.py (+19/-19) |
To merge this branch: | bzr merge lp:~zyga/checkbox/session-peek-api |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Daniel Manrique (community) | Approve | ||
Review via email:
|
This proposal supersedes a proposal from 2014-04-30.
Commit message
Description of the change
933a1ed plainbox:session: add SessionStorage.id
5897758 plainbox:session: make SessionStorage.
6e72202 plainbox:session: refactor _restore_
08a3e28 plainbox:session: create EnvelopeUnpackMixIn
f300476 plainbox:session: add SessionPeekHelper
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'plainbox/plainbox/impl/session/__init__.py' | |||
2 | --- plainbox/plainbox/impl/session/__init__.py 2014-01-27 23:26:33 +0000 | |||
3 | +++ plainbox/plainbox/impl/session/__init__.py 2014-04-30 19:25:48 +0000 | |||
4 | @@ -79,6 +79,7 @@ | |||
5 | 79 | 'JobState', | 79 | 'JobState', |
6 | 80 | 'SessionManager', | 80 | 'SessionManager', |
7 | 81 | 'SessionMetaData', | 81 | 'SessionMetaData', |
8 | 82 | 'SessionPeekHelper', | ||
9 | 82 | 'SessionState', | 83 | 'SessionState', |
10 | 83 | 'SessionStateLegacyAPI', | 84 | 'SessionStateLegacyAPI', |
11 | 84 | 'SessionStorage', | 85 | 'SessionStorage', |
12 | @@ -91,7 +92,8 @@ | |||
13 | 91 | from plainbox.impl.session.jobs import UndesiredJobReadinessInhibitor | 92 | from plainbox.impl.session.jobs import UndesiredJobReadinessInhibitor |
14 | 92 | from plainbox.impl.session.legacy import SessionStateLegacyAPI | 93 | from plainbox.impl.session.legacy import SessionStateLegacyAPI |
15 | 93 | from plainbox.impl.session.manager import SessionManager | 94 | from plainbox.impl.session.manager import SessionManager |
16 | 95 | from plainbox.impl.session.resume import SessionPeekHelper | ||
17 | 96 | from plainbox.impl.session.state import SessionMetaData | ||
18 | 94 | from plainbox.impl.session.state import SessionState | 97 | from plainbox.impl.session.state import SessionState |
19 | 95 | from plainbox.impl.session.state import SessionMetaData | ||
20 | 96 | from plainbox.impl.session.storage import SessionStorage | 98 | from plainbox.impl.session.storage import SessionStorage |
21 | 97 | from plainbox.impl.session.storage import SessionStorageRepository | 99 | from plainbox.impl.session.storage import SessionStorageRepository |
22 | 98 | 100 | ||
23 | === modified file 'plainbox/plainbox/impl/session/resume.py' | |||
24 | --- plainbox/plainbox/impl/session/resume.py 2014-03-17 11:30:19 +0000 | |||
25 | +++ plainbox/plainbox/impl/session/resume.py 2014-04-30 19:25:48 +0000 | |||
26 | @@ -56,6 +56,7 @@ | |||
27 | 56 | from plainbox.impl.result import IOLogRecord | 56 | from plainbox.impl.result import IOLogRecord |
28 | 57 | from plainbox.impl.result import MemoryJobResult | 57 | from plainbox.impl.result import MemoryJobResult |
29 | 58 | from plainbox.impl.secure.qualifiers import SimpleQualifier | 58 | from plainbox.impl.secure.qualifiers import SimpleQualifier |
30 | 59 | from plainbox.impl.session.state import SessionMetaData | ||
31 | 59 | from plainbox.impl.session.state import SessionState | 60 | from plainbox.impl.session.state import SessionState |
32 | 60 | 61 | ||
33 | 61 | logger = logging.getLogger("plainbox.session.resume") | 62 | logger = logging.getLogger("plainbox.session.resume") |
34 | @@ -92,7 +93,78 @@ | |||
35 | 92 | """ | 93 | """ |
36 | 93 | 94 | ||
37 | 94 | 95 | ||
39 | 95 | class SessionResumeHelper: | 96 | class EnvelopeUnpackMixIn: |
40 | 97 | """ | ||
41 | 98 | A mix-in class capable of unpacking the envelope of the session storage | ||
42 | 99 | """ | ||
43 | 100 | |||
44 | 101 | def unpack_envelope(self, data): | ||
45 | 102 | """ | ||
46 | 103 | Unpack the binary envelope and get access to a JSON object | ||
47 | 104 | |||
48 | 105 | :param data: | ||
49 | 106 | Bytes representing the dormant session | ||
50 | 107 | :returns: | ||
51 | 108 | the JSON representation of a session stored in the envelope | ||
52 | 109 | :raises CorruptedSessionError: | ||
53 | 110 | if the representation of the session is corrupted in any way | ||
54 | 111 | """ | ||
55 | 112 | try: | ||
56 | 113 | data = gzip.decompress(data) | ||
57 | 114 | except IOError: | ||
58 | 115 | raise CorruptedSessionError(_("Cannot decompress session data")) | ||
59 | 116 | try: | ||
60 | 117 | text = data.decode("UTF-8") | ||
61 | 118 | except UnicodeDecodeError: | ||
62 | 119 | raise CorruptedSessionError(_("Cannot decode session text")) | ||
63 | 120 | try: | ||
64 | 121 | return json.loads(text) | ||
65 | 122 | except ValueError: | ||
66 | 123 | raise CorruptedSessionError(_("Cannot interpret session JSON")) | ||
67 | 124 | |||
68 | 125 | |||
69 | 126 | class SessionPeekHelper(EnvelopeUnpackMixIn): | ||
70 | 127 | |||
71 | 128 | def peek(self, data): | ||
72 | 129 | """ | ||
73 | 130 | Peek at the meta-data of a dormant session. | ||
74 | 131 | |||
75 | 132 | :param data: | ||
76 | 133 | Bytes representing the dormant session | ||
77 | 134 | :returns: | ||
78 | 135 | a SessionMetaData object | ||
79 | 136 | :raises CorruptedSessionError: | ||
80 | 137 | if the representation of the session is corrupted in any way | ||
81 | 138 | :raises IncompatibleSessionError: | ||
82 | 139 | if session serialization format is not supported | ||
83 | 140 | """ | ||
84 | 141 | json_repr = self.unpack_envelope(data) | ||
85 | 142 | return self._peek_json(json_repr) | ||
86 | 143 | |||
87 | 144 | def _peek_json(self, json_repr): | ||
88 | 145 | """ | ||
89 | 146 | Resume a SessionMetaData object from the JSON representation. | ||
90 | 147 | |||
91 | 148 | This method is called by :meth:`peek()` after the initial envelope | ||
92 | 149 | and parsing is done. The only error conditions that can happen | ||
93 | 150 | are related to semantic incompatibilities or corrupted internal state. | ||
94 | 151 | """ | ||
95 | 152 | logger.debug(_("Peeking at json... (see below)")) | ||
96 | 153 | logger.debug(json.dumps(json_repr, indent=4)) | ||
97 | 154 | _validate(json_repr, value_type=dict) | ||
98 | 155 | version = _validate(json_repr, key="version", choice=[1]) | ||
99 | 156 | if version == 1: | ||
100 | 157 | return SessionPeekHelper1().peek_json(json_repr) | ||
101 | 158 | elif version == 2: | ||
102 | 159 | return SessionPeekHelper2().peek_json(json_repr) | ||
103 | 160 | elif version == 3: | ||
104 | 161 | return SessionPeekHelper3().peek_json(json_repr) | ||
105 | 162 | else: | ||
106 | 163 | raise IncompatibleSessionError( | ||
107 | 164 | _("Unsupported version {}").format(version)) | ||
108 | 165 | |||
109 | 166 | |||
110 | 167 | class SessionResumeHelper(EnvelopeUnpackMixIn): | ||
111 | 96 | """ | 168 | """ |
112 | 97 | Helper class for implementing session resume feature. | 169 | Helper class for implementing session resume feature. |
113 | 98 | 170 | ||
114 | @@ -139,18 +211,7 @@ | |||
115 | 139 | :raises IncompatibleJobError: | 211 | :raises IncompatibleJobError: |
116 | 140 | if serialized jobs are not the same as current jobs | 212 | if serialized jobs are not the same as current jobs |
117 | 141 | """ | 213 | """ |
130 | 142 | try: | 214 | json_repr = self.unpack_envelope(data) |
119 | 143 | data = gzip.decompress(data) | ||
120 | 144 | except IOError: | ||
121 | 145 | raise CorruptedSessionError(_("Cannot decompress session data")) | ||
122 | 146 | try: | ||
123 | 147 | text = data.decode("UTF-8") | ||
124 | 148 | except UnicodeDecodeError: | ||
125 | 149 | raise CorruptedSessionError(_("Cannot decode session text")) | ||
126 | 150 | try: | ||
127 | 151 | json_repr = json.loads(text) | ||
128 | 152 | except ValueError: | ||
129 | 153 | raise CorruptedSessionError(_("Cannot interpret session JSON")) | ||
131 | 154 | return self._resume_json(json_repr, early_cb) | 215 | return self._resume_json(json_repr, early_cb) |
132 | 155 | 216 | ||
133 | 156 | def _resume_json(self, json_repr, early_cb=None): | 217 | def _resume_json(self, json_repr, early_cb=None): |
134 | @@ -195,7 +256,143 @@ | |||
135 | 195 | return job.id not in self._retain_id_set | 256 | return job.id not in self._retain_id_set |
136 | 196 | 257 | ||
137 | 197 | 258 | ||
139 | 198 | class SessionResumeHelper1: | 259 | class MetaDataHelper1MixIn: |
140 | 260 | |||
141 | 261 | @classmethod | ||
142 | 262 | def _restore_SessionState_metadata(cls, metadata, session_repr): | ||
143 | 263 | """ | ||
144 | 264 | Extract meta-data information from the representation of the session | ||
145 | 265 | and set it in the given session object | ||
146 | 266 | """ | ||
147 | 267 | # Get the representation of the meta-data | ||
148 | 268 | metadata_repr = _validate( | ||
149 | 269 | session_repr, key='metadata', value_type=dict) | ||
150 | 270 | # Set each bit back to the session | ||
151 | 271 | metadata.title = _validate( | ||
152 | 272 | metadata_repr, key='title', value_type=str, value_none=True) | ||
153 | 273 | metadata.flags = set([ | ||
154 | 274 | _validate( | ||
155 | 275 | flag, value_type=str, | ||
156 | 276 | value_type_msg=_("Each flag must be a string")) | ||
157 | 277 | for flag in _validate( | ||
158 | 278 | metadata_repr, key='flags', value_type=list)]) | ||
159 | 279 | metadata.running_job_name = _validate( | ||
160 | 280 | metadata_repr, key='running_job_name', value_type=str, | ||
161 | 281 | value_none=True) | ||
162 | 282 | |||
163 | 283 | |||
164 | 284 | class MetaDataHelper2MixIn(MetaDataHelper1MixIn): | ||
165 | 285 | |||
166 | 286 | @classmethod | ||
167 | 287 | def _restore_SessionState_metadata(cls, metadata, session_repr): | ||
168 | 288 | """ | ||
169 | 289 | Extract meta-data information from the representation of the session | ||
170 | 290 | and set it in the given session object | ||
171 | 291 | """ | ||
172 | 292 | super()._restore_SessionState_metadata(metadata, session_repr) | ||
173 | 293 | # Get the representation of the meta-data | ||
174 | 294 | metadata_repr = _validate( | ||
175 | 295 | session_repr, key='metadata', value_type=dict) | ||
176 | 296 | app_blob = _validate( | ||
177 | 297 | metadata_repr, key='app_blob', value_type=str, | ||
178 | 298 | value_none=True) | ||
179 | 299 | if app_blob is not None: | ||
180 | 300 | try: | ||
181 | 301 | app_blob = app_blob.encode("ASCII") | ||
182 | 302 | except UnicodeEncodeError: | ||
183 | 303 | # TRANSLATORS: please don't translate app_blob | ||
184 | 304 | raise CorruptedSessionError(_("app_blob is not ASCII")) | ||
185 | 305 | try: | ||
186 | 306 | app_blob = base64.standard_b64decode(app_blob) | ||
187 | 307 | except binascii.Error: | ||
188 | 308 | # TRANSLATORS: please don't translate app_blob | ||
189 | 309 | raise CorruptedSessionError(_("Cannot base64 decode app_blob")) | ||
190 | 310 | metadata.app_blob = app_blob | ||
191 | 311 | |||
192 | 312 | |||
193 | 313 | class MetaDataHelper3MixIn(MetaDataHelper2MixIn): | ||
194 | 314 | |||
195 | 315 | @classmethod | ||
196 | 316 | def _restore_SessionState_metadata(cls, metadata, session_repr): | ||
197 | 317 | """ | ||
198 | 318 | Extract meta-data information from the representation of the session | ||
199 | 319 | and set it in the given session object | ||
200 | 320 | """ | ||
201 | 321 | super()._restore_SessionState_metadata(metadata, session_repr) | ||
202 | 322 | # Get the representation of the meta-data | ||
203 | 323 | metadata_repr = _validate( | ||
204 | 324 | session_repr, key='metadata', value_type=dict) | ||
205 | 325 | metadata.app_id = _validate( | ||
206 | 326 | metadata_repr, key='app_id', value_type=str, | ||
207 | 327 | value_none=True) | ||
208 | 328 | |||
209 | 329 | |||
210 | 330 | class SessionPeekHelper1(MetaDataHelper1MixIn): | ||
211 | 331 | """ | ||
212 | 332 | Helper class for implementing session peek feature | ||
213 | 333 | |||
214 | 334 | This class works with data constructed by | ||
215 | 335 | :class:`~plainbox.impl.session.suspend.SessionSuspendHelper1` which has | ||
216 | 336 | been pre-processed by :class:`SessionPeekHelper` (to strip the initial | ||
217 | 337 | envelope). | ||
218 | 338 | |||
219 | 339 | The only goal of this class is to reconstruct session state meta-data. | ||
220 | 340 | """ | ||
221 | 341 | |||
222 | 342 | def peek_json(self, json_repr): | ||
223 | 343 | """ | ||
224 | 344 | Resume a SessionState object from the JSON representation. | ||
225 | 345 | |||
226 | 346 | This method is called by :meth:`peek()` after the initial envelope and | ||
227 | 347 | parsing is done. The only error conditions that can happen are related | ||
228 | 348 | to semantic incompatibilities or corrupted internal state. | ||
229 | 349 | """ | ||
230 | 350 | _validate(json_repr, key="version", choice=[1]) | ||
231 | 351 | session_repr = _validate(json_repr, key='session', value_type=dict) | ||
232 | 352 | metadata = SessionMetaData() | ||
233 | 353 | self._restore_SessionState_metadata(metadata, session_repr) | ||
234 | 354 | return metadata | ||
235 | 355 | |||
236 | 356 | def _build_SessionState(self, session_repr, early_cb=None): | ||
237 | 357 | """ | ||
238 | 358 | Reconstruct the session state object. | ||
239 | 359 | |||
240 | 360 | This method creates a fresh SessionState instance and restores | ||
241 | 361 | jobs, results, meta-data and desired job list using helper methods. | ||
242 | 362 | """ | ||
243 | 363 | logger.debug(_("Starting to restore metadata...")) | ||
244 | 364 | metadata = SessionMetaData() | ||
245 | 365 | self._peek_SessionState_metadata(metadata, session_repr) | ||
246 | 366 | return metadata | ||
247 | 367 | |||
248 | 368 | |||
249 | 369 | class SessionPeekHelper2(MetaDataHelper2MixIn, SessionPeekHelper1): | ||
250 | 370 | """ | ||
251 | 371 | Helper class for implementing session peek feature | ||
252 | 372 | |||
253 | 373 | This class works with data constructed by | ||
254 | 374 | :class:`~plainbox.impl.session.suspend.SessionSuspendHelper1` which has | ||
255 | 375 | been pre-processed by :class:`SessionPeekHelper` (to strip the initial | ||
256 | 376 | envelope). | ||
257 | 377 | |||
258 | 378 | The only goal of this class is to reconstruct session state meta-data. | ||
259 | 379 | """ | ||
260 | 380 | |||
261 | 381 | |||
262 | 382 | class SessionPeekHelper3(MetaDataHelper3MixIn, SessionPeekHelper2): | ||
263 | 383 | """ | ||
264 | 384 | Helper class for implementing session peek feature | ||
265 | 385 | |||
266 | 386 | This class works with data constructed by | ||
267 | 387 | :class:`~plainbox.impl.session.suspend.SessionSuspendHelper1` which has | ||
268 | 388 | been pre-processed by :class:`SessionPeekHelper` (to strip the initial | ||
269 | 389 | envelope). | ||
270 | 390 | |||
271 | 391 | The only goal of this class is to reconstruct session state meta-data. | ||
272 | 392 | """ | ||
273 | 393 | |||
274 | 394 | |||
275 | 395 | class SessionResumeHelper1(MetaDataHelper1MixIn): | ||
276 | 199 | """ | 396 | """ |
277 | 200 | Helper class for implementing session resume feature | 397 | Helper class for implementing session resume feature |
278 | 201 | 398 | ||
279 | @@ -257,7 +454,8 @@ | |||
280 | 257 | _("Starting to restore jobs and results to %r..."), session) | 454 | _("Starting to restore jobs and results to %r..."), session) |
281 | 258 | self._restore_SessionState_jobs_and_results(session, session_repr) | 455 | self._restore_SessionState_jobs_and_results(session, session_repr) |
282 | 259 | logger.debug(_("Starting to restore metadata...")) | 456 | logger.debug(_("Starting to restore metadata...")) |
284 | 260 | self._restore_SessionState_metadata(session, session_repr) | 457 | self._restore_SessionState_metadata(session.metadata, session_repr) |
285 | 458 | logger.debug(_("restored metadata %r"), session.metadata) | ||
286 | 261 | logger.debug(_("Starting to restore desired job list...")) | 459 | logger.debug(_("Starting to restore desired job list...")) |
287 | 262 | self._restore_SessionState_desired_job_list(session, session_repr) | 460 | self._restore_SessionState_desired_job_list(session, session_repr) |
288 | 263 | logger.debug(_("Starting to restore job list...")) | 461 | logger.debug(_("Starting to restore job list...")) |
289 | @@ -372,29 +570,6 @@ | |||
290 | 372 | session.update_job_result(job, result_list[-1]) | 570 | session.update_job_result(job, result_list[-1]) |
291 | 373 | 571 | ||
292 | 374 | @classmethod | 572 | @classmethod |
293 | 375 | def _restore_SessionState_metadata(cls, session, session_repr): | ||
294 | 376 | """ | ||
295 | 377 | Extract meta-data information from the representation of the session | ||
296 | 378 | and set it in the given session object | ||
297 | 379 | """ | ||
298 | 380 | # Get the representation of the meta-data | ||
299 | 381 | metadata_repr = _validate( | ||
300 | 382 | session_repr, key='metadata', value_type=dict) | ||
301 | 383 | # Set each bit back to the session | ||
302 | 384 | session.metadata.title = _validate( | ||
303 | 385 | metadata_repr, key='title', value_type=str, value_none=True) | ||
304 | 386 | session.metadata.flags = set([ | ||
305 | 387 | _validate( | ||
306 | 388 | flag, value_type=str, | ||
307 | 389 | value_type_msg=_("Each flag must be a string")) | ||
308 | 390 | for flag in _validate( | ||
309 | 391 | metadata_repr, key='flags', value_type=list)]) | ||
310 | 392 | session.metadata.running_job_name = _validate( | ||
311 | 393 | metadata_repr, key='running_job_name', value_type=str, | ||
312 | 394 | value_none=True) | ||
313 | 395 | logger.debug(_("restored metadata %r"), session.metadata) | ||
314 | 396 | |||
315 | 397 | @classmethod | ||
316 | 398 | def _restore_SessionState_desired_job_list(cls, session, session_repr): | 573 | def _restore_SessionState_desired_job_list(cls, session, session_repr): |
317 | 399 | """ | 574 | """ |
318 | 400 | Extract the representation of desired_job_list from the session and | 575 | Extract the representation of desired_job_list from the session and |
319 | @@ -512,6 +687,44 @@ | |||
320 | 512 | return IOLogRecord(delay, stream_name, data) | 687 | return IOLogRecord(delay, stream_name, data) |
321 | 513 | 688 | ||
322 | 514 | 689 | ||
323 | 690 | class SessionResumeHelper2(MetaDataHelper2MixIn, SessionResumeHelper1): | ||
324 | 691 | """ | ||
325 | 692 | Helper class for implementing session resume feature | ||
326 | 693 | |||
327 | 694 | This class works with data constructed by | ||
328 | 695 | :class:`~plainbox.impl.session.suspend.SessionSuspendHelper2` which has | ||
329 | 696 | been pre-processed by :class:`SessionResumeHelper` (to strip the initial | ||
330 | 697 | envelope). | ||
331 | 698 | |||
332 | 699 | Due to the constraints of what can be represented in a suspended session, | ||
333 | 700 | this class cannot work in isolation. It must operate with a list of know | ||
334 | 701 | jobs. | ||
335 | 702 | |||
336 | 703 | Since (most of the) jobs are being provided externally (as they represent | ||
337 | 704 | the non-serialized parts of checkbox or other job providers) several | ||
338 | 705 | failure modes are possible. Those are documented in :meth:`resume()` | ||
339 | 706 | """ | ||
340 | 707 | |||
341 | 708 | |||
342 | 709 | class SessionResumeHelper3(MetaDataHelper3MixIn, SessionResumeHelper2): | ||
343 | 710 | """ | ||
344 | 711 | Helper class for implementing session resume feature | ||
345 | 712 | |||
346 | 713 | This class works with data constructed by | ||
347 | 714 | :class:`~plainbox.impl.session.suspend.SessionSuspendHelper3` which has | ||
348 | 715 | been pre-processed by :class:`SessionResumeHelper` (to strip the initial | ||
349 | 716 | envelope). | ||
350 | 717 | |||
351 | 718 | Due to the constraints of what can be represented in a suspended session, | ||
352 | 719 | this class cannot work in isolation. It must operate with a list of know | ||
353 | 720 | jobs. | ||
354 | 721 | |||
355 | 722 | Since (most of the) jobs are being provided externally (as they represent | ||
356 | 723 | the non-serialized parts of checkbox or other job providers) several | ||
357 | 724 | failure modes are possible. Those are documented in :meth:`resume()` | ||
358 | 725 | """ | ||
359 | 726 | |||
360 | 727 | |||
361 | 515 | def _validate(obj, **flags): | 728 | def _validate(obj, **flags): |
362 | 516 | """ | 729 | """ |
363 | 517 | Multi-purpose extraction and validation function. | 730 | Multi-purpose extraction and validation function. |
364 | @@ -556,120 +769,3 @@ | |||
365 | 556 | obj_name, value_choice)) | 769 | obj_name, value_choice)) |
366 | 557 | raise CorruptedSessionError(error_msg) | 770 | raise CorruptedSessionError(error_msg) |
367 | 558 | return value | 771 | return value |
368 | 559 | |||
369 | 560 | |||
370 | 561 | class SessionResumeHelper2(SessionResumeHelper1): | ||
371 | 562 | """ | ||
372 | 563 | Helper class for implementing session resume feature | ||
373 | 564 | |||
374 | 565 | This class works with data constructed by | ||
375 | 566 | :class:`~plainbox.impl.session.suspend.SessionSuspendHelper2` which has | ||
376 | 567 | been pre-processed by :class:`SessionResumeHelper` (to strip the initial | ||
377 | 568 | envelope). | ||
378 | 569 | |||
379 | 570 | Due to the constraints of what can be represented in a suspended session, | ||
380 | 571 | this class cannot work in isolation. It must operate with a list of know | ||
381 | 572 | jobs. | ||
382 | 573 | |||
383 | 574 | Since (most of the) jobs are being provided externally (as they represent | ||
384 | 575 | the non-serialized parts of checkbox or other job providers) several | ||
385 | 576 | failure modes are possible. Those are documented in :meth:`resume()` | ||
386 | 577 | """ | ||
387 | 578 | |||
388 | 579 | @classmethod | ||
389 | 580 | def _restore_SessionState_metadata(cls, session, session_repr): | ||
390 | 581 | """ | ||
391 | 582 | Extract meta-data information from the representation of the session | ||
392 | 583 | and set it in the given session object | ||
393 | 584 | """ | ||
394 | 585 | # Get the representation of the meta-data | ||
395 | 586 | metadata_repr = _validate( | ||
396 | 587 | session_repr, key='metadata', value_type=dict) | ||
397 | 588 | # Set each bit back to the session | ||
398 | 589 | session.metadata.title = _validate( | ||
399 | 590 | metadata_repr, key='title', value_type=str, value_none=True) | ||
400 | 591 | session.metadata.flags = set([ | ||
401 | 592 | _validate( | ||
402 | 593 | flag, value_type=str, | ||
403 | 594 | value_type_msg=_("Each flag must be a string")) | ||
404 | 595 | for flag in _validate( | ||
405 | 596 | metadata_repr, key='flags', value_type=list)]) | ||
406 | 597 | session.metadata.running_job_name = _validate( | ||
407 | 598 | metadata_repr, key='running_job_name', value_type=str, | ||
408 | 599 | value_none=True) | ||
409 | 600 | app_blob = _validate( | ||
410 | 601 | metadata_repr, key='app_blob', value_type=str, | ||
411 | 602 | value_none=True) | ||
412 | 603 | if app_blob is not None: | ||
413 | 604 | try: | ||
414 | 605 | app_blob = app_blob.encode("ASCII") | ||
415 | 606 | except UnicodeEncodeError: | ||
416 | 607 | # TRANSLATORS: please don't translate app_blob | ||
417 | 608 | raise CorruptedSessionError(_("app_blob is not ASCII")) | ||
418 | 609 | try: | ||
419 | 610 | app_blob = base64.standard_b64decode(app_blob) | ||
420 | 611 | except binascii.Error: | ||
421 | 612 | # TRANSLATORS: please don't translate app_blob | ||
422 | 613 | raise CorruptedSessionError(_("Cannot base64 decode app_blob")) | ||
423 | 614 | session.metadata.app_blob = app_blob | ||
424 | 615 | logger.debug(_("restored metadata %r"), session.metadata) | ||
425 | 616 | |||
426 | 617 | |||
427 | 618 | class SessionResumeHelper3(SessionResumeHelper2): | ||
428 | 619 | """ | ||
429 | 620 | Helper class for implementing session resume feature | ||
430 | 621 | |||
431 | 622 | This class works with data constructed by | ||
432 | 623 | :class:`~plainbox.impl.session.suspend.SessionSuspendHelper3` which has | ||
433 | 624 | been pre-processed by :class:`SessionResumeHelper` (to strip the initial | ||
434 | 625 | envelope). | ||
435 | 626 | |||
436 | 627 | Due to the constraints of what can be represented in a suspended session, | ||
437 | 628 | this class cannot work in isolation. It must operate with a list of know | ||
438 | 629 | jobs. | ||
439 | 630 | |||
440 | 631 | Since (most of the) jobs are being provided externally (as they represent | ||
441 | 632 | the non-serialized parts of checkbox or other job providers) several | ||
442 | 633 | failure modes are possible. Those are documented in :meth:`resume()` | ||
443 | 634 | """ | ||
444 | 635 | |||
445 | 636 | @classmethod | ||
446 | 637 | def _restore_SessionState_metadata(cls, session, session_repr): | ||
447 | 638 | """ | ||
448 | 639 | Extract meta-data information from the representation of the session | ||
449 | 640 | and set it in the given session object | ||
450 | 641 | """ | ||
451 | 642 | # Get the representation of the meta-data | ||
452 | 643 | metadata_repr = _validate( | ||
453 | 644 | session_repr, key='metadata', value_type=dict) | ||
454 | 645 | # Set each bit back to the session | ||
455 | 646 | session.metadata.title = _validate( | ||
456 | 647 | metadata_repr, key='title', value_type=str, value_none=True) | ||
457 | 648 | session.metadata.flags = set([ | ||
458 | 649 | _validate( | ||
459 | 650 | flag, value_type=str, | ||
460 | 651 | value_type_msg=_("Each flag must be a string")) | ||
461 | 652 | for flag in _validate( | ||
462 | 653 | metadata_repr, key='flags', value_type=list)]) | ||
463 | 654 | session.metadata.running_job_name = _validate( | ||
464 | 655 | metadata_repr, key='running_job_name', value_type=str, | ||
465 | 656 | value_none=True) | ||
466 | 657 | app_blob = _validate( | ||
467 | 658 | metadata_repr, key='app_blob', value_type=str, | ||
468 | 659 | value_none=True) | ||
469 | 660 | if app_blob is not None: | ||
470 | 661 | try: | ||
471 | 662 | app_blob = app_blob.encode("ASCII") | ||
472 | 663 | except UnicodeEncodeError: | ||
473 | 664 | # TRANSLATORS: please don't translate app_blob | ||
474 | 665 | raise CorruptedSessionError(_("app_blob is not ASCII")) | ||
475 | 666 | try: | ||
476 | 667 | app_blob = base64.standard_b64decode(app_blob) | ||
477 | 668 | except binascii.Error: | ||
478 | 669 | # TRANSLATORS: please don't translate app_blob | ||
479 | 670 | raise CorruptedSessionError(_("Cannot base64 decode app_blob")) | ||
480 | 671 | session.metadata.app_blob = app_blob | ||
481 | 672 | session.metadata.app_id = _validate( | ||
482 | 673 | metadata_repr, key='app_id', value_type=str, | ||
483 | 674 | value_none=True) | ||
484 | 675 | logger.debug(_("restored metadata %r"), session.metadata) | ||
485 | 676 | 772 | ||
486 | === modified file 'plainbox/plainbox/impl/session/storage.py' | |||
487 | --- plainbox/plainbox/impl/session/storage.py 2014-02-20 21:43:55 +0000 | |||
488 | +++ plainbox/plainbox/impl/session/storage.py 2014-04-30 19:25:48 +0000 | |||
489 | @@ -206,6 +206,13 @@ | |||
490 | 206 | return self._location | 206 | return self._location |
491 | 207 | 207 | ||
492 | 208 | @property | 208 | @property |
493 | 209 | def id(self): | ||
494 | 210 | """ | ||
495 | 211 | identifier of the session storage (name of the random directory) | ||
496 | 212 | """ | ||
497 | 213 | return os.path.splitext(os.path.basename(self.location))[0] | ||
498 | 214 | |||
499 | 215 | @property | ||
500 | 209 | def session_file(self): | 216 | def session_file(self): |
501 | 210 | """ | 217 | """ |
502 | 211 | pathname of the session state file | 218 | pathname of the session state file |
503 | @@ -404,6 +411,11 @@ | |||
504 | 404 | # Close the session file | 411 | # Close the session file |
505 | 405 | logger.debug(_("Closed descriptor %d"), session_fd) | 412 | logger.debug(_("Closed descriptor %d"), session_fd) |
506 | 406 | os.close(session_fd) | 413 | os.close(session_fd) |
507 | 414 | except IOError as exc: | ||
508 | 415 | if exc.errno == errno.ENOENT: | ||
509 | 416 | # Treat lack of 'session' file as an empty file | ||
510 | 417 | return b'' | ||
511 | 418 | raise | ||
512 | 407 | finally: | 419 | finally: |
513 | 408 | # Close the location directory | 420 | # Close the location directory |
514 | 409 | logger.debug(_("Closed descriptor %d"), location_fd) | 421 | logger.debug(_("Closed descriptor %d"), location_fd) |
515 | @@ -427,6 +439,11 @@ | |||
516 | 427 | finally: | 439 | finally: |
517 | 428 | # Close the session file | 440 | # Close the session file |
518 | 429 | os.close(session_fd) | 441 | os.close(session_fd) |
519 | 442 | except IOError as exc: | ||
520 | 443 | if exc.errno == errno.ENOENT: | ||
521 | 444 | # Treat lack of 'session' file as an empty file | ||
522 | 445 | return b'' | ||
523 | 446 | raise | ||
524 | 430 | finally: | 447 | finally: |
525 | 431 | # Close the location directory | 448 | # Close the location directory |
526 | 432 | os.close(location_fd) | 449 | os.close(location_fd) |
527 | 433 | 450 | ||
528 | === modified file 'plainbox/plainbox/impl/session/test_resume.py' | |||
529 | --- plainbox/plainbox/impl/session/test_resume.py 2014-03-26 20:01:03 +0000 | |||
530 | +++ plainbox/plainbox/impl/session/test_resume.py 2014-04-30 19:25:48 +0000 | |||
531 | @@ -957,7 +957,7 @@ | |||
532 | 957 | """ | 957 | """ |
533 | 958 | with self.assertRaises(CorruptedSessionError) as boom: | 958 | with self.assertRaises(CorruptedSessionError) as boom: |
534 | 959 | self.good_repr['metadata'] = 1 | 959 | self.good_repr['metadata'] = 1 |
536 | 960 | self.resume_fn(self.session, self.good_repr) | 960 | self.resume_fn(self.session.metadata, self.good_repr) |
537 | 961 | self.assertEqual( | 961 | self.assertEqual( |
538 | 962 | str(boom.exception), | 962 | str(boom.exception), |
539 | 963 | "Value of key 'metadata' is of incorrect type int") | 963 | "Value of key 'metadata' is of incorrect type int") |
540 | @@ -969,7 +969,7 @@ | |||
541 | 969 | """ | 969 | """ |
542 | 970 | with self.assertRaises(CorruptedSessionError) as boom: | 970 | with self.assertRaises(CorruptedSessionError) as boom: |
543 | 971 | self.good_repr['metadata']['title'] = 1 | 971 | self.good_repr['metadata']['title'] = 1 |
545 | 972 | self.resume_fn(self.session, self.good_repr) | 972 | self.resume_fn(self.session.metadata, self.good_repr) |
546 | 973 | self.assertEqual( | 973 | self.assertEqual( |
547 | 974 | str(boom.exception), | 974 | str(boom.exception), |
548 | 975 | "Value of key 'title' is of incorrect type int") | 975 | "Value of key 'title' is of incorrect type int") |
549 | @@ -980,7 +980,7 @@ | |||
550 | 980 | ``title`` to be None | 980 | ``title`` to be None |
551 | 981 | """ | 981 | """ |
552 | 982 | self.good_repr['metadata']['title'] = None | 982 | self.good_repr['metadata']['title'] = None |
554 | 983 | self.resume_fn(self.session, self.good_repr) | 983 | self.resume_fn(self.session.metadata, self.good_repr) |
555 | 984 | self.assertEqual(self.session.metadata.title, None) | 984 | self.assertEqual(self.session.metadata.title, None) |
556 | 985 | 985 | ||
557 | 986 | def test_restore_SessionState_metadata_restores_title(self): | 986 | def test_restore_SessionState_metadata_restores_title(self): |
558 | @@ -988,7 +988,7 @@ | |||
559 | 988 | verify that _restore_SessionState_metadata() restores ``title`` | 988 | verify that _restore_SessionState_metadata() restores ``title`` |
560 | 989 | """ | 989 | """ |
561 | 990 | self.good_repr['metadata']['title'] = "a title" | 990 | self.good_repr['metadata']['title'] = "a title" |
563 | 991 | self.resume_fn(self.session, self.good_repr) | 991 | self.resume_fn(self.session.metadata, self.good_repr) |
564 | 992 | self.assertEqual(self.session.metadata.title, "a title") | 992 | self.assertEqual(self.session.metadata.title, "a title") |
565 | 993 | 993 | ||
566 | 994 | def test_restore_SessionState_metadata_checks_flags_type(self): | 994 | def test_restore_SessionState_metadata_checks_flags_type(self): |
567 | @@ -998,7 +998,7 @@ | |||
568 | 998 | """ | 998 | """ |
569 | 999 | with self.assertRaises(CorruptedSessionError) as boom: | 999 | with self.assertRaises(CorruptedSessionError) as boom: |
570 | 1000 | self.good_repr['metadata']['flags'] = 1 | 1000 | self.good_repr['metadata']['flags'] = 1 |
572 | 1001 | self.resume_fn(self.session, self.good_repr) | 1001 | self.resume_fn(self.session.metadata, self.good_repr) |
573 | 1002 | self.assertEqual( | 1002 | self.assertEqual( |
574 | 1003 | str(boom.exception), | 1003 | str(boom.exception), |
575 | 1004 | "Value of key 'flags' is of incorrect type int") | 1004 | "Value of key 'flags' is of incorrect type int") |
576 | @@ -1010,7 +1010,7 @@ | |||
577 | 1010 | """ | 1010 | """ |
578 | 1011 | with self.assertRaises(CorruptedSessionError) as boom: | 1011 | with self.assertRaises(CorruptedSessionError) as boom: |
579 | 1012 | self.good_repr['metadata']['flags'] = None | 1012 | self.good_repr['metadata']['flags'] = None |
581 | 1013 | self.resume_fn(self.session, self.good_repr) | 1013 | self.resume_fn(self.session.metadata, self.good_repr) |
582 | 1014 | self.assertEqual( | 1014 | self.assertEqual( |
583 | 1015 | str(boom.exception), | 1015 | str(boom.exception), |
584 | 1016 | "Value of key 'flags' cannot be None") | 1016 | "Value of key 'flags' cannot be None") |
585 | @@ -1022,7 +1022,7 @@ | |||
586 | 1022 | """ | 1022 | """ |
587 | 1023 | with self.assertRaises(CorruptedSessionError) as boom: | 1023 | with self.assertRaises(CorruptedSessionError) as boom: |
588 | 1024 | self.good_repr['metadata']['flags'] = [1] | 1024 | self.good_repr['metadata']['flags'] = [1] |
590 | 1025 | self.resume_fn(self.session, self.good_repr) | 1025 | self.resume_fn(self.session.metadata, self.good_repr) |
591 | 1026 | self.assertEqual( | 1026 | self.assertEqual( |
592 | 1027 | str(boom.exception), | 1027 | str(boom.exception), |
593 | 1028 | "Each flag must be a string") | 1028 | "Each flag must be a string") |
594 | @@ -1032,7 +1032,7 @@ | |||
595 | 1032 | verify that _restore_SessionState_metadata() restores ``flags`` | 1032 | verify that _restore_SessionState_metadata() restores ``flags`` |
596 | 1033 | """ | 1033 | """ |
597 | 1034 | self.good_repr['metadata']['flags'] = ["flag1", "flag2"] | 1034 | self.good_repr['metadata']['flags'] = ["flag1", "flag2"] |
599 | 1035 | self.resume_fn(self.session, self.good_repr) | 1035 | self.resume_fn(self.session.metadata, self.good_repr) |
600 | 1036 | self.assertEqual(self.session.metadata.flags, set(['flag1', 'flag2'])) | 1036 | self.assertEqual(self.session.metadata.flags, set(['flag1', 'flag2'])) |
601 | 1037 | 1037 | ||
602 | 1038 | def test_restore_SessionState_metadata_checks_running_job_name_type(self): | 1038 | def test_restore_SessionState_metadata_checks_running_job_name_type(self): |
603 | @@ -1042,7 +1042,7 @@ | |||
604 | 1042 | """ | 1042 | """ |
605 | 1043 | with self.assertRaises(CorruptedSessionError) as boom: | 1043 | with self.assertRaises(CorruptedSessionError) as boom: |
606 | 1044 | self.good_repr['metadata']['running_job_name'] = 1 | 1044 | self.good_repr['metadata']['running_job_name'] = 1 |
608 | 1045 | self.resume_fn(self.session, self.good_repr) | 1045 | self.resume_fn(self.session.metadata, self.good_repr) |
609 | 1046 | self.assertEqual( | 1046 | self.assertEqual( |
610 | 1047 | str(boom.exception), | 1047 | str(boom.exception), |
611 | 1048 | "Value of key 'running_job_name' is of incorrect type int") | 1048 | "Value of key 'running_job_name' is of incorrect type int") |
612 | @@ -1053,7 +1053,7 @@ | |||
613 | 1053 | ``running_job_name`` to be None | 1053 | ``running_job_name`` to be None |
614 | 1054 | """ | 1054 | """ |
615 | 1055 | self.good_repr['metadata']['running_job_name'] = None | 1055 | self.good_repr['metadata']['running_job_name'] = None |
617 | 1056 | self.resume_fn(self.session, self.good_repr) | 1056 | self.resume_fn(self.session.metadata, self.good_repr) |
618 | 1057 | self.assertEqual(self.session.metadata.running_job_name, None) | 1057 | self.assertEqual(self.session.metadata.running_job_name, None) |
619 | 1058 | 1058 | ||
620 | 1059 | def test_restore_SessionState_metadata_restores_running_job_name(self): | 1059 | def test_restore_SessionState_metadata_restores_running_job_name(self): |
621 | @@ -1062,7 +1062,7 @@ | |||
622 | 1062 | the value of ``running_job_name`` | 1062 | the value of ``running_job_name`` |
623 | 1063 | """ | 1063 | """ |
624 | 1064 | self.good_repr['metadata']['running_job_name'] = "a job" | 1064 | self.good_repr['metadata']['running_job_name'] = "a job" |
626 | 1065 | self.resume_fn(self.session, self.good_repr) | 1065 | self.resume_fn(self.session.metadata, self.good_repr) |
627 | 1066 | self.assertEqual(self.session.metadata.running_job_name, "a job") | 1066 | self.assertEqual(self.session.metadata.running_job_name, "a job") |
628 | 1067 | 1067 | ||
629 | 1068 | 1068 | ||
630 | @@ -1093,7 +1093,7 @@ | |||
631 | 1093 | with self.assertRaises(CorruptedSessionError) as boom: | 1093 | with self.assertRaises(CorruptedSessionError) as boom: |
632 | 1094 | obj_repr = copy.copy(self.good_repr) | 1094 | obj_repr = copy.copy(self.good_repr) |
633 | 1095 | obj_repr['metadata']['app_blob'] = 1 | 1095 | obj_repr['metadata']['app_blob'] = 1 |
635 | 1096 | self.resume_fn(self.session, obj_repr) | 1096 | self.resume_fn(self.session.metadata, obj_repr) |
636 | 1097 | self.assertEqual( | 1097 | self.assertEqual( |
637 | 1098 | str(boom.exception), | 1098 | str(boom.exception), |
638 | 1099 | "Value of key 'app_blob' is of incorrect type int") | 1099 | "Value of key 'app_blob' is of incorrect type int") |
639 | @@ -1105,7 +1105,7 @@ | |||
640 | 1105 | """ | 1105 | """ |
641 | 1106 | obj_repr = copy.copy(self.good_repr) | 1106 | obj_repr = copy.copy(self.good_repr) |
642 | 1107 | obj_repr['metadata']['app_blob'] = None | 1107 | obj_repr['metadata']['app_blob'] = None |
644 | 1108 | self.resume_fn(self.session, obj_repr) | 1108 | self.resume_fn(self.session.metadata, obj_repr) |
645 | 1109 | self.assertEqual(self.session.metadata.app_blob, None) | 1109 | self.assertEqual(self.session.metadata.app_blob, None) |
646 | 1110 | 1110 | ||
647 | 1111 | def test_restore_SessionState_metadata_restores_app_blob(self): | 1111 | def test_restore_SessionState_metadata_restores_app_blob(self): |
648 | @@ -1114,7 +1114,7 @@ | |||
649 | 1114 | """ | 1114 | """ |
650 | 1115 | obj_repr = copy.copy(self.good_repr) | 1115 | obj_repr = copy.copy(self.good_repr) |
651 | 1116 | obj_repr['metadata']['app_blob'] = "YmxvYg==" | 1116 | obj_repr['metadata']['app_blob'] = "YmxvYg==" |
653 | 1117 | self.resume_fn(self.session, obj_repr) | 1117 | self.resume_fn(self.session.metadata, obj_repr) |
654 | 1118 | self.assertEqual(self.session.metadata.app_blob, b"blob") | 1118 | self.assertEqual(self.session.metadata.app_blob, b"blob") |
655 | 1119 | 1119 | ||
656 | 1120 | def test_restore_SessionState_metadata_non_ascii_app_blob(self): | 1120 | def test_restore_SessionState_metadata_non_ascii_app_blob(self): |
657 | @@ -1125,7 +1125,7 @@ | |||
658 | 1125 | with self.assertRaises(CorruptedSessionError) as boom: | 1125 | with self.assertRaises(CorruptedSessionError) as boom: |
659 | 1126 | obj_repr = copy.copy(self.good_repr) | 1126 | obj_repr = copy.copy(self.good_repr) |
660 | 1127 | obj_repr['metadata']['app_blob'] = '\uFFFD' | 1127 | obj_repr['metadata']['app_blob'] = '\uFFFD' |
662 | 1128 | self.resume_fn(self.session, obj_repr) | 1128 | self.resume_fn(self.session.metadata, obj_repr) |
663 | 1129 | self.assertEqual(str(boom.exception), "app_blob is not ASCII") | 1129 | self.assertEqual(str(boom.exception), "app_blob is not ASCII") |
664 | 1130 | self.assertIsInstance(boom.exception.__context__, UnicodeEncodeError) | 1130 | self.assertIsInstance(boom.exception.__context__, UnicodeEncodeError) |
665 | 1131 | 1131 | ||
666 | @@ -1137,7 +1137,7 @@ | |||
667 | 1137 | with self.assertRaises(CorruptedSessionError) as boom: | 1137 | with self.assertRaises(CorruptedSessionError) as boom: |
668 | 1138 | obj_repr = copy.copy(self.good_repr) | 1138 | obj_repr = copy.copy(self.good_repr) |
669 | 1139 | obj_repr['metadata']['app_blob'] = '==broken' | 1139 | obj_repr['metadata']['app_blob'] = '==broken' |
671 | 1140 | self.resume_fn(self.session, obj_repr) | 1140 | self.resume_fn(self.session.metadata, obj_repr) |
672 | 1141 | self.assertEqual(str(boom.exception), "Cannot base64 decode app_blob") | 1141 | self.assertEqual(str(boom.exception), "Cannot base64 decode app_blob") |
673 | 1142 | # base64.standard_b64decode() raises binascii.Error | 1142 | # base64.standard_b64decode() raises binascii.Error |
674 | 1143 | self.assertIsInstance(boom.exception.__context__, binascii.Error) | 1143 | self.assertIsInstance(boom.exception.__context__, binascii.Error) |
675 | @@ -1171,7 +1171,7 @@ | |||
676 | 1171 | with self.assertRaises(CorruptedSessionError) as boom: | 1171 | with self.assertRaises(CorruptedSessionError) as boom: |
677 | 1172 | obj_repr = copy.copy(self.good_repr) | 1172 | obj_repr = copy.copy(self.good_repr) |
678 | 1173 | obj_repr['metadata']['app_id'] = 1 | 1173 | obj_repr['metadata']['app_id'] = 1 |
680 | 1174 | self.resume_fn(self.session, obj_repr) | 1174 | self.resume_fn(self.session.metadata, obj_repr) |
681 | 1175 | self.assertEqual( | 1175 | self.assertEqual( |
682 | 1176 | str(boom.exception), | 1176 | str(boom.exception), |
683 | 1177 | "Value of key 'app_id' is of incorrect type int") | 1177 | "Value of key 'app_id' is of incorrect type int") |
684 | @@ -1183,7 +1183,7 @@ | |||
685 | 1183 | """ | 1183 | """ |
686 | 1184 | obj_repr = copy.copy(self.good_repr) | 1184 | obj_repr = copy.copy(self.good_repr) |
687 | 1185 | obj_repr['metadata']['app_id'] = None | 1185 | obj_repr['metadata']['app_id'] = None |
689 | 1186 | self.resume_fn(self.session, obj_repr) | 1186 | self.resume_fn(self.session.metadata, obj_repr) |
690 | 1187 | self.assertEqual(self.session.metadata.app_id, None) | 1187 | self.assertEqual(self.session.metadata.app_id, None) |
691 | 1188 | 1188 | ||
692 | 1189 | def test_restore_SessionState_metadata_restores_app_id(self): | 1189 | def test_restore_SessionState_metadata_restores_app_id(self): |
693 | @@ -1192,7 +1192,7 @@ | |||
694 | 1192 | """ | 1192 | """ |
695 | 1193 | obj_repr = copy.copy(self.good_repr) | 1193 | obj_repr = copy.copy(self.good_repr) |
696 | 1194 | obj_repr['metadata']['app_id'] = "id" | 1194 | obj_repr['metadata']['app_id'] = "id" |
698 | 1195 | self.resume_fn(self.session, obj_repr) | 1195 | self.resume_fn(self.session.metadata, obj_repr) |
699 | 1196 | self.assertEqual(self.session.metadata.app_id, "id") | 1196 | self.assertEqual(self.session.metadata.app_id, "id") |
700 | 1197 | 1197 | ||
701 | 1198 | 1198 |
Very cool :) the refactoring part looks OK and the peek stuff is a clever use of session metadata (I guess as the code says, if we restored the session to look at the metadata it could possibly barf). THanks!