Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/c7n/filters/offhours.py: 62%

242 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-08 06:51 +0000

1# Copyright The Cloud Custodian Authors. 

2# SPDX-License-Identifier: Apache-2.0 

3""" 

4Resource Scheduling Offhours 

5============================ 

6 

7Custodian provides for time based filters, that allow for taking periodic 

8action on a resource, with resource schedule customization based on tag values. 

9A common use is offhours scheduling for asgs and instances. 

10 

11Features 

12======== 

13 

14- Flexible offhours scheduling with opt-in, opt-out selection, and timezone 

15 support. 

16- Resume during offhours support. 

17- Can be combined with other filters to get a particular set ( 

18 resources with tag, vpc, etc). 

19- Can be combined with arbitrary actions 

20- Can omit a set of dates such as public holidays. 

21 

22Policy Configuration 

23==================== 

24 

25We provide an `onhour` and `offhour` time filter, each should be used in a 

26different policy, they support the same configuration options: 

27 

28 - **weekends**: default true, whether to leave resources off for the weekend 

29 - **weekends-only**: default false, whether to turn the resource off only on 

30 the weekend 

31 - **default_tz**: which timezone to utilize when evaluating time **(REQUIRED)** 

32 - **fallback-schedule**: If a resource doesn't support tagging or doesn't provide 

33 a tag you can supply a default schedule that will be used. When the tag is provided 

34 this will be ignored. See :ref:`ScheduleParser Time Specifications <scheduleparser-time-spec>`. 

35 - **tag**: which resource tag name to use for per-resource configuration 

36 (schedule and timezone overrides and opt-in/opt-out); default is 

37 ``maid_offhours``. 

38 - **opt-out**: Determines the behavior for resources which do not have a tag 

39 matching the one specified for **tag**. Values can be either ``false`` (the 

40 default) where the policy operates on an opt-in basis and resources must have 

41 the tag in order to be acted on by the policy, or ``true`` where the policy 

42 operates on an opt-out basis, and resources without the tag are acted on by 

43 the policy. 

44 - **onhour**: the default time to start/run resources, specified as 0-23 

45 - **offhour**: the default time to stop/suspend resources, specified as 0-23 

46 - **skip-days**: a list of dates to skip. Dates must use format YYYY-MM-DD 

47 - **skip-days-from**: a list of dates to skip stored at a url. **expr**, 

48 **format**, and **url** must be passed as parameters. Same syntax as 

49 ``value_from``. Can not specify both **skip-days-from** and **skip-days**. 

50 

51This example policy overrides most of the defaults for an offhour policy: 

52 

53.. code-block:: yaml 

54 

55 policies: 

56 - name: offhours-stop 

57 resource: ec2 

58 filters: 

59 - type: offhour 

60 weekends: false 

61 default_tz: pt 

62 tag: downtime 

63 opt-out: true 

64 onhour: 8 

65 offhour: 20 

66 

67 

68Tag Based Configuration 

69======================= 

70 

71Resources can use a special tag to override the default configuration on a 

72per-resource basis. Note that the name of the tag is configurable via the 

73``tag`` option in the policy; the examples below use the default tag name, 

74``maid_offhours``. 

75 

76The value of the tag must be one of the following: 

77 

78- **(empty)** or **on** - An empty tag value or a value of "on" implies night 

79 and weekend offhours using the default time zone configured in the policy 

80 (tz=est if unspecified) and the default onhour and offhour values configured 

81 in the policy. 

82- **off** - If offhours is configured to run in opt-out mode, this tag can be 

83 specified to disable offhours on a given instance. If offhours is configured 

84 to run in opt-in mode, this tag will have no effect (the resource will still 

85 be opted out). 

86- a semicolon-separated string composed of one or more of the following 

87 components, which override the defaults specified in the policy: 

88 

89 * ``tz=<timezone>`` to evaluate with a resource-specific timezone, where 

90 ``<timezone>`` is either one of the supported timezone aliases defined in 

91 :py:attr:`c7n.filters.offhours.Time.TZ_ALIASES` (such as ``pt``) or the name 

92 of a geographic timezone identifier in 

93 [IANA's tzinfo database](https://www.iana.org/time-zones), such as 

94 ``Americas/Los_Angeles``. *(Note all timezone aliases are 

95 referenced to a locality to ensure taking into account local daylight 

96 savings time, if applicable.)* 

97 * ``off=(time spec)`` and/or ``on=(time spec)`` matching time specifications 

98 supported by :py:class:`c7n.filters.offhours.ScheduleParser` as described 

99 in the next section. 

100 

101 

102.. _scheduleparser-time-spec: 

103 

104ScheduleParser Time Specifications 

105---------------------------------- 

106 

107Each time specification follows the format ``(days,hours)``. Multiple time 

108specifications can be combined in square-bracketed lists, i.e. 

109``[(days,hours),(days,hours),(days,hours)]``. 

110 

111**Examples**:: 

112 

113 # up mon-fri from 7am-7pm; eastern time 

114 off=(M-F,19);on=(M-F,7) 

115 # up mon-fri from 6am-9pm; up sun from 10am-6pm; pacific time 

116 off=[(M-F,21),(U,18)];on=[(M-F,6),(U,10)];tz=pt 

117 

118**Possible values**: 

119 

120 +------------+----------------------+ 

121 | field | values | 

122 +============+======================+ 

123 | days | M, T, W, H, F, S, U | 

124 +------------+----------------------+ 

125 | hours | 0, 1, 2, ..., 22, 23 | 

126 +------------+----------------------+ 

127 

128 Days can be specified in a range (ex. M-F). 

129 

130Policy examples 

131=============== 

132 

133Turn ec2 instances on and off 

134 

135.. code-block:: yaml 

136 

137 policies: 

138 - name: offhours-stop 

139 resource: ec2 

140 filters: 

141 - type: offhour 

142 actions: 

143 - stop 

144 

145 - name: offhours-start 

146 resource: ec2 

147 filters: 

148 - type: onhour 

149 actions: 

150 - start 

151 

152Here's doing the same with auto scale groups 

153 

154.. code-block:: yaml 

155 

156 policies: 

157 - name: asg-offhours-stop 

158 resource: asg 

159 filters: 

160 - offhour 

161 actions: 

162 - suspend 

163 - name: asg-onhours-start 

164 resource: asg 

165 filters: 

166 - onhour 

167 actions: 

168 - resume 

169 

170Additional policy examples and resource-type-specific information can be seen in 

171the :ref:`EC2 Offhours <ec2offhours>` and :ref:`ASG Offhours <asgoffhours>` 

172use cases. 

173 

174Resume During Offhours 

175====================== 

176 

177These policies are evaluated hourly; during each run (once an hour), 

178cloud-custodian will act on **only** the resources tagged for that **exact** 

179hour. In other words, if a resource has an offhours policy of 

180stopping/suspending at 23:00 Eastern daily and starting/resuming at 06:00 

181Eastern daily, and you run cloud-custodian once an hour via Lambda, that 

182resource will only be stopped once a day sometime between 23:00 and 23:59, and 

183will only be started once a day sometime between 06:00 and 06:59. If the current 

184hour does not *exactly* match the hour specified in the policy, nothing will be 

185done at all. 

186 

187As a result of this, if custodian stops an instance or suspends an ASG and you 

188need to start/resume it, you can safely do so manually and custodian won't touch 

189it again until the next day. 

190 

191ElasticBeanstalk, EFS and Other Services with Tag Value Restrictions 

192==================================================================== 

193 

194A number of AWS services have restrictions on the characters that can be used 

195in tag values, such as `ElasticBeanstalk <http://docs.aws.amazon.com/elasticbean 

196stalk/latest/dg/using-features.tagging.html>`_ and `EFS <http://docs.aws.amazon. 

197com/efs/latest/ug/API_Tag.html>`_. In particular, these services do not allow 

198parenthesis, square brackets, commas, or semicolons, or empty tag values. This 

199proves to be problematic with the tag-based schedule configuration described 

200above. The best current workaround is to define a separate policy with a unique 

201``tag`` name for each unique schedule that you want to use, and then tag 

202resources with that tag name and a value of ``on``. Note that this can only be 

203used in opt-in mode, not opt-out. 

204 

205Another option is to escape the tag value with the following mapping, generated 

206with the char's unicode number `"u" + hex(ord(the_char))[2:]`. 

207This works for GCP resources as well. 

208 

209- ( and ) as u28 and u29 

210- [ and ] as u5b and u5d 

211- , as u2c 

212- ; as u3b 

213- = as u3d 

214- / as u2f 

215- - as u2d 

216 

217**Examples**:: 

218 

219 # off=(M-F,18);tz=Australia/Sydney 

220 offu3du28M-Fu2c18u29u3btzu3dAustraliau2fSydney 

221 # off=[(M-F,18),(S,13)] 

222 off=u5bu28M-Fu2c18u29u2cu28Su2c13u29u5d 

223 

224Public Holidays 

225=============== 

226 

227In order to properly implement support for public holidays, make sure to include 

228either **skip-days** or **skip-days-from** with your policy. This list 

229should contain all of the public holidays you wish to address and must use 

230YYYY-MM-DD syntax for its dates. If the date the policy is being run on matches 

231any one of those dates, the policy will not return any resources. These dates 

232include year as many holidays vary from year to year so year is required to prevent 

233errors. A sample policy that would not start stopped instances on a public holiday 

234might look like: 

235 

236.. code-block:: yaml 

237 

238 policies: 

239 - name: onhour-morning-start-skip-holidays 

240 resource: ec2 

241 filters: 

242 - type: onhour 

243 tag: custodian_downtime 

244 default_tz: et 

245 onhour: 6 

246 skip-days: ['2017-12-25'] 

247 actions: 

248 - start 

249 

250""" 

