Coverage for python/univention/config_registry/handler.py: 54%

633 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-09-11 14:11 +0000

1# 

2# main configuration registry classes 

3# 

4# SPDX-FileCopyrightText: 2004-2026 Univention GmbH 

5# SPDX-License-Identifier: AGPL-3.0-only 

6# 

7# API stability :pylint: disable-msg=R0201,W0613,R0903 

8# Too pedantic :pylint: disable-msg=W0704 

9# Rewrite :pylint: disable-msg=R0912 

10 

11"""Univention Configuration Registry handlers.""" 

12 

13 

14import errno 

15import os 

16import pickle # noqa: S403 

17import random 

18import re 

19import subprocess 

20import sys 

21from collections.abc import Iterable, Mapping 

22from grp import getgrnam 

23from pwd import getpwnam 

24from typing import IO, Any 

25 

26from univention.config_registry.misc import asciify, directory_files 

27from univention.debhelper import parseRfc822 # pylint: disable-msg=W0403 

28 

29 

30_OPT = Mapping[str, Any] 

31_UCR = Mapping[str, str] 

32_CHANGES = Mapping[str, tuple[str | None, str | None]] 

33_ARG = tuple[_UCR, _CHANGES] 

34_INFO = Mapping[str, list[str]] 

35 

36__all__ = ['ConfigHandlers'] 

37 

38VARIABLE_PATTERN = re.compile('@%@([^@]+)@%@') 

39VARIABLE_TOKEN = re.compile('@%@') 

40EXECUTE_TOKEN = re.compile(b'@!@') 

41WARNING_PATTERN = re.compile('(UCRWARNING|BCWARNING|UCRWARNING_ASCII)=(.+)') 

42 

43INFO_DIR = '/etc/univention/templates/info' 

44FILE_DIR = '/etc/univention/templates/files' 

45SCRIPT_DIR = '/etc/univention/templates/scripts' 

46MODULE_DIR = '/etc/univention/templates/modules' 

47 

48WARNING_TEXT = '''\ 

49Warning: This file is auto-generated and might be overwritten by 

50 univention-config-registry. 

51 Please edit the following file(s) instead: 

52Warnung: Diese Datei wurde automatisch generiert und kann durch 

53 univention-config-registry ueberschrieben werden. 

54 Bitte bearbeiten Sie an Stelle dessen die folgende(n) Datei(en): 

55 

56''' 

57assert asciify(WARNING_TEXT) == WARNING_TEXT, "Only ASCII allowed in WARNING_TEXT" 

58 

59 

60def run_filter(template: str, directory: _UCR, srcfiles: Iterable[str] = set(), opts: _OPT = {}) -> bytes: 

61 """ 

62 Process a template file: substitute variables. 

63 

64 :param template: Text string of template. 

65 :param directory: UCR instance. 

66 :param srcfiles: File names of source template. 

67 :param opts: Command line options. 

68 :returns: The modified template with all UCR variables and sections replaced. 

69 """ 

70 template = _replace_variables(template, directory, srcfiles) 

71 

72 tmpl = template.encode("UTF-8") 

73 

74 if opts.get('disallow-execution', False): 

75 return tmpl 

76 

77 tmpl = _replace_exec(tmpl) 

78 

79 return tmpl 

80 

81 

82def _replace_variables(template: str, directory: _UCR, srcfiles: Iterable[str]) -> str: 

83 while True: 

84 i = VARIABLE_TOKEN.finditer(template) 

85 try: 

86 start = next(i) 

87 end = next(i) 

88 name = template[start.end():end.start()] 

89 

90 if name in directory: 

91 value = directory[name] 

92 if not isinstance(value, str): 92 ↛ 94line 92 didn't jump to line 94 because the condition on line 92 was never true

93 # Python 2 with unicode value 

94 value = value.encode('UTF-8') # important! template must not be of type unicode ever (in py2), otherwise some characters are lost in the below subprocess stdinput 

95 else: 

96 match = WARNING_PATTERN.match(name) 

97 if match: 

98 mode, prefix = match.groups() 

99 value = warning_string(prefix, srcfiles=srcfiles) 

100 if mode == "UCRWARNING_ASCII": 

101 value = asciify(value) 

102 else: 

103 value = '' 

104 

105 if isinstance(value, list | tuple): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 value = value[0] 

107 template = template[:start.start()] + value + template[end.end():] 

108 except StopIteration: 

109 break 

110 

111 return template 

112 

113 

114def _replace_exec(template: bytes) -> bytes: 

115 while True: 

116 i = EXECUTE_TOKEN.finditer(template) 

117 try: 

118 start = next(i) 

119 end = next(i) 

120 

121 proc = subprocess.Popen( 

122 (sys.executable,), 

123 stdin=subprocess.PIPE, stdout=subprocess.PIPE, 

124 close_fds=True) 

125 value = proc.communicate(b'''\ 

126# -*- coding: utf-8 -*- 

127import univention.config_registry 

128configRegistry = univention.config_registry.ConfigRegistry() 

129configRegistry.load() 

130# for compatibility 

131baseConfig = configRegistry 

132%s 

133''' % template[start.end():end.start()])[0] 

134 template = template[:start.start()] + value + template[end.end():] 

135 

