Statistics
| Branch: | Tag: | Revision:

root / snf-tools / synnefo_tools / burnin / common.py @ cb9edcdf

History | View | Annotate | Download (23 kB)

1
# Copyright 2013 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or
4
# without modification, are permitted provided that the following
5
# conditions are met:
6
#
7
#   1. Redistributions of source code must retain the above
8
#      copyright notice, this list of conditions and the following
9
#      disclaimer.
10
#
11
#   2. Redistributions in binary form must reproduce the above
12
#      copyright notice, this list of conditions and the following
13
#      disclaimer in the documentation and/or other materials
14
#      provided with the distribution.
15
#
16
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
# POSSIBILITY OF SUCH DAMAGE.
28
#
29
# The views and conclusions contained in the software and
30
# documentation are those of the authors and should not be
31
# interpreted as representing official policies, either expressed
32
# or implied, of GRNET S.A.
33

    
34
"""
35
Common utils for burnin tests
36

37
"""
38

    
39
import re
40
import shutil
41
import unittest
42
import datetime
43
import tempfile
44
import traceback
45

    
46
from kamaki.clients.cyclades import CycladesClient, CycladesNetworkClient
47
from kamaki.clients.astakos import CachedAstakosClient
48
from kamaki.clients.compute import ComputeClient
49
from kamaki.clients.pithos import PithosClient
50
from kamaki.clients.image import ImageClient
51

    
52
from synnefo_tools.burnin.logger import Log
53

    
54

    
55
# --------------------------------------------------------------------
56
# Global variables
57
logger = None   # Invalid constant name. pylint: disable-msg=C0103
58
success = None  # Invalid constant name. pylint: disable-msg=C0103
59
SNF_TEST_PREFIX = "snf-test-"
60
CONNECTION_RETRY_LIMIT = 2
61
SYSTEM_USERS = ["images@okeanos.grnet.gr", "images@demo.synnefo.org"]
62
KB = 2**10
63
MB = 2**20
64
GB = 2**30
65

    
66

    
67
# --------------------------------------------------------------------
68
# BurninTestResult class
69
class BurninTestResult(unittest.TestResult):
70
    """Modify the TextTestResult class"""
71
    def __init__(self):
72
        super(BurninTestResult, self).__init__()
73

    
74
        # Test parameters
75
        self.failfast = True
76

    
77
    def startTest(self, test):  # noqa
78
        """Called when the test case test is about to be run"""
79
        super(BurninTestResult, self).startTest(test)
80
        logger.log(test.__class__.__name__, test.shortDescription())
81

    
82
    # Method could be a function. pylint: disable-msg=R0201
83
    def _test_failed(self, test, err):
84
        """Test failed"""
85
        # Get class name
86
        if test.__class__.__name__ == "_ErrorHolder":
87
            class_name = test.id().split('.')[-1].rstrip(')')
88
        else:
89
            class_name = test.__class__.__name__
90
        err_msg = str(test) + "... failed (%s)."
91
        timestamp = datetime.datetime.strftime(
92
            datetime.datetime.now(), "%a %b %d %Y %H:%M:%S")
93
        logger.error(class_name, err_msg, timestamp)
94
        (err_type, err_value, err_trace) = err
95
        trcback = traceback.format_exception(err_type, err_value, err_trace)
96
        logger.info(class_name, trcback)
97

    
98
    def addError(self, test, err):  # noqa
99
        """Called when the test case test raises an unexpected exception"""
100
        super(BurninTestResult, self).addError(test, err)
101
        self._test_failed(test, err)
102

    
103
    def addFailure(self, test, err):  # noqa
104
        """Called when the test case test signals a failure"""
105
        super(BurninTestResult, self).addFailure(test, err)
106
        self._test_failed(test, err)
107

    
108

    
109
# --------------------------------------------------------------------
110
# Helper Classes
111
# Too few public methods. pylint: disable-msg=R0903
112
# Too many instance attributes. pylint: disable-msg=R0902
113
class Clients(object):
114
    """Our kamaki clients"""
115
    auth_url = None
116
    token = None
117
    # Astakos