251# note we have to module import for our testing mocks 

252import datetime 

253import logging 

254from os.path import join 

255 

256from dateutil import zoneinfo, tz as tzutil 

257 

258from c7n.exceptions import PolicyValidationError 

259from c7n.filters import Filter 

260from c7n.utils import type_schema, dumps 

261from c7n.resolver import ValuesFrom 

262 

263log = logging.getLogger('custodian.offhours') 

264 

265 

266def brackets_removed(u): 

267 return u.translate({ord('['): None, ord(']'): None}) 

268 

269 

270def parens_removed(u): 

271 return u.translate({ord('('): None, ord(')'): None}) 

272 

273 

274class Time(Filter): 

275 """ 

276 Schedule offhours for resources see :ref:`offhours <offhours>` 

277 for features and configuration. 

278 """ 

279 schema = { 

280 'type': 'object', 

281 'properties': { 

282 'tag': {'type': 'string'}, 

283 'default_tz': {'type': 'string'}, 

284 'fallback-schedule': {'type': 'string'}, 

285 'fallback_schedule': {'type': 'string'}, 

286 'weekends': {'type': 'boolean'}, 

287 'weekends-only': {'type': 'boolean'}, 

288 'opt-out': {'type': 'boolean'}, 

289 'skip-days': {'type': 'array', 'items': 

290 {'type': 'string', 'pattern': '^[0-9]{4}-[0-9]{2}-[0-9]{2}'}}, 

291 'skip-days-from': ValuesFrom.schema, 

292 } 

293 } 