136 except StopIteration: 

137 break 

138 

139 return template 

140 

141 

142def run_script(script: str, arg: str, changes: _CHANGES) -> None: 

143 """ 

144 Execute script with command line arguments using a shell and pass changes 

145 on STDIN. 

146 For each changed variable a line with the 'name of the variable', the 'old 

147 value', and the 'new value' are passed separated by '@%@'. 

148 

149 :param script: File name of the script. 

150 :param arg: Execution mode, e.g. `generate` or `postinst`. 

151 :param changes: Dictionary of changed UCR variables, mapping UCR variable names to 2-tuple (old-value, new-value). 

152 """ 

153 diff = [ 

154 '%s@%%@%s@%%@%s\n' % (key, old, new) 

155 for (key, (old, new)) in changes.items() 

156 if old and new 

157 ] 

158 

159 cmd = script + " " + arg 

160 proc = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, close_fds=True) 

161 proc.communicate(''.join(diff).encode('UTF-8')) 

162 

163 

164def run_module(modpath: str, fn: str, ucr: _UCR, changes: _CHANGES) -> None: 

165 """ 

166 Load the Python module that MUST be located in :py:const:`MODULE_DIR` or any 

167 subdirectory. 

168 

169 :param modpath: The path to the module relative to :py:const:`MODULE_DIR`. 

170 :param fn: Execution mode, e.g. `handler` or `preinst` or `postinst`. 

171 :param ucr: UCR instance. 

172 :param changes: Dictionary of changed UCR variables, mapping UCR variable names to 2-tuple (old-value, new-value). 

173 """ 

174 # temporarily prepend MODULE_DIR to load path 

175 sys.path.insert(0, MODULE_DIR) 

176 module_name = os.path.splitext(modpath)[0] 

177 try: 

178 module = __import__(module_name.replace(os.path.sep, '.')) 

179 f = getattr(module, fn) 

180 f(ucr, changes) 

181 except (AttributeError, ImportError) as ex: 

182 print(ex, file=sys.stderr) 

183 del sys.path[0] 

184 

185 

186def warning_string(prefix: str = '# ', srcfiles: Iterable[str] = set()) -> str: 

187 """ 

188 Generate UCR warning text. 

189 

190 :param prefix: String to prepend before each line. 

191 :param srcfiles: File names of source template. 

192 :returns: A warning sting based on :py:const:`WARNING_TEXT`. 

193 """ 

194 res = [ 

195 '%s%s' % (prefix, line) 

196 for line in WARNING_TEXT.splitlines() 

197 ] + [ 

198 '%s\t%s' % (prefix, srcfile) 

199 for srcfile in sorted(srcfiles) 

200 ] + [ 

201 prefix, 

202 ] 

203 

204 return "\n".join(res) 

205 

206 

207class ConfigHandler: 

208 """Base class of all config handlers.""" 

209 

210 variables: set[str] = set() 

211 

212 def __call__(self, args: _ARG) -> None: 

213 raise NotImplementedError() 

214 

215 

216class ConfigHandlerDiverting(ConfigHandler): 

217 """ 

218 File diverting config handler. 

219 

220 :param to_file: Destination file name. 

221 """ 

222 

223 def __init__(self, to_file: str) -> None: 

224 super().__init__() 

225 self.to_file = os.path.join('/', to_file) 

226 self.user: int | None = None 

227 self.group: int | None = None 

228 self.mode: int | None = None 

229 self.preinst: str | None = None 

230 self.postinst: str | None = None 

231 

232 def __hash__(self): 

233 """Return unique hash.""" 

234 return hash(self.to_file) 

235 

236 def __eq__(self, other): 

237 """Compare this to other handler.""" 

238 if isinstance(other, type(self)): 238 ↛ 240line 238 didn't jump to line 240 because the condition on line 238 was always true

239 return self.to_file == other.to_file 

240 return NotImplemented 

241 

242 def __ne__(self, other): 

243 """Compare this to other handler.""" 

244 if isinstance(other, type(self)): 244 ↛ 246line 244 didn't jump to line 246 because the condition on line 244 was always true

245 return self.to_file != other.to_file 

246 return NotImplemented 

247 

248 def _set_perm(self, stat: os.stat_result | None, to_file: str | None = None) -> None: 

249 """ 

250 Set file permissions. 

251 

252 :param stat: File status. 

253 :param to_file: Destination file name. 

254 """ 

255 if not to_file: 

256 to_file = self.to_file 

257 elif self.to_file != to_file: 257 ↛ 265line 257 didn't jump to line 265 because the condition on line 257 was always true

258 try: 

259 old_stat = os.stat(self.to_file) 

260 os.chmod(to_file, old_stat.st_mode) 

261 os.chown(to_file, old_stat.st_uid, old_stat.st_gid) 

262 except OSError: 

263 pass 

264 

265 if self.user or self.group or self.mode: 

266 if self.mode: 

267 os.chmod(to_file, self.mode) 

268 

269 if self.user and self.group: 

270 os.chown(to_file, self.user, self.group) 

271 elif self.user: 

272 os.chown(to_file, self.user, 0) 

273 elif self.group: 

274 os.chown(to_file, 0, self.group) 

275 elif stat: 

276 os.chmod(to_file, stat.st_mode) 

