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
« 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.
4"""Utility functions."""
6import logging
7import os
8import random
9import string
10import subprocess
11from collections.abc import Iterator
12from contextlib import contextmanager
13from pathlib import Path
15from structured_tutorials.errors import PromptNotConfirmedError
16from structured_tutorials.runners.base import RunnerBase
18log = logging.getLogger(__name__)
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)
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)
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()}
47Press Enter to continue... """)
48 raise
49 finally:
50 if runner.cleanup:
51 log.info("Running cleanup commands.")
53 for command_config in runner.cleanup:
54 runner.run_shell_command(command_config.command, command_config.show_output)
57def git_export(destination: str | Path, ref: str = "HEAD") -> Path:
58 """Export the git repository to `django-ca-{ref}/` in the given destination directory.
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}"
67 if not destination.exists(): # pragma: no cover # created by caller
68 destination.mkdir(parents=True)
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()
78 return destination