294 schema_alias = True 

295 time_type = None 

296 

297 # Defaults and constants 

298 DEFAULT_TAG = "maid_offhours" 

299 DEFAULT_TZ = 'et' 

300 

301 TZ_ALIASES = { 

302 'pdt': 'America/Los_Angeles', 

303 'pt': 'America/Los_Angeles', 

304 'pst': 'America/Los_Angeles', 

305 'ast': 'America/Phoenix', 

306 'at': 'America/Phoenix', 

307 'est': 'America/New_York', 

308 'edt': 'America/New_York', 

309 'et': 'America/New_York', 

310 'cst': 'America/Chicago', 

311 'cdt': 'America/Chicago', 

312 'ct': 'America/Chicago', 

313 'mst': 'America/Denver', 

314 'mdt': 'America/Denver', 

315 'mt': 'America/Denver', 

316 'gmt': 'Etc/GMT', 

317 'gt': 'Etc/GMT', 

318 'bst': 'Europe/London', 

319 'ist': 'Europe/Dublin', 

320 'cet': 'Europe/Berlin', 

321 # Technically IST (Indian Standard Time), but that's the same as Ireland 

322 'it': 'Asia/Kolkata', 

323 'jst': 'Asia/Tokyo', 

324 'kst': 'Asia/Seoul', 

325 'sgt': 'Asia/Singapore', 

326 'aet': 'Australia/Sydney', 

327 'brt': 'America/Sao_Paulo', 

328 'nzst': 'Pacific/Auckland', 

329 'utc': 'Etc/UTC', 

330 } 