277 

278 def _call_silent(self, *cmd: str) -> int: 

279 """ 

280 Call command with stdin, stdout, and stderr redirected from/to :file:`/dev/null`. 

281 

282 :param cmd: List of command with arguments. 

283 :returns: Process exit code. 

284 """ 

285 with open(os.path.devnull, 'w', encoding='utf-8') as null: 

286 # tell possibly wrapped dpkg-divert to really do the work 

287 env = dict(os.environ) 

288 env['DPKG_MAINTSCRIPT_PACKAGE'] = 'univention-config' 

289 return subprocess.call(cmd, stdin=null, stdout=null, stderr=null, env=env) 

290 

291 def need_divert(self) -> bool: 

292 """Check if diversion is needed.""" 

293 return False 

294 

295 def install_divert(self) -> None: 

296 """Prepare file for diversion.""" 

297 deb = '%s.debian' % self.to_file 

298 self._call_silent('dpkg-divert', '--quiet', '--rename', '--local', '--divert', deb, '--add', self.to_file) 

299 # Make sure a valid file still exists 

300 if os.path.exists(deb) and not os.path.exists(self.to_file): 300 ↛ 302line 300 didn't jump to line 302 because the condition on line 300 was never true

301 # Don't use shutil.copy2() which looses file ownership (Bug #22596) 

302 self._call_silent('cp', '-p', deb, self.to_file) 

303 

304 def uninstall_divert(self) -> bool: 

305 """ 

306 Undo diversion of file. 

307 

308 :returns: `True` because the diversion was removed. 

309 """ 

310 try: 

311 os.unlink(self.to_file) 

312 except OSError: 

313 pass 

314 deb = '%s.debian' % self.to_file 

315 self._call_silent('dpkg-divert', '--quiet', '--rename', '--local', '--divert', deb, '--remove', self.to_file) 

316 return True 

317 

318 def _temp_file_name(self) -> str: 

319 dirname, basename = os.path.split(self.to_file) 

320 filename = '.%s__ucr__commit__%s' % (basename, random.random()) 

321 return os.path.join(dirname, filename) 

322 

323 def _install_file(self, tmp_to_file: str) -> None: 

324 """ 

325 Atomically move the generated temporary file to its destination. 

326 

327 :param tmp_to_file: Path of the temporary file to move into place. 

328 :raises OSError: when the destination cannot be written, e.g. when a directory exists at the destination path. 

329 """ 

330 try: 

331 os.rename(tmp_to_file, self.to_file) 

332 except OSError as ex: 

333 if ex.errno == errno.EBUSY: 

334 with open(self.to_file, 'w+', encoding='utf-8') as fd: 

335 fd.write(open(tmp_to_file, encoding='utf-8').read()) 

336 os.unlink(tmp_to_file) 

337 else: 

338 print('E: failed to write %s: %s' % (self.to_file, ex.strerror), file=sys.stderr) 

339 raise 

340 

341 

342class ConfigHandlerMultifile(ConfigHandlerDiverting): 

343 """ 

344 Handler for multifile. 

345 

346 :param dummy_from_file: Source file name used to copy file permissions from. 

347 :param to_file: Destination file name. 

348 """ 

349 

350 def __init__(self, dummy_from_file: str, to_file: str) -> None: 

351 super().__init__(to_file) 

352 self.variables: set[str] = set() 

353 self.from_files: set[str] = set() 

354 self.dummy_from_file = dummy_from_file 

355 self.def_count = 1 

356 

357 def __setstate__(self, state): 

358 """Load state upon unpickling.""" 

359 self.__dict__.update(state) 

360 # may raise AttributeError, which forces UCR to rebuild the cache 

361 self.def_count # :pylint: disable-msg=W0104 # noqa: B018 

362 

363 def add_subfiles(self, subfiles: list[tuple[str, set[str]]]) -> None: 

364 """ 

365 Add subfiles to multifile. 

366 

367 :param subfiles: List of 2-tuples (file-name, set-of-variable-names). 

368 """ 

369 for from_file, variables in subfiles: 

370 self.from_files.add(from_file) 

371 self.variables |= variables 

372 

373 def remove_subfile(self, subfile: str) -> None: 

374 """ 

375 Remove subfile. 

376 

377 Removed diversion of set of sub-files becomes empty. 

378 """ 

379 self.from_files.discard(subfile) 

380 if not self.need_divert(): 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true

381 self.uninstall_divert() 

382 

383 def __call__(self, args: _ARG) -> None: 

384 """Generate multfile from subfile templates.""" 

385 ucr, changed = args 

386 print('Multifile: %s' % self.to_file) 

387 

388 if hasattr(self, 'preinst') and self.preinst: 

389 run_module(self.preinst, 'preinst', ucr, changed) 

390 

391 if self.def_count == 0 or not self.from_files: 

392 return 

393 

394 to_dir = os.path.dirname(self.to_file) 

395 if not os.path.isdir(to_dir): 

396 os.makedirs(to_dir, 0o755) 

397 

398 if os.path.isfile(self.dummy_from_file): 

399 stat: os.stat_result | None = os.stat(self.dummy_from_file) 

400 elif os.path.isfile(self.to_file): 

401 stat = os.stat(self.to_file) 

