Coverage for structured_tutorials / utils.py: 100%

60 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-22 22:55 +0100

1# Copyright (c) 2025 Mathias Ertl 

2# Licensed under the MIT License. See LICENSE file for details. 

3 

4"""Utility functions.""" 

5 

6import logging 

7import os 

8import random 

9import string 

10import subprocess 

11from collections.abc import Iterator, Sized 

12from contextlib import contextmanager 

13from pathlib import Path 

14from typing import TYPE_CHECKING 

15 

16from structured_tutorials.errors import PromptNotConfirmedError, RunTutorialException, StructuredTutorialError 

17from structured_tutorials.typing import COUNT_TYPE 

18 

19if TYPE_CHECKING: 

20 from structured_tutorials.runners.base import RunnerBase 

21 

22log = logging.getLogger(__name__) 

23 

24 

25def check_count(value: Sized, count: COUNT_TYPE) -> None: 

26 """Shared function to test a count.""" 

27 if count is None: 

28 return 

29 if isinstance(count, int) and len(value) != count: 

30 raise ValueError(f"{len(value)}, but expected {count}.") 

31 if isinstance(count, tuple): 

32 if count[0] is not None and len(value) < count[0]: 

33 raise ValueError(f"{len(value)} is less then the minimum ({count[0]}).") 

34 if count[1] is not None and len(value) > count[1]: 

35 raise ValueError(f"{len(value)} is more then the maximum ({count[1]}).") 

36 

37 

38@contextmanager 

39def chdir(dest: str | Path) -> Iterator[Path]: 

40 """Context manager to temporarily switch to a different directory.""" 

41 cwd = Path.cwd() 

42 try: 

43 os.chdir(dest) 

44 yield cwd 

45 finally: 

46 os.chdir(cwd) 

47 

48 

49def _prompt(interactive: bool) -> None: 

50 if interactive: 

51 input(f"""An error occurred while running the tutorial. 

52Current working directory is {os.getcwd()} 

53 

54Press Enter to continue... """) 

55 

56 

57@contextmanager 

58def cleanup(runner: "RunnerBase") -> Iterator[None]: 

59 """Context manager to always run cleanup commands.""" 

60 try: 

61 yield 

62 except PromptNotConfirmedError as ex: 

63 # The user did not confirm a prompt. We just log this fact as warning and continue with cleanup. 

64 # We do NOT prompt the user here, as we assume the user already inspected the state. 

65 log.warning(ex) 

66 except StructuredTutorialError as ex: 

67 log.error(ex) 

68 _prompt(runner.interactive) 

69 raise RunTutorialException(ex) from ex 

70 except Exception as ex: 

71 log.exception(ex) 

72 _prompt(runner.interactive) 

73 raise RunTutorialException(ex) from ex 

74 finally: 

75 if runner.cleanup: 

76 log.info("Running cleanup commands.") 

77 

78 for command_config in runner.cleanup: 

79 runner.run_shell_command( 

80 command_config.command, 

81 command_config.show_output, 

82 environment=command_config.environment, 

83 clear_environment=command_config.clear_environment, 

84 ) 

85 

86 

87def git_export(destination: str | Path, ref: str = "HEAD") -> Path: 

88 """Export the git repository to `django-ca-{ref}/` in the given destination directory. 

89 

90 `ref` may be any valid git reference, usually a git tag. 

91 """ 

92 # Add a random suffix to the export destination to improve build isolation (e.g. Docker Compose will use 

93 # that directory name as a name for Docker images/containers). 

94 random_suffix = "".join(random.choice(string.ascii_lowercase) for i in range(12)) 

95 destination = Path(destination) / f"git-export-{ref}-{random_suffix}" 

96 

97 if not destination.exists(): # pragma: no cover # created by caller 

98 destination.mkdir(parents=True) 

99 

100 with subprocess.Popen(["git", "archive", ref], stdout=subprocess.PIPE) as git_archive_cmd: 

101 with subprocess.Popen(["tar", "-x", "-C", str(destination)], stdin=git_archive_cmd.stdout) as tar: 

102 # TYPEHINT NOTE: stdout is not None b/c of stdout=subprocess.PIPE 

103 stdout = git_archive_cmd.stdout 

104 assert stdout is not None, "stdout not captured." 

105 stdout.close() 

106 tar.communicate() 

107 

108 return destination