331 TAG_RESTRICTIONS = ["(", ")", "[", "]", ",", ";", "=", "/", "-"] 

332 # mapping to ['u28', 'u29', 'u5b', 'u5d', 'u2c', 'u3b', 'u3d', 'u2f', "u2d"] 

333 TAG_RESTRICTIONS_ESCAPE = ["u" + hex(ord(c))[2:] for c in TAG_RESTRICTIONS] 

334 

335 z_names = list(zoneinfo.get_zonefile_instance().zones) 

336 non_title_case_zones = ( 

337 lambda aliases=TZ_ALIASES.keys(), z_names=z_names: 

338 {z.lower(): z for z in z_names 

339 if z.title() != z and z.lower() not in aliases})() 

340 TZ_ALIASES.update(non_title_case_zones) 

341 

342 def __init__(self, data, manager=None): 

343 super(Time, self).__init__(data, manager) 

344 self.default_tz = self.data.get('default_tz', self.DEFAULT_TZ) 

345 self.weekends = self.data.get('weekends', True) 

346 self.weekends_only = self.data.get('weekends-only', False) 

347 self.opt_out = self.data.get('opt-out', False) 

348 self.tag_key = self.data.get('tag', self.DEFAULT_TAG).lower() 

349 # we originally had fallback_schedule, but the code was looking for 

350 # fallback-schedule, we want to deprecate the underscore form. 

351 self.fallback_schedule = ( 

352 self.data.get('fallback-schedule') or 

353 self.data.get('fallback_schedule') 

354 ) 

355 self.default_schedule = self.get_default_schedule() 

356 self.parser = ScheduleParser(self.default_schedule) 

357 

358 self.id_key = None 

359 

360 self.opted_out = [] 

361 self.parse_errors = [] 

362 self.enabled_count = 0 

363 

364 def validate(self): 

365 if self.get_tz(self.default_tz) is None: 

366 raise PolicyValidationError( 

367 "Invalid timezone specified %s" % ( 

368 self.default_tz)) 

369 hour = self.data.get("%shour" % self.time_type, self.DEFAULT_HR) 

370 if hour not in self.parser.VALID_HOURS: 

371 raise PolicyValidationError( 

372 "Invalid hour specified %s" % (hour,)) 

373 if 'skip-days' in self.data and 'skip-days-from' in self.data: 

374 raise PolicyValidationError( 

375 "Cannot specify two sets of skip days %s" % ( 

376 self.data,)) 

377 return self 

378 

379 def process(self, resources, event=None): 

380 resources = super(Time, self).process(resources) 

381 if self.parse_errors and self.manager and self.manager.ctx.log_dir: 

382 self.log.warning("parse errors %d", len(self.parse_errors)) 

383 with open(join( 

384 self.manager.ctx.log_dir, 'parse_errors.json'), 'w') as fh: 

385 dumps(self.parse_errors, fh=fh) 

386 self.parse_errors = [] 