402 else: 

403 stat = None 

404 

405 tmp_to_file = self._temp_file_name() 

406 try: 

407 filter_opts: dict[str, Any] = {} 

408 

409 with open(tmp_to_file, 'wb') as to_fp: 

410 self._set_perm(stat, tmp_to_file) 

411 

412 for from_file in sorted(self.from_files, key=os.path.basename): 

413 try: 

414 with open(from_file, encoding='utf-8') as from_fp: 

415 to_fp.write(run_filter(from_fp.read(), ucr, srcfiles=self.from_files, opts=filter_opts)) 

416 except OSError: 

417 continue 

418 

419 self._install_file(tmp_to_file) 

420 except Exception: 

421 if os.path.exists(tmp_to_file): 

422 os.unlink(tmp_to_file) 

423 raise 

424 

425 if hasattr(self, 'postinst') and self.postinst: 

426 run_module(self.postinst, 'postinst', ucr, changed) 

427 

428 script_file = os.path.join(SCRIPT_DIR, self.to_file.strip("/")) 

429 if os.path.isfile(script_file): 

430 run_script(script_file, 'postinst', changed) 

431 

432 def need_divert(self) -> bool: 

433 """ 

434 Diversion is needed when at least one multifile and one subfile 

435 definition exists. 

436 """ 

437 return self.def_count >= 1 and bool(self.from_files) 

438 

439 def install_divert(self) -> None: 

440 """Prepare file for diversion.""" 

441 if self.need_divert(): 

442 super().install_divert() 

443 

444 def uninstall_divert(self) -> bool: 

445 """ 

446 Undo diversion of file. 

447 

448 :returns: `True` when the diversion is removed, `False` when the diversion is still needed. 

449 """ 

450 if self.need_divert(): 

451 return False 

452 return super().uninstall_divert() 

453 

454 

455class ConfigHandlerFile(ConfigHandlerDiverting): 

456 """ 

457 Handler for (single)file. 

458 

459 :param from_file: Template source file name. 

460 :param to_file: Destination file name. 

461 """ 

462 

463 def __init__(self, from_file: str, to_file: str) -> None: 

464 super().__init__(to_file) 

465 self.from_file = from_file 

466 

467 def __call__(self, args: _ARG) -> None: 

468 """Generate file from template.""" 

469 ucr, changed = args 

470 

471 if hasattr(self, 'preinst') and self.preinst: 

472 run_module(self.preinst, 'preinst', ucr, changed) 

473 

474 print('File: %s' % self.to_file) 

475 

476 to_dir = os.path.dirname(self.to_file) 

477 if not os.path.isdir(to_dir): 

478 os.makedirs(to_dir, 0o755) 

479 

480 try: 

481 stat = os.stat(self.from_file) 

482 except OSError: 

483 print("The referenced template file does not exist", file=sys.stderr) 

484 return 

485 

486 tmp_to_file = self._temp_file_name() 

487 try: 

488 filter_opts: dict[str, Any] = {} 

489 

490 with open(self.from_file, encoding='utf-8') as from_fp, open(tmp_to_file, 'wb') as to_fp: 

491 self._set_perm(stat, tmp_to_file) 

492 

493 to_fp.write(run_filter(from_fp.read(), ucr, srcfiles=[self.from_file], opts=filter_opts)) 

494 

495 self._install_file(tmp_to_file) 

496 except Exception: 

497 if os.path.exists(tmp_to_file): 

498 os.unlink(tmp_to_file) 

499 raise 

500 

501 if hasattr(self, 'postinst') and self.postinst: 

502 run_module(self.postinst, 'postinst', ucr, changed) 

503 

504 script_file = self.from_file.replace(FILE_DIR, SCRIPT_DIR) 

505 if os.path.isfile(script_file): 

506 run_script(script_file, 'postinst', changed) 

507 

508 def need_divert(self) -> bool: 

509 """For simple files the diversion is always needed.""" 

510 return True 

511 

512 

513class ConfigHandlerScript(ConfigHandler): 

514 """ 

515 Handler for UCR scripts. 

516 

517 :param script: Script file name. 

518 """ 

519 

520 def __init__(self, script: str) -> None: 

521 super().__init__() 

522 self.script = script 

523 

524 def __hash__(self): 

525 """Return unique hash.""" 

526 return hash(self.script) 

527 

528 def __eq__(self, other): 

529 """Compare this to other handler.""" 

530 if isinstance(other, type(self)): 530 ↛ 532line 530 didn't jump to line 532 because the condition on line 530 was always true

531 return self.script == other.script 

532 return NotImplemented 

533 

534 def __ne__(self, other): 

535 """Compare this to other handler.""" 

536 if isinstance(other, type(self)): 536 ↛ 538line 536 didn't jump to line 538 because the condition on line 536 was always true

537 return self.script != other.script 

538 return NotImplemented 

539 

540 def __call__(self, args: _ARG) -> None: 

541 """Call external programm after change.""" 

542 _ucr, changed = args 

543 print('Script: %s' % self.script) 

544 if os.path.isfile(self.script): 544 ↛ exitline 544 didn't return from function '__call__' because the condition on line 544 was always true

545 run_script(self.script, 'generate', changed) 

546 

547 