118
    astakos = None
119
    retry = CONNECTION_RETRY_LIMIT
120
    # Compute
121
    compute = None
122
    compute_url = None
123
    # Cyclades
124
    cyclades = None
125
    # Network
126
    network = None
127
    network_url = None
128
    # Pithos
129
    pithos = None
130
    pithos_url = None
131
    # Image
132
    image = None
133
    image_url = None
134

    
135
    def initialize_clients(self):
136
        """Initialize all the Kamaki Clients"""
137
        self.astakos = CachedAstakosClient(self.auth_url, self.token)
138
        self.astakos.CONNECTION_RETRY_LIMIT = self.retry
139

    
140
        self.compute_url = \
141
            self.astakos.get_service_endpoints('compute')['publicURL']
142
        self.compute = ComputeClient(self.compute_url, self.token)
143
        self.compute.CONNECTION_RETRY_LIMIT = self.retry
144

    
145
        self.cyclades = CycladesClient(self.compute_url, self.token)
146
        self.cyclades.CONNECTION_RETRY_LIMIT = self.retry
147

    
148
        self.network_url = \
149
            self.astakos.get_service_endpoints('network')['publicURL']
150
        self.network = CycladesNetworkClient(self.network_url, self.token)
151
        self.network.CONNECTION_RETRY_LIMIT = self.retry
152

    
153
        self.pithos_url = self.astakos.\
154
            get_service_endpoints('object-store')['publicURL']
155
        self.pithos = PithosClient(self.pithos_url, self.token)
156
        self.pithos.CONNECTION_RETRY_LIMIT = self.retry
157

    
158
        self.image_url = \
159
            self.astakos.get_service_endpoints('image')['publicURL']
160
        self.image = ImageClient(self.image_url, self.token)
161
        self.image.CONNECTION_RETRY_LIMIT = self.retry
162

    
163

    
164
class Proper(object):
165
    """A descriptor used by tests implementing the TestCase class
166

167
    Since each instance of the TestCase will only be used to run a single
168
    test method (a new fixture is created for each test) the attributes can
169
    not be saved in the class instances. Instead we use descriptors.
170

171
    """
172
    def __init__(self, value=None):
173
        self.val = value
174

    
175
    def __get__(self, obj, objtype=None):
176
        return self.val
177

    
178
    def __set__(self, obj, value):
179
        self.val = value
180

    
181

    
182
# --------------------------------------------------------------------
183
# BurninTests class
184
# Too many public methods (45/20). pylint: disable-msg=R0904
185
class BurninTests(unittest.TestCase):
186
    """Common class that all burnin tests should implement"""
187
    clients = Clients()
188
    run_id = None
189
    use_ipv6 = None
190
    action_timeout = None
191
    action_warning = None
192
    query_interval = None
193
    system_user = None
194
    images = None
195
    flavors = None
196
    delete_stale = False
197
    temp_directory = None
198
    failfast = None
199

    
200
    quotas = Proper(value=None)
201

    
202
    @classmethod
203
    def setUpClass(cls):  # noqa
204
        """Initialize BurninTests"""
205
        cls.suite_name = cls.__name__
206
        logger.testsuite_start(cls.suite_name)
207

    
208
        # Set test parameters
209
        cls.longMessage = True
210

    
211
    def test_000_clients_setup(self):
212
        """Initializing astakos/cyclades/pithos clients"""
213
        # Update class attributes
214
        self.clients.initialize_clients()
215
        self.info("Astakos auth url is %s", self.clients.auth_url)
216
        self.info("Cyclades url is %s", self.clients.compute_url)
217
        self.info("Network url is %s", self.clients.network_url)
218
        self.info("Pithos url is %s", self.clients.pithos_url)
219
        self.info("Image url is %s", self.clients.image_url)
220

    
221
        self.quotas = self._get_quotas()
222
        self.info("  Disk usage is %s bytes",
223
                  self.quotas['system']['cyclades.disk']['usage'])
224
        self.info("  VM usage is %s",
225
                  self.quotas['system']['cyclades.vm']['usage'])