387 if self.opted_out and self.manager and self.manager.ctx.log_dir: 

388 self.log.debug("disabled count %d", len(self.opted_out)) 

389 with open(join( 

390 self.manager.ctx.log_dir, 'opted_out.json'), 'w') as fh: 

391 dumps(self.opted_out, fh=fh) 

392 self.opted_out = [] 

393 return resources 

394 

395 def __call__(self, i): 

396 value = self.get_tag_value(i) 

397 # Sigh delayed init, due to circle dep, process/init would be better 

398 # but unit testing is calling this direct. 

399 if self.id_key is None: 

400 self.id_key = ( 

401 self.manager is None and 'InstanceId' or self.manager.get_model().id) 

402 

403 # The resource tag is not present, if we're not running in an opt-out 

404 # mode, we're done. 

405 if value is False: 

406 if not self.opt_out: 

407 return False 

408 value = "" # take the defaults 

409 

410 # Resource opt out, track and record 

411 if 'off' == value: 

412 self.opted_out.append(i) 

413 return False 

414 else: 

415 self.enabled_count += 1 

416 

417 try: 

418 return self.process_resource_schedule(i, value, self.time_type) 

419 except Exception: 

420 log.exception( 

421 "%s failed to process resource:%s value:%s", 

422 self.__class__.__name__, i[self.id_key], value) 

423 return False 

424 

425 def process_resource_schedule(self, i, value, time_type): 

426 """Does the resource tag schedule and policy match the current time.""" 

427 rid = i[self.id_key] 

428 # this is to normalize trailing semicolons which when done allows 

429 # dateutil.parser.parse to process: value='off=(m-f,1);' properly. 

430 # before this normalization, some cases would silently fail. 

431 value = ';'.join(filter(None, value.split(';'))) 

432 if self.parser.has_resource_schedule(value, time_type): 

433 schedule = self.parser.parse(value) 

434 elif self.parser.keys_are_valid(value): 

435 # respect timezone from tag 

436 raw_data = self.parser.raw_data(value) 

437 if 'tz' in raw_data: 

438 schedule = dict(self.default_schedule) 

439 schedule['tz'] = raw_data['tz'] 

440 else: 

441 schedule = self.default_schedule 

442 else: 

443 schedule = None 

444 if schedule is None: 

445 log.warning( 

446 "Invalid schedule on resource:%s value:%s", rid, value) 

447 self.parse_errors.append((rid, value)) 

448 return False 

449 tz = self.get_tz(schedule['tz']) 

450 if not tz: 

451 log.warning( 

452 "Could not resolve tz on resource:%s value:%s", rid, value) 

453 self.parse_errors.append((rid, value)) 

454 return False 

455 now = datetime.datetime.now(tz).replace( 

456 minute=0, second=0, microsecond=0) 

457 now_str = now.strftime("%Y-%m-%d") 

458 if 'skip-days-from' in self.data: 

459 values = ValuesFrom(self.data['skip-days-from'], self.manager) 

460 self.skip_days = values.get_values() 

461 else: 

462 self.skip_days = self.data.get('skip-days', []) 

463 if now_str in self.skip_days: 

464 return False 

465 return self.match(now, schedule) 

466 

467 def match(self, now, schedule): 

468 time = schedule.get(self.time_type, ()) 

469 for item in time: 

470 days, hour = item.get("days"), item.get('hour') 

471 if now.weekday() in days and now.hour == hour: 

472 return True 

473 return False 

474 

475 def get_tag_value(self, i): 

476 """Get the resource's tag value specifying its schedule.""" 

477 # Look for the tag, Normalize tag key and tag value 

478 found = self.fallback_schedule 

479 for t in i.get('Tags', ()): 

480 if t['Key'].lower() == self.tag_key: 

481 found = t['Value'] 

482 break 

483 # NOTE for GCP resources, eg sql-instance 

484 if found == self.fallback_schedule and 'labels' in i: 

485 found = i.get('labels', {}).get(self.tag_key) or found 