548class ConfigHandlerModule(ConfigHandler): 

549 """ 

550 Handler for UCR Python module. 

551 

552 :param module: Module file name. 

553 """ 

554 

555 def __init__(self, module: str) -> None: 

556 super().__init__() 

557 self.module = module 

558 

559 def __hash__(self): 

560 """Return unique hash.""" 

561 return hash(self.module) 

562 

563 def __eq__(self, other): 

564 """Compare this to other handler.""" 

565 if isinstance(other, type(self)): 565 ↛ 567line 565 didn't jump to line 567 because the condition on line 565 was always true

566 return self.module == other.module 

567 return NotImplemented 

568 

569 def __ne__(self, other): 

570 """Compare this to other handler.""" 

571 if isinstance(other, type(self)): 571 ↛ 573line 571 didn't jump to line 573 because the condition on line 571 was always true

572 return self.module != other.module 

573 return NotImplemented 

574 

575 def __call__(self, args: _ARG) -> None: 

576 """Call Python module after change.""" 

577 ucr, changed = args 

578 print('Module: %s' % self.module) 

579 run_module(self.module, 'handler', ucr, changed) 

580 

581 

582def grep_variables(text: str) -> set[str]: 

583 """ 

584 Search UCR template text for used variables. 

585 

586 :returns: Set of all variables inside `@%@` delimiters. 

587 """ 

588 return set(VARIABLE_PATTERN.findall(text)) 

589 

590 

591class ConfigHandlers: 

592 """Manage handlers for configuration variables.""" 

593 

594 CACHE_FILE = '/var/cache/univention-config/cache' 

595 # 0: without version 

596 # 1: with version header 

597 # 2: switch to handlers mapping to set, drop file, add multifile.def_count 

598 # 3: split config_registry into sub modules 

599 VERSION = 3 

600 VERSION_MIN = 3 

601 VERSION_MAX = 3 

602 VERSION_TEXT = 'univention-config cache, version' 

603 VERSION_NOTICE = '%s %s\n' % (VERSION_TEXT, VERSION) 

604 VERSION_RE = re.compile('^%s (?P<version>[0-9]+)$' % VERSION_TEXT) 

605 

606 _handlers: dict[str, set[ConfigHandler]] = {} # variable -> set(handlers) 

607 _multifiles: dict[str, ConfigHandlerMultifile] = {} # multifile -> handler 

608 _subfiles: dict[str, list[tuple[str, set[str]]]] = {} # multifile -> [(subfile, variables)] // pending 

609 

610 def __init__(self) -> None: 

611 pass 

612 

613 @staticmethod 

614 def _get_cache_version(cache_file: IO) -> int: 

615 """ 

616 Read cached `.info` data. 

617 

618 :param cache_file: Opened cache file. 

619 :returns: Version. 

620 """ 

621 line = cache_file.readline() # IOError is propagated 

622 match = ConfigHandlers.VERSION_RE.match(line) 

623 if match: 

624 version = int(match.group('version')) 

625 # "Old style" cache (version 0) doesn't contain version notice 

626 else: 

627 cache_file.seek(0) 

628 version = 0 

629 return version 

630 

631 @classmethod 

632 def _parse_rfc822_file(cls, fname: str) -> list[dict[str, list[str]]]: 

633 """ 

634 Parse :rfc:`822` file. 

635 

636 :param fname: Path to :rfc:`822` file. 

637 :returns: A list of dictionaries. 

638 """ 

639 with open(fname, encoding='utf-8') as fd: 

640 return parseRfc822(fd.read()) 

641 

642 def load(self) -> None: 

643 """Load cached `.info` data or force update.""" 

644 try: 

645 with open(ConfigHandlers.CACHE_FILE, 'rb') as cache_file: 645 ↛ exitline 645 didn't return from function 'load' because

646 version = self._get_cache_version(cache_file) 

647 chv = ConfigHandlers 

648 if not chv.VERSION_MIN <= version <= chv.VERSION_MAX: 

649 raise TypeError("Invalid cache file version.") 

650 pickler = pickle.Unpickler(cache_file) 

651 self._handlers = pickler.load() 

652 if version <= 1: 

653 # version <= 1: _handlers[multifile] -> [handlers] 

654 # version >= 2: _handlers[multifile] -> set([handlers]) 

655 self._handlers = {k: set(v) for k, v in self._handlers.items()} 

656 # version <= 1: _files UNUSED 

657 pickler.load() 

658 self._subfiles = pickler.load() 

659 self._multifiles = pickler.load() 

660 except (Exception, pickle.UnpicklingError): 

661 self.update() 

662 

663 def get_handler(self, entry: _INFO) -> ConfigHandler | None: 

664 """ 

665 Parse entry and return Handler instance. 

666 

667 :param entry: `.info` file entry dictionary. 

668 :returns: An instance of `None`. 

669 """ 

670 try: 

671 typ = entry['Type'][0] 

672 handler = getattr(self, '_get_handler_%s' % typ) 

673 except (LookupError, AttributeError): 

674 return None 

675 else: 

676 return handler(entry) 

677 

678 def _parse_common_file_handler(self, handler: ConfigHandlerDiverting, entry: _INFO) -> None: 