226
        self.info("  DiskSpace usage is %s bytes",
227
                  self.quotas['system']['pithos.diskspace']['usage'])
228
        self.info("  Ram usage is %s bytes",
229
                  self.quotas['system']['cyclades.ram']['usage'])
230
        self.info("  Floating IPs usage is %s",
231
                  self.quotas['system']['cyclades.floating_ip']['usage'])
232
        self.info("  CPU usage is %s",
233
                  self.quotas['system']['cyclades.cpu']['usage'])
234
        self.info("  Network usage is %s",
235
                  self.quotas['system']['cyclades.network.private']['usage'])
236

    
237
    def _run_tests(self, tcases):
238
        """Run some generated testcases"""
239
        global success  # Using global. pylint: disable-msg=C0103,W0603,W0602
240

    
241
        for tcase in tcases:
242
            self.info("Running testsuite %s", tcase.__name__)
243
            success = run_test(tcase) and success
244
            if self.failfast and not success:
245
                break
246

    
247
    # ----------------------------------
248
    # Loggers helper functions
249
    def log(self, msg, *args):
250
        """Pass the section value to logger"""
251
        logger.log(self.suite_name, msg, *args)
252

    
253
    def info(self, msg, *args):
254
        """Pass the section value to logger"""
255
        logger.info(self.suite_name, msg, *args)
256

    
257
    def debug(self, msg, *args):
258
        """Pass the section value to logger"""
259
        logger.debug(self.suite_name, msg, *args)
260

    
261
    def warning(self, msg, *args):
262
        """Pass the section value to logger"""
263
        logger.warning(self.suite_name, msg, *args)
264

    
265
    def error(self, msg, *args):
266
        """Pass the section value to logger"""
267
        logger.error(self.suite_name, msg, *args)
268

    
269
    # ----------------------------------
270
    # Helper functions that every testsuite may need
271
    def _get_uuid(self):
272
        """Get our uuid"""
273
        authenticate = self.clients.astakos.authenticate()
274
        uuid = authenticate['access']['user']['id']
275
        self.info("User's uuid is %s", uuid)
276
        return uuid
277

    
278
    def _get_username(self):
279
        """Get our User Name"""
280
        authenticate = self.clients.astakos.authenticate()
281
        username = authenticate['access']['user']['name']
282
        self.info("User's name is %s", username)
283
        return username
284

    
285
    def _create_tmp_directory(self):
286
        """Create a tmp directory"""
287
        temp_dir = tempfile.mkdtemp(dir=self.temp_directory)
288
        self.info("Temp directory %s created", temp_dir)
289
        return temp_dir
290

    
291
    def _remove_tmp_directory(self, tmp_dir):
292
        """Remove a tmp directory"""
293
        try:
294
            shutil.rmtree(tmp_dir)
295
            self.info("Temp directory %s deleted", tmp_dir)
296
        except OSError:
297
            pass
298

    
299
    def _get_uuid_of_system_user(self):
300
        """Get the uuid of the system user
301

302
        This is the user that upload the 'official' images.
303

304
        """
305
        self.info("Getting the uuid of the system user")
306
        system_users = None
307
        if self.system_user is not None:
308
            parsed_su = parse_typed_option(self.system_user)
309
            if parsed_su is None:
310
                msg = "Invalid system-user format: %s. Must be [id|name]:.+"
311
                self.warning(msg, self.system_user)
312
            else:
313
                su_type, su_value = parsed_su
314
                if su_type == "name":
315
                    system_users = [su_value]
316
                elif su_type == "id":
317
                    self.info("System user's uuid is %s", su_value)
318
                    return su_value
319
                else:
320
                    self.error("Unrecognized system-user type %s", su_type)
321
                    self.fail("Unrecognized system-user type")
322

    
323
        if system_users is None:
324
            system_users = SYSTEM_USERS
325

    
326
        uuids = self.clients.astakos.usernames2uuids(system_users)
327
        for su_name in system_users:
328
            self.info("Trying username %s", su_name)
329
            if su_name in uuids:
330
                self.info("System user's uuid is %s", uuids[su_name])
331
                return uuids[su_name]