486 if found in (False, None): 

487 return False 

488 # enforce utf8, or do translate tables via unicode ord mapping 

489 value = found.lower().encode('utf8').decode('utf8') 

490 value = self.unescape_tag_restrictions(value) 

491 # Some folks seem to be interpreting the docs quote marks as 

492 # literal for values. 

493 value = value.strip("'").strip('"') 

494 return value 

495 

496 @classmethod 

497 def unescape_tag_restrictions(cls, value: str): 

498 for i, c in enumerate(cls.TAG_RESTRICTIONS_ESCAPE): 

499 value = value.replace(c, cls.TAG_RESTRICTIONS[i]) 

500 return value 

501 

502 @classmethod 

503 def get_tz(cls, tz): 

504 found = cls.TZ_ALIASES.get(tz) 

505 if found: 

506 return tzutil.gettz(found) 

507 return tzutil.gettz(tz.title()) 

508 

509 def get_default_schedule(self): 

510 raise NotImplementedError("use subclass") 

511 

512 

513class OffHour(Time): 

514 

515 schema = type_schema( 

516 'offhour', rinherit=Time.schema, required=['offhour', 'default_tz'], 

517 offhour={'type': 'integer', 'minimum': 0, 'maximum': 23}) 

518 time_type = "off" 

519 

520 DEFAULT_HR = 19 

521 

522 def get_default_schedule(self): 

523 default = {'tz': self.default_tz, self.time_type: [ 

524 {'hour': self.data.get( 

525 "%shour" % self.time_type, self.DEFAULT_HR)}]} 

526 if self.weekends_only: 

527 default[self.time_type][0]['days'] = [4] 

528 elif self.weekends: 

529 default[self.time_type][0]['days'] = tuple(range(5)) 

530 else: 

531 default[self.time_type][0]['days'] = tuple(range(7)) 

532 return default 

533 

534 

535class OnHour(Time): 

536 

537 schema = type_schema( 

538 'onhour', rinherit=Time.schema, required=['onhour', 'default_tz'], 

539 onhour={'type': 'integer', 'minimum': 0, 'maximum': 23}) 

540 time_type = "on" 

541 

542 DEFAULT_HR = 7 

543 

544 def get_default_schedule(self): 

545 default = {'tz': self.default_tz, self.time_type: [ 

546 {'hour': self.data.get( 

547 "%shour" % self.time_type, self.DEFAULT_HR)}]} 

548 if self.weekends_only: 

549 # turn on monday 

550 default[self.time_type][0]['days'] = [0] 

551 elif self.weekends: 

552 default[self.time_type][0]['days'] = tuple(range(5)) 

553 else: 

554 default[self.time_type][0]['days'] = tuple(range(7)) 

555 return default 

556 

557 

558class ScheduleParser: 

559 """Parses tag values for custom on/off hours schedules. 

560 

561 At the minimum the ``on`` and ``off`` values are required. Each of 

562 these must be seperated by a ``;`` in the format described below. 

563 

564 **Schedule format**:: 

565 

566 # up mon-fri from 7am-7pm; eastern time 

567 off=(M-F,19);on=(M-F,7) 

568 # up mon-fri from 6am-9pm; up sun from 10am-6pm; pacific time 

569 off=[(M-F,21),(U,18)];on=[(M-F,6),(U,10)];tz=pt 

570 

571 **Possible values**: 

572 

573 +------------+----------------------+ 

574 | field | values | 

575 +============+======================+ 

576 | days | M, T, W, H, F, S, U | 

577 +------------+----------------------+ 

578 | hours | 0, 1, 2, ..., 22, 23 | 

579 +------------+----------------------+ 

580 

581 Days can be specified in a range (ex. M-F). 

582 

583 If the timezone is not supplied, it is assumed ET (eastern time), but this 

584 default can be configurable. 

585 

586 **Parser output**: 

587 

588 The schedule parser will return a ``dict`` or ``None`` (if the schedule is 

589 invalid):: 

590 

591 # off=[(M-F,21),(U,18)];on=[(M-F,6),(U,10)];tz=pt 

592 { 

593 off: [ 

594 { days: "M-F", hour: 21 }, 

595 { days: "U", hour: 18 } 

596 ], 

597 on: [ 

598 { days: "M-F", hour: 6 }, 

599 { days: "U", hour: 10 } 

600 ], 

601 tz: "pt" 

602 } 

603 

604 """ 