679 """ 

680 Parse common file and multifile entries. 

681 

682 :param handler: Handler instance. 

683 :param entry: `.info` file entry dictionary. 

684 """ 

685 try: 

686 handler.preinst = entry['Preinst'][0] 

687 except LookupError: 

688 pass 

689 

690 try: 

691 handler.postinst = entry['Postinst'][0] 

692 except LookupError: 

693 pass 

694 

695 handler.variables |= set(entry.get('Variables', set())) 

696 

697 try: 

698 user = entry['User'][0] 

699 except LookupError: 

700 pass 

701 else: 

702 try: 

703 handler.user = getpwnam(user).pw_uid 

704 except LookupError: 

705 print(('W: failed to convert the username %s to the uid' % (user,)), file=sys.stderr) 

706 

707 try: 

708 group = entry['Group'][0] 

709 except LookupError: 

710 pass 

711 else: 

712 try: 

713 handler.group = getgrnam(group).gr_gid 

714 except LookupError: 

715 print(('W: failed to convert the groupname %s to the gid' % (group,)), file=sys.stderr) 

716 

717 try: 

718 mode = entry['Mode'][0] 

719 except LookupError: 

720 pass 

721 else: 

722 try: 

723 handler.mode = int(mode, 8) 

724 except ValueError: 

725 print('W: failed to convert mode %s' % (mode,), file=sys.stderr) 

726 

727 def _get_handler_file(self, entry: _INFO) -> ConfigHandlerFile | None: 

728 """ 

729 Parse file entry and return Handler instance. 

730 

731 :param entry: `.info` file entry dictionary. 

732 :returns: An instance of `None`. 

733 """ 

734 try: 

735 name = entry['File'][0] 

736 except LookupError: 

737 return None 

738 from_path = os.path.join(FILE_DIR, name) 

739 handler = ConfigHandlerFile(from_path, name) 

740 if os.path.exists(from_path): 740 ↛ 741line 740 didn't jump to line 741 because the condition on line 740 was never true

741 with open(from_path, encoding='utf-8') as fd: 

742 handler.variables = grep_variables(fd.read()) 

743 

744 self._parse_common_file_handler(handler, entry) 

745 

746 return handler 

747 

748 def _get_handler_script(self, entry: _INFO) -> ConfigHandlerScript | None: 

749 """ 

750 Parse script entry and return Handler instance. 

751 

752 :param entry: `.info` file entry dictionary. 

753 :returns: An instance of `None`. 

754 """ 

755 try: 

756 script = entry['Script'][0] 

757 variables = entry['Variables'] 

758 except LookupError: 

759 return None 

760 handler = ConfigHandlerScript(os.path.join(SCRIPT_DIR, script)) 

761 handler.variables = set(variables) 

762 return handler 

763 

764 def _get_handler_module(self, entry: _INFO) -> ConfigHandlerModule | None: 

765 """ 

766 Parse module entry and return Handler instance. 

767 

768 :param entry: `.info` file entry dictionary. 

769 :returns: An instance of `None`. 

770 """ 

771 try: 

772 module = entry['Module'][0] 

773 variables = entry['Variables'] 

774 except LookupError: 

775 return None 

776 handler = ConfigHandlerModule(os.path.splitext(module)[0]) 

777 handler.variables = set(variables) 

778 return handler 

779 

780 def _get_handler_multifile(self, entry: _INFO) -> ConfigHandlerMultifile | None: 

781 """ 

782 Parse multifile entry and return Handler instance. 

783 

784 :param entry: `.info` file entry dictionary. 

785 :returns: An instance of `None`. 

786 """ 

787 try: 

788 mfile = entry['Multifile'][0] 

789 except LookupError: 

790 return None 

791 try: 

792 handler = self._multifiles[mfile] 

793 handler.def_count += 1 

794 except KeyError: 

795 from_path = os.path.join(FILE_DIR, mfile) 

796 handler = ConfigHandlerMultifile(from_path, mfile) 

797 

798 self._parse_common_file_handler(handler, entry) 

799 

800 # Add pending subfiles from earlier entries 

801 self._multifiles[mfile] = handler 

802 try: 

803 file_vars = self._subfiles.pop(mfile) 

804 handler.add_subfiles(file_vars) 

805 except KeyError: 

806 pass 

807 

808 return handler 

809 

810 def _get_handler_subfile(self, entry: dict[str, list[str]]) -> ConfigHandlerMultifile | None: 

811 """ 

812 Parse subfile entry and return Handler instance. 

813 

814 :param entry: `.info` file entry dictionary. 

815 :returns: An instance of `None`. 

816 """ 

817 try: 

818 mfile = entry['Multifile'][0] 

819 subfile = entry['Subfile'][0] 

820 except LookupError: 

821 return None 

822 variables = set(entry.get('Variables', set())) 

823 name = os.path.join(FILE_DIR, subfile) 

824 try: 

825 with open(name, encoding='utf-8') as temp_file: 825 ↛ 826,   825 ↛ 8302 missed branches: 1) line 825 didn't jump to line 826 because , 2) line 825 didn't jump to line 830 because

826 variables |= grep_variables(temp_file.read()) 

827 except OSError: 

828 print("Failed to process Subfile %s" % (name,), file=sys.stderr) 

829 return None 