332

    
333
        self.warning("No system user found")
334
        return None
335

    
336
    def _skip_if(self, condition, msg):
337
        """Skip tests"""
338
        if condition:
339
            self.info("Test skipped: %s" % msg)
340
            self.skipTest(msg)
341

    
342
    # ----------------------------------
343
    # Flavors
344
    def _get_list_of_flavors(self, detail=False):
345
        """Get (detailed) list of flavors"""
346
        if detail:
347
            self.info("Getting detailed list of flavors")
348
        else:
349
            self.info("Getting simple list of flavors")
350
        flavors = self.clients.compute.list_flavors(detail=detail)
351
        return flavors
352

    
353
    def _find_flavors(self, patterns, flavors=None):
354
        """Find a list of suitable flavors to use
355

356
        The patterns is a list of `typed_options'. A list of all flavors
357
        matching this patterns will be returned.
358

359
        """
360
        if flavors is None:
361
            flavors = self._get_list_of_flavors(detail=True)
362

    
363
        ret_flavors = []
364
        for ptrn in patterns:
365
            parsed_ptrn = parse_typed_option(ptrn)
366
            if parsed_ptrn is None:
367
                msg = "Invalid flavor format: %s. Must be [id|name]:.+"
368
                self.warning(msg, ptrn)
369
                continue
370
            flv_type, flv_value = parsed_ptrn
371
            if flv_type == "name":
372
                # Filter flavor by name
373
                msg = "Trying to find a flavor with name %s"
374
                self.info(msg, flv_value)
375
                filtered_flvs = \
376
                    [f for f in flavors if
377
                     re.search(flv_value, f['name'], flags=re.I) is not None]
378
            elif flv_type == "id":
379
                # Filter flavors by id
380
                msg = "Trying to find a flavor with id %s"
381
                self.info(msg, flv_value)
382
                filtered_flvs = \
383
                    [f for f in flavors if str(f['id']) == flv_value]
384
            else:
385
                self.error("Unrecognized flavor type %s", flv_type)
386
                self.fail("Unrecognized flavor type")
387

    
388
            # Append and continue
389
            ret_flavors.extend(filtered_flvs)
390

    
391
        self.assertGreater(len(ret_flavors), 0,
392
                           "No matching flavors found")
393
        return ret_flavors
394

    
395
    # ----------------------------------
396
    # Images
397
    def _get_list_of_images(self, detail=False):
398
        """Get (detailed) list of images"""
399
        if detail:
400
            self.info("Getting detailed list of images")
401
        else:
402
            self.info("Getting simple list of images")
403
        images = self.clients.image.list_public(detail=detail)
404
        # Remove images registered by burnin
405
        images = [img for img in images
406
                  if not img['name'].startswith(SNF_TEST_PREFIX)]
407
        return images
408

    
409
    def _get_list_of_sys_images(self, images=None):
410
        """Get (detailed) list of images registered by system user or by me"""
411
        self.info("Getting list of images registered by system user or by me")
412
        if images is None:
413
            images = self._get_list_of_images(detail=True)
414

    
415
        su_uuid = self._get_uuid_of_system_user()
416
        my_uuid = self._get_uuid()
417
        ret_images = [i for i in images
418
                      if i['owner'] == su_uuid or i['owner'] == my_uuid]
419

    
420
        return ret_images
421

    
422
    def _find_images(self, patterns, images=None):
423
        """Find a list of suitable images to use
424

425
        The patterns is a list of `typed_options'. A list of all images
426
        matching this patterns will be returned.
427

428
        """
429
        if images is None:
430
            images = self._get_list_of_sys_images()
431

    
432
        ret_images = []
433
        for ptrn in patterns:
434
            parsed_ptrn = parse_typed_option(ptrn)
435
            if parsed_ptrn is None:
436
                msg = "Invalid image format: %s. Must be [id|name]:.+"
437
                self.warning(msg, ptrn)
438
                continue
439
            img_type, img_value = parsed_ptrn
440
            if img_type == "name":
441
                # Filter image by name
442
                msg = "Trying to find an image with name %s"