605 

606 DAY_MAP = {'m': 0, 't': 1, 'w': 2, 'h': 3, 'f': 4, 's': 5, 'u': 6} 

607 VALID_HOURS = tuple(range(24)) 

608 

609 def __init__(self, default_schedule): 

610 self.default_schedule = default_schedule 

611 self.cache = {} 

612 

613 @staticmethod 

614 def raw_data(tag_value): 

615 """convert the tag to a dictionary, taking values as is 

616 

617 This method name and purpose are opaque... and not true. 

618 """ 

619 data = {} 

620 pieces = [] 

621 for p in tag_value.split(' '): 

622 pieces.extend(p.split(';')) 

623 # parse components 

624 for piece in pieces: 

625 kv = piece.split('=') 

626 # components must by key=value 

627 if not len(kv) == 2: 

628 continue 

629 key, value = kv 

630 data[key] = value 

631 return data 

632 

633 def keys_are_valid(self, tag_value): 

634 """test that provided tag keys are valid""" 

635 for key in ScheduleParser.raw_data(tag_value): 

636 if key not in ('on', 'off', 'tz'): 

637 return False 

638 return True 

639 

640 def parse(self, tag_value): 

641 # check the cache 

642 if tag_value in self.cache: 

643 return self.cache[tag_value] 

644 

645 schedule = {} 

646 

647 if not self.keys_are_valid(tag_value): 

648 return None 

649 # parse schedule components 

650 pieces = tag_value.split(';') 

651 for piece in pieces: 

652 kv = piece.split('=') 

653 # components must by key=value 

654 if not len(kv) == 2: 

655 return None 

656 key, value = kv 

657 if key != 'tz': 

658 value = self.parse_resource_schedule(value) 

659 if value is None: 

660 return None 

661 schedule[key] = value 

662 

663 # add default timezone, if none supplied or blank 

664 if not schedule.get('tz'): 

665 schedule['tz'] = self.default_schedule['tz'] 

666 

667 # cache 

668 self.cache[tag_value] = schedule 

669 return schedule 

670 

671 @staticmethod 

672 def has_resource_schedule(tag_value, time_type): 

673 raw_data = ScheduleParser.raw_data(tag_value) 

674 # note time_type is set to 'on' or 'off' and raw_data is a dict 

675 return time_type in raw_data 

676 

677 def parse_resource_schedule(self, lexeme): 

678 parsed = [] 

679 exprs = brackets_removed(lexeme).split(',(') 

680 for e in exprs: 

681 tokens = parens_removed(e).split(',') 

682 # custom hours must have two parts: (<days>, <hour>) 

683 if not len(tokens) == 2: 

684 return None 

685 if not tokens[1].isdigit(): 

686 return None 

687 hour = int(tokens[1]) 

688 if hour not in self.VALID_HOURS: 

689 return None 

690 days = self.expand_day_range(tokens[0]) 

691 if not days: 

692 return None 

693 parsed.append({'days': days, 'hour': hour}) 

694 return parsed 

695 

696 def expand_day_range(self, days): 

697 # single day specified 

698 if days in self.DAY_MAP: 

699 return [self.DAY_MAP[days]] 

700 day_range = [d for d in map(self.DAY_MAP.get, days.split('-')) 

701 if d is not None] 

702 if not len(day_range) == 2: 

703 return None 

704 # support wrap around days aka friday-monday = 4,5,6,0 

705 if day_range[0] > day_range[1]: 

706 return list(range(day_range[0], 7)) + list(range(day_range[1] + 1)) 

707 return list(range(min(day_range), max(day_range) + 1))