830 qentry = (name, variables) 

831 # if multifile handler does not yet exists, queue subfiles for later 

832 try: 

833 handler = self._multifiles[mfile] 

834 handler.add_subfiles([qentry]) 

835 except KeyError: 

836 pending = self._subfiles.setdefault(mfile, []) 

837 pending.append(qentry) 

838 return None 

839 return handler 

840 

841 def update(self) -> set[ConfigHandler]: 

842 """ 

843 Parse all `.info` files to build list of handlers. 

844 

845 :returns: Set of all handlers. 

846 """ 

847 self._handlers.clear() 

848 self._multifiles.clear() 

849 self._subfiles.clear() 

850 

851 handlers: set[ConfigHandler] = set() 

852 for info in directory_files(INFO_DIR): 852 ↛ 853line 852 didn't jump to line 853 because the loop on line 852 never started

853 if not info.endswith('.info'): 

854 continue 

855 for section in self._parse_rfc822_file(info): 

856 handler = self.get_handler(section) 

857 if handler: 

858 handlers.add(handler) 

859 for handler in handlers: 859 ↛ 860line 859 didn't jump to line 860 because the loop on line 859 never started

860 for variable in handler.variables: 

861 v2h = self._handlers.setdefault(variable, set()) 

862 v2h.add(handler) 

863 

864 self._save_cache() 

865 return handlers 

866 

867 def update_divert(self, handlers: Iterable[ConfigHandler]) -> None: 

868 """ 

869 Synchronize diversions with handlers. 

870 

871 :param handlers: List of handlers. 

872 """ 

873 wanted = {h.to_file: h for h in handlers if isinstance(h, ConfigHandlerDiverting) and h.need_divert()} 

874 to_remove: set[str] = set() 

875 # Scan for diversions done by UCR 

876 with open('/var/lib/dpkg/diversions', encoding='utf-8') as div_file: 

877 # from \n to \n package \n 

878 try: 

879 while True: 

880 path_from = next(div_file).rstrip() 

881 path_to = next(div_file).rstrip() 

882 diversion = next(div_file).rstrip() 

883 if path_from + '.debian' != path_to: 

884 continue 

885 if diversion != ':': # local diversion 

886 continue 

887 assert path_from not in to_remove # no duplicates 

888 try: 

889 handler = wanted.pop(path_from) 

890 except KeyError: 

891 to_remove.add(path_from) 

892 except StopIteration: 

893 pass 

894 

895 # Remove existing diversion not wanted 

896 for path in to_remove: 

897 tmp_handler = ConfigHandlerDiverting(path) 

898 tmp_handler.uninstall_divert() 

899 # Install missing diversions still wanted 

900 for path, handler in wanted.items(): 

901 handler.install_divert() 

902 

903 def _save_cache(self) -> None: 

904 """Write cache file.""" 

905 try: 

906 with open(ConfigHandlers.CACHE_FILE, 'wb') as cache_file: 

907 cache_file.write(ConfigHandlers.VERSION_NOTICE.encode('utf-8')) 

908 pickler = pickle.Pickler(cache_file) 

909 pickler.dump(self._handlers) 

910 pickler.dump(self._subfiles) 

911 pickler.dump(self._multifiles) 

912 except OSError as ex: 

913 if ex.errno != errno.EACCES: 

914 raise 

915 

916 def register(self, package: str, ucr: _UCR) -> set[ConfigHandler]: 

917 """ 

918 Register new info file for package. 

919 

920 :param package: Name of the package to register. 

921 :param ucr: UCR instance. 

922 :returns: Set of (new) handlers. 

923 """ 

924 handlers: set[ConfigHandler] = set() 

925 fname = os.path.join(INFO_DIR, '%s.info' % package) 

926 for section in self._parse_rfc822_file(fname): 

927 handler = self.get_handler(section) 

928 if handler: 

929 handlers.add(handler) 

930 

931 for handler in handlers: 

932 if isinstance(handler, ConfigHandlerDiverting): 

933 handler.install_divert() 

934 

935 values: dict[str, tuple[None, str | None]] = {} 

936 for variable in handler.variables: 

937 v2h = self._handlers.setdefault(variable, set()) 

938 v2h.add(handler) 

939 values[variable] = (None, ucr[variable]) 

940 try: 

941 _re = re.compile(variable) 

942 except re.error: 

943 continue 

944 

945 values.update((key, (None, val)) for key, val in ucr.items() if _re.match(key)) 

946 

947 handler((ucr, values)) 

948 

949 self._save_cache() 

950 return handlers 

951 

952 def unregister(self, package: str, ucr: _UCR) -> set[ConfigHandler]: 

953 """ 

954 Un-register info file for package. 

955 

956 :param package: Name of the package to un-register. 

957 :param ucr: UCR instance. 

958 :returns: Set of (then obsolete) handlers. 

959 """ 

960 obsolete_handlers: set[ConfigHandler] = set() 

961 mf_handlers: set[ConfigHandler] = set() # Remaining Multifile handlers 

962 fname = os.path.join(INFO_DIR, '%s.info' % package) 

963 for section in self._parse_rfc822_file(fname): 

964 try: 

965 typ = section['Type'][0] 

966 except LookupError: 

967 continue 