443
                self.info(msg, img_value)
444
                filtered_imgs = \
445
                    [i for i in images if
446
                     re.search(img_value, i['name'], flags=re.I) is not None]
447
            elif img_type == "id":
448
                # Filter images by id
449
                msg = "Trying to find an image with id %s"
450
                self.info(msg, img_value)
451
                filtered_imgs = \
452
                    [i for i in images if
453
                     i['id'].lower() == img_value.lower()]
454
            else:
455
                self.error("Unrecognized image type %s", img_type)
456
                self.fail("Unrecognized image type")
457

    
458
            # Append and continue
459
            ret_images.extend(filtered_imgs)
460

    
461
        self.assertGreater(len(ret_images), 0,
462
                           "No matching images found")
463
        return ret_images
464

    
465
    # ----------------------------------
466
    # Pithos
467
    def _set_pithos_account(self, account):
468
        """Set the Pithos account"""
469
        assert account, "No pithos account was given"
470

    
471
        self.info("Setting Pithos account to %s", account)
472
        self.clients.pithos.account = account
473

    
474
    def _set_pithos_container(self, container):
475
        """Set the Pithos container"""
476
        assert container, "No pithos container was given"
477

    
478
        self.info("Setting Pithos container to %s", container)
479
        self.clients.pithos.container = container
480

    
481
    def _get_list_of_containers(self, account=None):
482
        """Get list of containers"""
483
        if account is not None:
484
            self._set_pithos_account(account)
485
        self.info("Getting list of containers")
486
        return self.clients.pithos.list_containers()
487

    
488
    def _create_pithos_container(self, container):
489
        """Create a pithos container
490

491
        If the container exists, nothing will happen
492

493
        """
494
        assert container, "No pithos container was given"
495

    
496
        self.info("Creating pithos container %s", container)
497
        self.clients.pithos.container = container
498
        self.clients.pithos.container_put()
499

    
500
    # ----------------------------------
501
    # Quotas
502
    def _get_quotas(self):
503
        """Get quotas"""
504
        self.info("Getting quotas")
505
        astakos_client = self.clients.astakos.get_client()
506
        return astakos_client.get_quotas()
507

    
508
    # Invalid argument name. pylint: disable-msg=C0103
509
    # Too many arguments. pylint: disable-msg=R0913
510
    def _check_quotas(self, disk=None, vm=None, diskspace=None,
511
                      ram=None, ip=None, cpu=None, network=None):
512
        """Check that quotas' changes are consistent"""
513
        assert any(v is None for v in
514
                   [disk, vm, diskspace, ram, ip, cpu, network]), \
515
            "_check_quotas require arguments"
516

    
517
        self.info("Check that quotas' changes are consistent")
518
        old_quotas = self.quotas
519
        new_quotas = self._get_quotas()
520
        self.quotas = new_quotas
521

    
522
        # Check Disk usage
523
        self._check_quotas_aux(
524
            old_quotas, new_quotas, 'cyclades.disk', disk)
525
        # Check VM usage
526
        self._check_quotas_aux(
527
            old_quotas, new_quotas, 'cyclades.vm', vm)
528
        # Check DiskSpace usage
529
        self._check_quotas_aux(
530
            old_quotas, new_quotas, 'pithos.diskspace', diskspace)
531
        # Check Ram usage
532
        self._check_quotas_aux(
533
            old_quotas, new_quotas, 'cyclades.ram', ram)
534
        # Check Floating IPs usage
535
        self._check_quotas_aux(
536
            old_quotas, new_quotas, 'cyclades.floating_ip', ip)
537
        # Check CPU usage
538
        self._check_quotas_aux(
539
            old_quotas, new_quotas, 'cyclades.cpu', cpu)
540
        # Check Network usage
541
        self._check_quotas_aux(
542
            old_quotas, new_quotas, 'cyclades.network.private', network)
543

    
544
    def _check_quotas_aux(self, old_quotas, new_quotas, resource, value):
545
        """Auxiliary function for _check_quotas"""
546
        old_value = old_quotas['system'][resource]['usage']
