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
« 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.
4"""Utility functions."""
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
16from structured_tutorials.errors import PromptNotConfirmedError, RunTutorialException, StructuredTutorialError
17from structured_tutorials.typing import COUNT_TYPE
19if TYPE_CHECKING:
20 from structured_tutorials.runners.base import RunnerBase
22log = logging.getLogger(__name__)
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]}).")
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)
49def _prompt(interactive: bool) -> None:
50 if interactive:
51 input(f"""An error occurred while running the tutorial.
52Current working directory is {os.getcwd()}
54Press Enter to continue... """)
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.")
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 )
87def git_export(destination: str | Path, ref: str = "HEAD") -> Path:
88 """Export the git repository to `django-ca-{ref}/` in the given destination directory.
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}"
97 if not destination.exists(): # pragma: no cover # created by caller
98 destination.mkdir(parents=True)
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()
108 return destination