968 if typ == 'file': 

969 handler = self.get_handler(section) 

970 elif typ == 'subfile': 

971 mfile = section['Multifile'][0] 

972 sfile = section['Subfile'][0] 

973 try: 

974 handler = self._multifiles[mfile] 

975 except KeyError: 

976 continue # skip SubFile w/o MultiFile 

977 name = os.path.join(FILE_DIR, sfile) 

978 handler.remove_subfile(name) 

979 mf_handlers.add(handler) 

980 elif typ == 'multifile': 

981 mfile = section['Multifile'][0] 

982 handler = self._multifiles[mfile] 

983 handler.def_count -= 1 

984 mf_handlers.add(handler) 

985 else: 

986 continue 

987 if not handler: # Bug #17913 

988 print(("Skipping internal error: no handler for %r in %s" % (section, package)), file=sys.stderr) 

989 continue 

990 if isinstance(handler, ConfigHandlerDiverting) and handler.uninstall_divert(): 

991 obsolete_handlers.add(handler) 

992 

993 for handler in mf_handlers - obsolete_handlers: 

994 self.call_handler(ucr, handler) 

995 

996 try: 

997 # remove cache file to force rebuild of cache 

998 os.unlink(ConfigHandlers.CACHE_FILE) 

999 except OSError: 

1000 pass 

1001 return obsolete_handlers 

1002 

1003 def __call__(self, variables: Iterable[str], arg: _ARG) -> None: 

1004 """ 

1005 Call handlers registered for changes in variables. 

1006 

1007 :param variables: Changed UCR variable names. 

1008 :param arg: 2-tuple(UCR-instance, changed) where changed is a dictionary mapping ucs-variable-names to values. 

1009 """ 

1010 if not variables: 

1011 return 

1012 pending_handlers: set[ConfigHandler] = set() 

1013 

1014 for reg_var, handlers in self._handlers.items(): 1014 ↛ 1015line 1014 didn't jump to line 1015 because the loop on line 1014 never started

1015 try: 

1016 _re = re.compile(reg_var) 

1017 except re.error as ex: 

1018 print('Failed to compile regular expression %s: %s' % (reg_var, ex), file=sys.stderr) 

1019 continue 

1020 

1021 for variable in variables: 

1022 if _re.match(variable): 

1023 pending_handlers |= handlers 

1024 

1025 for handler in pending_handlers: 1025 ↛ 1026line 1025 didn't jump to line 1026 because the loop on line 1025 never started

1026 handler(arg) 

1027 

1028 def commit(self, ucr: _UCR, filelist: Iterable[str] = []) -> None: 

1029 """ 

1030 Call handlers to (re-)generate files. 

1031 

1032 :param ucr: UCR instance. 

1033 :param filelist: List of files to re-generate. By default *all* files will be re-generated and all modules and scripts will we re-invoked! 

1034 """ 

1035 _filelist = [] 

1036 for fname in filelist: 

1037 fname = os.path.expanduser(fname) 

1038 fname = os.path.expandvars(fname) 

1039 fname = os.path.abspath(fname) 

1040 _filelist.append(fname) 

1041 

1042 # find handlers 

1043 pending_handlers = set() 

1044 for fname in directory_files(INFO_DIR): 1044 ↛ 1045line 1044 didn't jump to line 1045 because the loop on line 1044 never started

1045 if not fname.endswith('.info'): 

1046 continue 

1047 for section in self._parse_rfc822_file(fname): 

1048 if not section.get('Type'): 

1049 continue 

1050 handler = None 

1051 if _filelist: 

1052 files = section.get('File') or section.get('Multifile') or () 

1053 for filename in files: 

1054 if not os.path.isabs(filename): 

1055 filename = '/%s' % filename 

1056 if filename in _filelist: 

1057 handler = self.get_handler(section) 

1058 break 

1059 else: 

1060 continue 

1061 else: 

1062 handler = self.get_handler(section) 

1063 

1064 if handler: 

1065 pending_handlers.add(handler) 

1066 

1067 # print missing files 

1068 for fname in set(_filelist) - {h.to_file for h in pending_handlers if isinstance(h, ConfigHandlerDiverting)}: 

1069 print('Warning: The file %r is not registered as an UCR template.' % (fname,), file=sys.stderr) 

1070 

1071 # call handlers 

1072 for handler in pending_handlers: 1072 ↛ 1073line 1072 didn't jump to line 1073 because the loop on line 1072 never started

1073 self.call_handler(ucr, handler) 

1074 

1075 def call_handler(self, ucr: _UCR, handler: ConfigHandler) -> None: 

1076 """ 

1077 Call handler passing current configuration variables. 

1078 

1079 :param ucr: UCR instance. 

1080 :param handler: The handler to call. 

1081 """ 

1082 values: dict[str, tuple[str | None, str | None]] = {} 

1083 for variable in handler.variables: 

1084 if variable in self._handlers.keys(): 

1085 if ".*" in variable: 

1086 for i in range(4): 

1087 var = variable.replace(".*", "%s" % i) 

1088 val = ucr.get(var) 

1089 values[var] = (val, val) 

1090 else: 

1091 val = ucr.get(variable) 

1092 values[variable] = (val, val) 

1093 handler((ucr, values))