547
        new_value = new_quotas['system'][resource]['usage']
548
        if value is not None:
549
            assert isinstance(value, int), \
550
                "%s value has to be integer" % resource
551
            old_value += value
552
        self.assertEqual(old_value, new_value,
553
                         "%s quotas don't match" % resource)
554

    
555

    
556
# --------------------------------------------------------------------
557
# Initialize Burnin
558
def initialize(opts, testsuites, stale_testsuites):
559
    """Initalize burnin
560

561
    Initialize our logger and burnin state
562

563
    """
564
    # Initialize logger
565
    global logger  # Using global statement. pylint: disable-msg=C0103,W0603
566
    curr_time = datetime.datetime.now()
567
    logger = Log(opts.log_folder, verbose=opts.verbose,
568
                 use_colors=opts.use_colors, in_parallel=False,
569
                 log_level=opts.log_level, curr_time=curr_time)
570

    
571
    # Initialize clients
572
    Clients.auth_url = opts.auth_url
573
    Clients.token = opts.token
574

    
575
    # Pass the rest options to BurninTests
576
    BurninTests.use_ipv6 = opts.use_ipv6
577
    BurninTests.action_timeout = opts.action_timeout
578
    BurninTests.action_warning = opts.action_warning
579
    BurninTests.query_interval = opts.query_interval
580
    BurninTests.system_user = opts.system_user
581
    BurninTests.flavors = opts.flavors
582
    BurninTests.images = opts.images
583
    BurninTests.delete_stale = opts.delete_stale
584
    BurninTests.temp_directory = opts.temp_directory
585
    BurninTests.failfast = opts.failfast
586
    BurninTests.run_id = SNF_TEST_PREFIX + \
587
        datetime.datetime.strftime(curr_time, "%Y%m%d%H%M%S")
588

    
589
    # Choose tests to run
590
    if opts.show_stale:
591
        # We will run the stale_testsuites
592
        return (stale_testsuites, True)
593

    
594
    if opts.tests != "all":
595
        testsuites = opts.tests
596
    if opts.exclude_tests is not None:
597
        testsuites = [tsuite for tsuite in testsuites
598
                      if tsuite not in opts.exclude_tests]
599

    
600
    return (testsuites, opts.failfast)
601

    
602

    
603
# --------------------------------------------------------------------
604
# Run Burnin
605
def run_burnin(testsuites, failfast=False):
606
    """Run burnin testsuites"""
607
    # Using global. pylint: disable-msg=C0103,W0603,W0602
608
    global logger, success
609

    
610
    success = True
611
    run_tests(testsuites, failfast=failfast)
612

    
613
    # Clean up our logger
614
    del(logger)
615

    
616
    # Return
617
    return 0 if success else 1
618

    
619

    
620
def run_tests(tcases, failfast=False):
621
    """Run some testcases"""
622
    global success  # Using global. pylint: disable-msg=C0103,W0603,W0602
623

    
624
    for tcase in tcases:
625
        was_success = run_test(tcase)
626
        success = success and was_success
627
        if failfast and not success:
628
            break
629

    
630

    
631
def run_test(tcase):
632
    """Run a testcase"""
633
    tsuite = unittest.TestLoader().loadTestsFromTestCase(tcase)
634
    results = tsuite.run(BurninTestResult())
635

    
636
    return was_successful(tcase.__name__, results.wasSuccessful())
637

    
638

    
639
# --------------------------------------------------------------------
640
# Helper functions
641
def was_successful(tsuite, successful):
642
    """Handle whether a testsuite was succesful or not"""
643
    if successful:
644
        logger.testsuite_success(tsuite)
645
        return True
646
    else:
647
        logger.testsuite_failure(tsuite)
648
        return False
649

    
650

    
651
def parse_typed_option(value):
652
    """Parse typed options (flavors and images)
653

654
    The options are in the form 'id:123-345' or 'name:^Debian Base$'
655

656
    """
657
    try:
658
        [type_, val] = value.strip().split(':')
659
        if type_ not in ["id", "name"]:
660
            raise ValueError
661
        return type_, val
662
    except ValueError:
663
        return None