blob: 8eec71e86a1a1f580134beba3e98443fbd748cb7 [file] [log] [blame]
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +02001# osmo_gsm_tester: manage resources
2#
3# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
4#
5# Author: Neels Hofmeyr <neels@hofmeyr.de>
6#
7# This program is free software: you can redistribute it and/or modify
Harald Welte27205342017-06-03 09:51:45 +02008# it under the terms of the GNU General Public License as
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +02009# published by the Free Software Foundation, either version 3 of the
10# License, or (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Harald Welte27205342017-06-03 09:51:45 +020015# GNU General Public License for more details.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020016#
Harald Welte27205342017-06-03 09:51:45 +020017# You should have received a copy of the GNU General Public License
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020018# along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20import os
Neels Hofmeyr3531a192017-03-28 14:30:28 +020021import time
22import copy
23import atexit
24import pprint
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020025
26from . import log
27from . import config
Neels Hofmeyr3531a192017-03-28 14:30:28 +020028from . import util
29from . import schema
30from . import ofono_client
31from . import osmo_nitb
Neels Hofmeyr391afe32017-05-18 19:22:12 +020032from . import bts_sysmo, bts_osmotrx
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020033
Neels Hofmeyr3531a192017-03-28 14:30:28 +020034from .util import is_dict, is_list
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020035
Neels Hofmeyr3531a192017-03-28 14:30:28 +020036HASH_KEY = '_hash'
37RESERVED_KEY = '_reserved_by'
38USED_KEY = '_used'
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020039
Neels Hofmeyr3531a192017-03-28 14:30:28 +020040RESOURCES_CONF = 'resources.conf'
41LAST_USED_MSISDN_FILE = 'last_used_msisdn.state'
42RESERVED_RESOURCES_FILE = 'reserved_resources.state'
43
Neels Hofmeyr76d81032017-05-18 18:35:32 +020044R_IP_ADDRESS = 'ip_address'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020045R_BTS = 'bts'
46R_ARFCN = 'arfcn'
47R_MODEM = 'modem'
Neels Hofmeyr76d81032017-05-18 18:35:32 +020048R_ALL = (R_IP_ADDRESS, R_BTS, R_ARFCN, R_MODEM)
Neels Hofmeyr3531a192017-03-28 14:30:28 +020049
50RESOURCES_SCHEMA = {
Neels Hofmeyr76d81032017-05-18 18:35:32 +020051 'ip_address[].addr': schema.IPV4,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020052 'bts[].label': schema.STR,
53 'bts[].type': schema.STR,
Neels Hofmeyr17c139e2017-04-12 02:42:02 +020054 'bts[].ipa_unit_id': schema.INT,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020055 'bts[].addr': schema.IPV4,
56 'bts[].band': schema.BAND,
Your Name44af3412017-04-13 03:11:59 +020057 'bts[].trx_list[].hw_addr': schema.HWADDR,
58 'bts[].trx_list[].net_device': schema.STR,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020059 'arfcn[].arfcn': schema.INT,
60 'arfcn[].band': schema.BAND,
61 'modem[].label': schema.STR,
62 'modem[].path': schema.STR,
63 'modem[].imsi': schema.IMSI,
64 'modem[].ki': schema.KI,
65 }
66
67WANT_SCHEMA = util.dict_add(
68 dict([('%s[].times' % r, schema.INT) for r in R_ALL]),
69 RESOURCES_SCHEMA)
70
71KNOWN_BTS_TYPES = {
Your Name44af3412017-04-13 03:11:59 +020072 'osmo-bts-sysmo': bts_sysmo.SysmoBts,
73 'osmo-bts-trx': bts_osmotrx.OsmoBtsTrx,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020074 }
75
76def register_bts_type(name, clazz):
77 KNOWN_BTS_TYPES[name] = clazz
78
79class ResourcesPool(log.Origin):
80 _remember_to_free = None
81 _registered_exit_handler = False
82
83 def __init__(self):
84 self.config_path = config.get_config_file(RESOURCES_CONF)
85 self.state_dir = config.get_state_dir()
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +020086 super().__init__(log.C_CNF, conf=self.config_path, state=self.state_dir.path)
Neels Hofmeyr3531a192017-03-28 14:30:28 +020087 self.read_conf()
88
89 def read_conf(self):
90 self.all_resources = Resources(config.read(self.config_path, RESOURCES_SCHEMA))
91 self.all_resources.set_hashes()
92
93 def reserve(self, origin, want):
94 '''
95 attempt to reserve the resources specified in the dict 'want' for
96 'origin'. Obtain a lock on the resources lock dir, verify that all
97 wanted resources are available, and if yes mark them as reserved.
98
99 On success, return a reservation object which can be used to release
100 the reservation. The reservation will be freed automatically on program
101 exit, if not yet done manually.
102
103 'origin' should be an Origin() instance.
104
105 'want' is a dict matching WANT_SCHEMA, which is the same as
106 the RESOURCES_SCHEMA, except each entity that can be reserved has a 'times'
107 field added, to indicate how many of those should be reserved.
108
109 If an entry has only a 'times' set, any of the resources may be
110 reserved without further limitations.
111
112 ResourcesPool may also be selected with narrowed down constraints.
Neels Hofmeyr76d81032017-05-18 18:35:32 +0200113 This would reserve one IP address, two modems, one BTS of type
Neels Hofmeyr391afe32017-05-18 19:22:12 +0200114 sysmo and one of type trx, plus 2 ARFCNs in the 1800 band:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200115
116 {
Neels Hofmeyr76d81032017-05-18 18:35:32 +0200117 'ip_address': [ { 'times': 1 } ],
Neels Hofmeyr391afe32017-05-18 19:22:12 +0200118 'bts': [ { 'type': 'sysmo', 'times': 1 }, { 'type': 'trx', 'times': 1 } ],
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200119 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
120 'modem': [ { 'times': 2 } ],
121 }
122
123 A times=1 value is implicit, so the above is equivalent to:
124
125 {
Neels Hofmeyr76d81032017-05-18 18:35:32 +0200126 'ip_address': [ {} ],
Neels Hofmeyr391afe32017-05-18 19:22:12 +0200127 'bts': [ { 'type': 'sysmo' }, { 'type': 'trx' } ],
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200128 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
129 'modem': [ { 'times': 2 } ],
130 }
131 '''
132 schema.validate(want, WANT_SCHEMA)
133
134 # replicate items that have a 'times' > 1
135 want = copy.deepcopy(want)
136 for key, item_list in want.items():
137 more_items = []
138 for item in item_list:
139 times = int(item.pop('times'))
140 if times and times > 1:
141 for i in range(times - 1):
142 more_items.append(copy.deepcopy(item))
143 item_list.extend(more_items)
144
145 origin_id = origin.origin_id()
146
147 with self.state_dir.lock(origin_id):
148 rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
149 reserved = Resources(config.read(rrfile_path, if_missing_return={}))
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200150 to_be_reserved = self.all_resources.without(reserved).find(origin, want)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200151
152 to_be_reserved.mark_reserved_by(origin_id)
153
154 reserved.add(to_be_reserved)
155 config.write(rrfile_path, reserved)
156
157 self.remember_to_free(to_be_reserved)
158 return ReservedResources(self, origin, to_be_reserved)
159
160 def free(self, origin, to_be_freed):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200161 log.ctx(origin)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200162 with self.state_dir.lock(origin.origin_id()):
163 rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
164 reserved = Resources(config.read(rrfile_path, if_missing_return={}))
165 reserved.drop(to_be_freed)
166 config.write(rrfile_path, reserved)
167 self.forget_freed(to_be_freed)
168
169 def register_exit_handler(self):
170 if self._registered_exit_handler:
171 return
172 atexit.register(self.clean_up_registered_resources)
173 self._registered_exit_handler = True
174
175 def unregister_exit_handler(self):
176 if not self._registered_exit_handler:
177 return
178 atexit.unregister(self.clean_up_registered_resources)
179 self._registered_exit_handler = False
180
181 def clean_up_registered_resources(self):
182 if not self._remember_to_free:
183 return
184 self.free(log.Origin('atexit.clean_up_registered_resources()'),
185 self._remember_to_free)
186
187 def remember_to_free(self, to_be_reserved):
188 self.register_exit_handler()
189 if not self._remember_to_free:
190 self._remember_to_free = Resources()
191 self._remember_to_free.add(to_be_reserved)
192
193 def forget_freed(self, freed):
194 if freed is self._remember_to_free:
195 self._remember_to_free.clear()
196 else:
197 self._remember_to_free.drop(freed)
198 if not self._remember_to_free:
199 self.unregister_exit_handler()
200
201 def next_msisdn(self, origin):
202 origin_id = origin.origin_id()
203
204 with self.state_dir.lock(origin_id):
205 msisdn_path = self.state_dir.child(LAST_USED_MSISDN_FILE)
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200206 log.ctx(msisdn_path)
207 last_msisdn = '1000'
208 if os.path.exists(msisdn_path):
209 if not os.path.isfile(msisdn_path):
210 raise RuntimeError('path should be a file but is not: %r' % msisdn_path)
211 with open(msisdn_path, 'r') as f:
212 last_msisdn = f.read().strip()
213 schema.msisdn(last_msisdn)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200214
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200215 next_msisdn = util.msisdn_inc(last_msisdn)
216 with open(msisdn_path, 'w') as f:
217 f.write(next_msisdn)
218 return next_msisdn
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200219
220
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200221class NoResourceExn(Exception):
222 pass
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200223
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200224class Resources(dict):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200225
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200226 def __init__(self, all_resources={}, do_copy=True):
227 if do_copy:
228 all_resources = copy.deepcopy(all_resources)
229 self.update(all_resources)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200230
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200231 def drop(self, reserved, fail_if_not_found=True):
232 # protect from modifying reserved because we're the same object
233 if reserved is self:
234 raise RuntimeError('Refusing to drop a list of resources from itself.'
235 ' This is probably a bug where a list of Resources()'
236 ' should have been copied but is passed as-is.'
237 ' use Resources.clear() instead.')
238
239 for key, reserved_list in reserved.items():
240 my_list = self.get(key) or []
241
242 if my_list is reserved_list:
243 self.pop(key)
244 continue
245
246 for reserved_item in reserved_list:
247 found = False
248 reserved_hash = reserved_item.get(HASH_KEY)
249 if not reserved_hash:
250 raise RuntimeError('Resources.drop() only works with hashed items')
251
252 for i in range(len(my_list)):
253 my_item = my_list[i]
254 my_hash = my_item.get(HASH_KEY)
255 if not my_hash:
256 raise RuntimeError('Resources.drop() only works with hashed items')
257 if my_hash == reserved_hash:
258 found = True
259 my_list.pop(i)
260 break
261
262 if fail_if_not_found and not found:
263 raise RuntimeError('Asked to drop resource from a pool, but the'
264 ' resource was not found: %s = %r' % (key, reserved_item))
265
266 if not my_list:
267 self.pop(key)
268 return self
269
270 def without(self, reserved):
271 return Resources(self).drop(reserved)
272
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200273 def find(self, for_origin, want, skip_if_marked=None, do_copy=True, raise_if_missing=True, log_label='Reserving'):
Neels Hofmeyr2fade332017-05-06 23:18:23 +0200274 '''
275 Pass a dict of resource requirements, e.g.:
276 want = {
277 'bts': [ {'type': 'osmo-bts-sysmo',}, {} ],
278 'modem': [ {'times': 3} ]
279 }
280 This function tries to find a combination from the available resources that
281 matches these requiremens. The returnvalue is a dict (wrapped in a Resources class)
282 that contains the matching resources in the order of 'want' dict: in above
283 example, the returned dict would have a 'bts' list with the first item being
284 a sysmoBTS, the second item being any other available BTS.
285
286 If skip_if_marked is passed, any resource that contains this key is skipped.
287 E.g. if a BTS has the USED_KEY set like
288 reserved_resources = { 'bts' : {..., '_used': True} }
289 then this may be skipped by passing skip_if_marked='_used'
290 (or rather skip_if_marked=USED_KEY).
291
292 If do_copy is True, the returned dict is a deep copy and does not share
293 lists with any other Resources dict.
294
295 If raise_if_missing is False, this will return an empty item for any
296 resource that had no match, instead of immediately raising an exception.
297 '''
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200298 matches = {}
Neels Hofmeyr17c139e2017-04-12 02:42:02 +0200299 for key, want_list in sorted(want.items()): # sorted for deterministic test results
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200300 # here we have a resource of a given type, e.g. 'bts', with a list
301 # containing as many BTSes as the caller wants to reserve/use. Each
302 # list item contains specifics for the particular BTS.
Neels Hofmeyr2a1a1fa2017-05-29 01:36:21 +0200303 my_list = self.get(key, [])
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200304
Neels Hofmeyrf9e86932017-06-06 19:47:40 +0200305 if log_label:
306 for_origin.log(log_label, len(want_list), 'x', key, '(candidates: %d)'%len(my_list))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200307
308 # Try to avoid a less constrained item snatching away a resource
309 # from a more detailed constrained requirement.
310
Neels Hofmeyr2fade332017-05-06 23:18:23 +0200311 # first record all matches, so that each requested item has a list
312 # of all available resources that match it. Some resources may
313 # appear for multiple requested items. Store matching indexes.
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200314 all_matches = []
315 for want_item in want_list:
316 item_match_list = []
317 for i in range(len(my_list)):
318 my_item = my_list[i]
319 if skip_if_marked and my_item.get(skip_if_marked):
320 continue
321 if item_matches(my_item, want_item, ignore_keys=('times',)):
322 item_match_list.append(i)
323 if not item_match_list:
Neels Hofmeyr9b907702017-05-06 23:20:33 +0200324 if raise_if_missing:
325 raise NoResourceExn('No matching resource available for %s = %r'
326 % (key, want_item))
327 else:
328 # this one failed... see below
329 all_matches = []
330 break
331
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200332 all_matches.append( item_match_list )
333
334 if not all_matches:
Neels Hofmeyr9b907702017-05-06 23:20:33 +0200335 # ...this one failed. Makes no sense to solve resource
336 # allocations, return an empty list for this key to mark
337 # failure.
338 matches[key] = []
339 continue
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200340
341 # figure out who gets what
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200342 try:
343 solution = solve(all_matches)
344 except NotSolvable:
345 # instead of a cryptic error message, raise an exception that
346 # conveys meaning to the user.
347 raise NoResourceExn('Could not resolve request to reserve resources: '
348 '%d x %s with requirements: %r' % (len(want_list), key, want_list))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200349 picked = [ my_list[i] for i in solution if i is not None ]
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200350 for_origin.dbg('Picked', config.tostr(picked))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200351 matches[key] = picked
352
353 return Resources(matches, do_copy=do_copy)
354
355 def set_hashes(self):
356 for key, item_list in self.items():
357 for item in item_list:
358 item[HASH_KEY] = util.hash_obj(item, HASH_KEY, RESERVED_KEY, USED_KEY)
359
360 def add(self, more):
361 if more is self:
362 raise RuntimeError('adding a list of resources to itself?')
363 config.add(self, copy.deepcopy(more))
364
365 def combine(self, more_rules):
366 if more_rules is self:
367 raise RuntimeError('combining a list of resource rules with itself?')
368 config.combine(self, copy.deepcopy(more))
369
370 def mark_reserved_by(self, origin_id):
371 for key, item_list in self.items():
372 for item in item_list:
373 item[RESERVED_KEY] = origin_id
374
375
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200376class NotSolvable(Exception):
377 pass
378
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200379def solve(all_matches):
380 '''
381 all_matches shall be a list of index-lists.
382 all_matches[i] is the list of indexes that item i can use.
383 Return a solution so that each i gets a different index.
384 solve([ [0, 1, 2],
385 [0],
386 [0, 2] ]) == [1, 0, 2]
387 '''
388
389 def all_differ(l):
390 return len(set(l)) == len(l)
391
392 def search_in_permutations(fixed=[]):
393 idx = len(fixed)
394 for i in range(len(all_matches[idx])):
395 val = all_matches[idx][i]
396 # don't add a val that's already in the list
397 if val in fixed:
398 continue
399 l = list(fixed)
400 l.append(val)
401 if len(l) == len(all_matches):
402 # found a solution
403 return l
404 # not at the end yet, add next digit
405 r = search_in_permutations(l)
406 if r:
407 # nested search_in_permutations() call found a solution
408 return r
409 # this entire branch yielded no solution
410 return None
411
412 if not all_matches:
413 raise RuntimeError('Cannot solve: no candidates')
414
415 solution = search_in_permutations()
416 if not solution:
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200417 raise NotSolvable('The requested resource requirements are not solvable %r'
418 % all_matches)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200419 return solution
420
421
422def contains_hash(list_of_dicts, a_hash):
423 for d in list_of_dicts:
424 if d.get(HASH_KEY) == a_hash:
425 return True
426 return False
427
428def item_matches(item, wanted_item, ignore_keys=None):
429 if is_dict(wanted_item):
430 # match up two dicts
431 if not isinstance(item, dict):
432 return False
433 for key, wanted_val in wanted_item.items():
434 if ignore_keys and key in ignore_keys:
435 continue
436 if not item_matches(item.get(key), wanted_val, ignore_keys=ignore_keys):
437 return False
438 return True
439
440 if is_list(wanted_item):
441 # multiple possible values
442 if item not in wanted_item:
443 return False
444 return True
445
446 return item == wanted_item
447
448
449class ReservedResources(log.Origin):
450 '''
451 After all resources have been figured out, this is the API that a test case
452 gets to interact with resources. From those resources that have been
453 reserved for it, it can pick some to mark them as currently in use.
454 Functions like nitb() provide a resource by automatically picking its
455 dependencies from so far unused (but reserved) resource.
456 '''
457
458 def __init__(self, resources_pool, origin, reserved):
459 self.resources_pool = resources_pool
460 self.origin = origin
461 self.reserved = reserved
462
463 def __repr__(self):
464 return 'resources(%s)=%s' % (self.origin.name(), pprint.pformat(self.reserved))
465
466 def get(self, kind, specifics=None):
467 if specifics is None:
468 specifics = {}
469 self.dbg('requesting use of', kind, specifics=specifics)
470 want = { kind: [specifics] }
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200471 available_dict = self.reserved.find(self.origin, want, skip_if_marked=USED_KEY,
472 do_copy=False, raise_if_missing=False,
473 log_label='Using')
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200474 available = available_dict.get(kind)
475 self.dbg(available=len(available))
476 if not available:
Neels Hofmeyrf9e86932017-06-06 19:47:40 +0200477 # cook up a detailed error message for the current situation
478 kind_reserved = self.reserved.get(kind, [])
479 used_count = len([r for r in kind_reserved if USED_KEY in r])
480 matching = self.reserved.find(self.origin, want, raise_if_missing=False, log_label=None).get(kind, [])
481 if not matching:
482 msg = 'none of the reserved resources matches requirements %r' % specifics
483 elif not (used_count < len(kind_reserved)):
484 msg = 'suite.conf reserved only %d x %r.' % (len(kind_reserved), kind)
485 else:
486 msg = ('No unused resource left that matches the requirements;'
487 ' Of reserved %d x %r, %d match the requirements, but all are already in use;'
488 ' Requirements: %r'
489 % (len(kind_reserved), kind, len(matching), specifics))
490 raise NoResourceExn('When trying to use instance nr %d of %r: %s' % (used_count + 1, kind, msg))
491
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200492 pick = available[0]
493 self.dbg(using=pick)
494 assert not pick.get(USED_KEY)
495 pick[USED_KEY] = True
496 return copy.deepcopy(pick)
497
498 def put(self, item):
499 if not item.get(USED_KEY):
500 raise RuntimeError('Can only put() a resource that is used: %r' % item)
501 hash_to_put = item.get(HASH_KEY)
502 if not hash_to_put:
503 raise RuntimeError('Can only put() a resource that has a hash marker: %r' % item)
504 for key, item_list in self.reserved.items():
505 my_list = self.get(key)
506 for my_item in my_list:
507 if hash_to_put == my_item.get(HASH_KEY):
508 my_item.pop(USED_KEY)
509
510 def put_all(self):
Pau Espin Pedrol1dd29552017-06-13 18:07:57 +0200511 if not self.reserved:
512 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200513 for key, item_list in self.reserved.items():
Pau Espin Pedrol1dd29552017-06-13 18:07:57 +0200514 for item in item_list:
515 item.pop(USED_KEY, None)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200516
517 def free(self):
Neels Hofmeyred4e5282017-05-29 02:53:54 +0200518 if self.reserved:
519 self.resources_pool.free(self.origin, self.reserved)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200520 self.reserved = None
521
Neels Hofmeyr2d1d5612017-05-22 20:02:41 +0200522 def counts(self):
523 counts = {}
524 for key in self.reserved.keys():
525 counts[key] = self.count(key)
526 return counts
527
528 def count(self, key):
529 return len(self.reserved.get(key) or [])
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200530
531# vim: expandtab tabstop=4 shiftwidth=4