Coverage for structured_tutorials / utils.py: 100%

42 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-21 19:08 +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 

12from contextlib import contextmanager 

13from pathlib import Path 

14 

15from structured_tutorials.errors import PromptNotConfirmedError 

16from structured_tutorials.runners.base import RunnerBase 

17 

18log = logging.getLogger(__name__) 

19 

20 

21@contextmanager 

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

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

24 cwd = Path.cwd() 

25 try: 

26 os.chdir(dest) 

27 yield cwd 

28 finally: 

29 os.chdir(cwd) 

30 

31 

32@contextmanager 

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

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

35 try: 

36 yield 

37 except Exception as ex: 

38 # Prompt the user to inspect the state if running in interactive mode AND the error is not already a 

39 # prompt confirmation prompt (in which case we assume the user already inspected the state). 

40 if not isinstance(ex, PromptNotConfirmedError): 

41 log.exception(ex) 

42 

43 if runner.interactive and not isinstance(ex, PromptNotConfirmedError): 

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

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

46 

47Press Enter to continue... """) 

48 raise 

49 finally: 

50 if runner.cleanup: 

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

52 

53 for command_config in runner.cleanup: 

54 runner.run_shell_command(command_config.command, command_config.show_output) 

55 

56 

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

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

59 

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

61 """ 

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

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

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

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

66 

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

68 destination.mkdir(parents=True) 

69 

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

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

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

73 stdout = git_archive_cmd.stdout 

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

75 stdout.close() 

76 tar.communicate() 

77 

78 return destination